diff --git a/apps/web/src/components/AccountControls.tsx b/apps/web/src/components/AccountControls.tsx
index 28dd0c9..fd0db96 100644
--- a/apps/web/src/components/AccountControls.tsx
+++ b/apps/web/src/components/AccountControls.tsx
@@ -92,6 +92,11 @@ export function AccountControls({ sync }: { sync: SyncState }) {
)}
{sync.status &&
{sync.status}
}
+ {sync.desktopHandoff && (
+
+ Open the Cascade app to finish signing in
+
+ )}
);
}
diff --git a/apps/web/src/sync/useSync.ts b/apps/web/src/sync/useSync.ts
index 97d3442..1433e7a 100644
--- a/apps/web/src/sync/useSync.ts
+++ b/apps/web/src/sync/useSync.ts
@@ -19,6 +19,8 @@ export interface SyncState {
account: Account | null;
status: string | null;
busy: boolean;
+ /** cascade:// deep link when handing a sign-in off to the Windows app. */
+ desktopHandoff: string | null;
signIn: (email: string) => Promise;
signOut: () => Promise;
deleteData: () => Promise;
@@ -66,6 +68,9 @@ export function useSync(
);
const [status, setStatus] = useState(null);
const [busy, setBusy] = useState(false);
+ // A cascade:// deep link when this page is handing a sign-in off to the
+ // Windows desktop app (links minted with &app=windows); null otherwise.
+ const [desktopHandoff, setDesktopHandoff] = useState(null);
const syncingRef = useRef(false);
// Latest listening figures, kept in a ref so the sync callback is stable.
const deviceTotalRef = useRef(0);
@@ -120,8 +125,27 @@ export function useSync(
const url = new URL(window.location.href);
const token = url.searchParams.get("token");
if (!token) return;
+
+ // Links minted for the Windows app carry &app=windows: hand the token off
+ // to the desktop app via the cascade:// protocol instead of verifying here,
+ // since the single-use token can only be redeemed once. We surface the deep
+ // link for the user to confirm (and attempt it automatically) rather than
+ // burning it on the web.
+ const isWindowsHandoff = url.searchParams.get("app") === "windows";
url.searchParams.delete("token");
+ url.searchParams.delete("app");
window.history.replaceState({}, "", url.toString());
+
+ if (isWindowsHandoff) {
+ const deepLink = `cascade://auth?token=${encodeURIComponent(token)}`;
+ setDesktopHandoff(deepLink);
+ setStatus("Opening the Cascade app to finish signing in…");
+ // Best-effort auto-launch; the visible link is the reliable fallback if
+ // the browser blocks programmatic protocol navigation.
+ window.location.href = deepLink;
+ return;
+ }
+
setBusy(true);
setStatus("Signing in…");
api
@@ -235,6 +259,7 @@ export function useSync(
account,
status,
busy,
+ desktopHandoff,
signIn,
signOut,
deleteData,
diff --git a/apps/windows/Cascade/App.xaml.cs b/apps/windows/Cascade/App.xaml.cs
index 8503752..77085b6 100644
--- a/apps/windows/Cascade/App.xaml.cs
+++ b/apps/windows/Cascade/App.xaml.cs
@@ -1,4 +1,6 @@
+using System;
using Microsoft.UI.Xaml;
+using Microsoft.Windows.AppLifecycle;
namespace Cascade;
@@ -15,5 +17,36 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
{
Window = new MainWindow();
Window.Activate();
+
+ // If this launch itself came from a cascade://auth?token=… link, finish
+ // the sign-in now that the window (and its view-model) exist.
+ var uri = Program.ExtractProtocolUri(AppInstance.GetCurrent().GetActivatedEventArgs());
+ if (uri is not null)
+ {
+ HandleProtocolActivation(uri);
+ }
+ }
+
+ ///
+ /// Route a cascade://auth?token=… activation — whether it launched
+ /// the app or was redirected here from a second instance — to the running
+ /// window's view-model, on the UI thread. The view-model's existing
+ /// SignInWithLinkAsync extracts the token and verifies it.
+ ///
+ public static void HandleProtocolActivation(Uri uri)
+ {
+ var window = Window;
+ if (window is null || !IsAuthUri(uri)) return;
+
+ window.DispatcherQueue.TryEnqueue(() =>
+ {
+ window.BringToFront();
+ _ = window.ViewModel.SignInWithLinkAsync(uri.OriginalString);
+ });
}
+
+ /// Accept only auth handoffs (host "auth" or a token query).
+ private static bool IsAuthUri(Uri uri) =>
+ string.Equals(uri.Host, "auth", StringComparison.OrdinalIgnoreCase) ||
+ uri.OriginalString.Contains("token=", StringComparison.OrdinalIgnoreCase);
}
diff --git a/apps/windows/Cascade/Cascade.csproj b/apps/windows/Cascade/Cascade.csproj
index 10ea72c..871ff3f 100644
--- a/apps/windows/Cascade/Cascade.csproj
+++ b/apps/windows/Cascade/Cascade.csproj
@@ -20,6 +20,9 @@
don't compile, so keep the field syntax and silence the forward-compat
nudge until this target moves to .NET 9. -->
$(NoWarn);MVVMTK0045
+
+ $(DefineConstants);DISABLE_XAML_GENERATED_MAIN
en-US
false
diff --git a/apps/windows/Cascade/Converters.cs b/apps/windows/Cascade/Converters.cs
index 2e7a0af..2113169 100644
--- a/apps/windows/Cascade/Converters.cs
+++ b/apps/windows/Cascade/Converters.cs
@@ -30,10 +30,4 @@ public static string TrackingLabel(bool enabled) =>
public static Visibility VisibleIf(bool b) =>
b ? Visibility.Visible : Visibility.Collapsed;
-
- public static Visibility VisibleIfSignedIn(Account? account) =>
- account is not null ? Visibility.Visible : Visibility.Collapsed;
-
- public static Visibility VisibleIfSignedOut(Account? account) =>
- account is null ? Visibility.Visible : Visibility.Collapsed;
}
diff --git a/apps/windows/Cascade/MainWindow.xaml b/apps/windows/Cascade/MainWindow.xaml
index 16e5cc3..1715611 100644
--- a/apps/windows/Cascade/MainWindow.xaml
+++ b/apps/windows/Cascade/MainWindow.xaml
@@ -168,7 +168,7 @@
+ Visibility="{x:Bind ViewModel.SignedOutVisibility, Mode=OneWay}">
+ Visibility="{x:Bind ViewModel.SignedInVisibility, Mode=OneWay}">
diff --git a/apps/windows/Cascade/MainWindow.xaml.cs b/apps/windows/Cascade/MainWindow.xaml.cs
index 679085e..f6b8590 100644
--- a/apps/windows/Cascade/MainWindow.xaml.cs
+++ b/apps/windows/Cascade/MainWindow.xaml.cs
@@ -35,6 +35,27 @@ private void SetWindowIcon()
Microsoft.UI.Windowing.AppWindow.GetFromWindowId(windowId).SetIcon(iconPath);
}
+ [System.Runtime.InteropServices.DllImport("user32.dll")]
+ private static extern bool SetForegroundWindow(IntPtr hWnd);
+
+ [System.Runtime.InteropServices.DllImport("user32.dll")]
+ private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
+
+ private const int SW_RESTORE = 9;
+
+ ///
+ /// Restore and foreground the window after a cascade:// deep-link
+ /// activation, so a sign-in handoff surfaces the app even if it was
+ /// minimized or behind other windows.
+ ///
+ public void BringToFront()
+ {
+ var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
+ ShowWindow(hwnd, SW_RESTORE);
+ SetForegroundWindow(hwnd);
+ Activate();
+ }
+
///
/// Slider raises ValueChanged on every motion frame; ignore the initial
/// load callback (where IntermediateValue == OldValue) so we don't
diff --git a/apps/windows/Cascade/Program.cs b/apps/windows/Cascade/Program.cs
new file mode 100644
index 0000000..fd2fb45
--- /dev/null
+++ b/apps/windows/Cascade/Program.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Cascade.Services;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.Windows.AppLifecycle;
+using Windows.ApplicationModel.Activation;
+
+namespace Cascade;
+
+///
+/// Hand-written entry point (the XAML-generated Main is disabled via
+/// DISABLE_XAML_GENERATED_MAIN) so we can be single-instance and handle
+/// cascade:// protocol activation. A magic-link handoff
+/// (cascade://auth?token=…) should sign into the already-running
+/// app rather than spawning a second window, so a second launch redirects its
+/// activation to the first instance and exits.
+///
+public static class Program
+{
+ [STAThread]
+ private static void Main()
+ {
+ WinRT.ComWrappersSupport.InitializeComWrappers();
+
+ // Make cascade:// resolve to this exe (idempotent, per-user, no admin).
+ ProtocolRegistration.EnsureRegistered();
+
+ if (DecideRedirection())
+ {
+ // We handed our activation to the primary instance — nothing to run.
+ return;
+ }
+
+ Application.Start(_ =>
+ {
+ var context = new DispatcherQueueSynchronizationContext(
+ DispatcherQueue.GetForCurrentThread());
+ SynchronizationContext.SetSynchronizationContext(context);
+ // App registers itself as Application.Current in its constructor.
+ new App();
+ });
+ }
+
+ ///
+ /// Register this process as the single-instance key owner. If another
+ /// instance already owns it, forward our activation args to it and report
+ /// that we redirected (so Main exits without starting a second app).
+ ///
+ private static bool DecideRedirection()
+ {
+ var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs();
+ var keyInstance = AppInstance.FindOrRegisterForKey("cascade-main");
+
+ if (keyInstance.IsCurrent)
+ {
+ keyInstance.Activated += OnActivated;
+ return false;
+ }
+
+ RedirectActivationTo(activationArgs, keyInstance);
+ return true;
+ }
+
+ /// Primary instance received a redirected activation from a second launch.
+ private static void OnActivated(object? sender, AppActivationArguments args)
+ {
+ var uri = ExtractProtocolUri(args);
+ if (uri is not null)
+ {
+ App.HandleProtocolActivation(uri);
+ }
+ }
+
+ /// Pull the activating URI out of a protocol activation, or null.
+ public static Uri? ExtractProtocolUri(AppActivationArguments args)
+ {
+ if (args.Kind == ExtendedActivationKind.Protocol &&
+ args.Data is IProtocolActivatedEventArgs protocolArgs)
+ {
+ return protocolArgs.Uri;
+ }
+
+ // Defensive fallback: if an activation path delivers the URI as a raw
+ // launch argument (Launch kind) instead of a parsed Protocol activation,
+ // recover it from the command line so the handoff still works.
+ if (args.Kind == ExtendedActivationKind.Launch &&
+ args.Data is ILaunchActivatedEventArgs launchArgs)
+ {
+ return FindCascadeUri(launchArgs.Arguments);
+ }
+ return null;
+ }
+
+ private static Uri? FindCascadeUri(string? arguments)
+ {
+ if (string.IsNullOrWhiteSpace(arguments)) return null;
+ foreach (var part in arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var token = part.Trim('"');
+ if (token.StartsWith($"{ProtocolRegistration.Scheme}://", StringComparison.OrdinalIgnoreCase) &&
+ Uri.TryCreate(token, UriKind.Absolute, out var uri))
+ {
+ return uri;
+ }
+ }
+ return null;
+ }
+
+ // --- activation-redirect plumbing ---------------------------------------
+ // RedirectActivationToAsync must be awaited, but Main runs on the STA UI
+ // thread where a plain .Wait() would deadlock the COM call. Per the WinAppSDK
+ // single-instancing sample, run the redirect on a thread-pool thread and pump
+ // COM on this one until it signals.
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr CreateEvent(
+ IntPtr lpEventAttributes, bool bManualReset, bool bInitialState, string? lpName);
+
+ [DllImport("kernel32.dll")]
+ private static extern bool SetEvent(IntPtr hEvent);
+
+ [DllImport("ole32.dll")]
+ private static extern uint CoWaitForMultipleObjects(
+ uint dwFlags, uint dwMilliseconds, ulong nHandles, IntPtr[] pHandles, out uint dwpIndex);
+
+ private const uint CWMO_DEFAULT = 0;
+ private const uint INFINITE = 0xFFFFFFFF;
+
+ private static void RedirectActivationTo(AppActivationArguments args, AppInstance keyInstance)
+ {
+ var redirectSemaphore = CreateEvent(IntPtr.Zero, true, false, null);
+ _ = Task.Run(() =>
+ {
+ keyInstance.RedirectActivationToAsync(args).AsTask().Wait();
+ SetEvent(redirectSemaphore);
+ });
+ _ = CoWaitForMultipleObjects(
+ CWMO_DEFAULT, INFINITE, 1, new[] { redirectSemaphore }, out _);
+ }
+}
diff --git a/apps/windows/Cascade/Services/ProtocolRegistration.cs b/apps/windows/Cascade/Services/ProtocolRegistration.cs
new file mode 100644
index 0000000..4543da3
--- /dev/null
+++ b/apps/windows/Cascade/Services/ProtocolRegistration.cs
@@ -0,0 +1,43 @@
+using System;
+using Microsoft.Windows.AppLifecycle;
+
+namespace Cascade.Services;
+
+///
+/// Registers the cascade:// URI scheme so a magic-link handoff
+/// (cascade://auth?token=…) launches or focuses this app.
+///
+/// We use WinAppSDK's rather than a
+/// hand-written HKCU\Software\Classes command: the manual key launches
+/// the exe with the URI as a raw argument, which WinAppSDK surfaces as a plain
+/// Launch activation (no parsed Uri). Registering through the
+/// manager instead makes activations arrive as
+///
+/// with the Uri populated — and it works for unpackaged apps, writing a
+/// per-user registration (no admin, WDAC-safe).
+///
+public static class ProtocolRegistration
+{
+ public const string Scheme = "cascade";
+
+ ///
+ /// Ensure cascade:// resolves to this app. Idempotent and best-effort
+ /// — any failure is swallowed so it never blocks startup (deep-link sign-in
+ /// is a convenience, not a requirement).
+ ///
+ public static void EnsureRegistered()
+ {
+ try
+ {
+ ActivationRegistrationManager.RegisterForProtocolActivation(
+ Scheme,
+ logo: string.Empty,
+ displayName: "Cascade",
+ exePath: string.Empty); // empty => the current executable
+ }
+ catch
+ {
+ // Best-effort; deep links just won't work until a successful run.
+ }
+ }
+}
diff --git a/apps/windows/Cascade/Services/SyncApi.cs b/apps/windows/Cascade/Services/SyncApi.cs
index c93d588..0e41914 100644
--- a/apps/windows/Cascade/Services/SyncApi.cs
+++ b/apps/windows/Cascade/Services/SyncApi.cs
@@ -27,7 +27,10 @@ public sealed class SyncApi
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) };
public Task RequestLinkAsync(string email) =>
- PostAsync("/auth/request", new { email }, null);
+ // The "windows" hint makes the emailed link carry &app=windows, so the
+ // web /auth page hands the token to this app via cascade:// rather than
+ // consuming it in the browser.
+ PostAsync("/auth/request", new { email, platform = "windows" }, null);
public async Task VerifyAsync(string token)
{
diff --git a/apps/windows/Cascade/ViewModels/AppViewModel.cs b/apps/windows/Cascade/ViewModels/AppViewModel.cs
index 34615ce..09ae77c 100644
--- a/apps/windows/Cascade/ViewModels/AppViewModel.cs
+++ b/apps/windows/Cascade/ViewModels/AppViewModel.cs
@@ -5,6 +5,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
namespace Cascade.ViewModels;
@@ -56,7 +57,22 @@ public sealed partial class AppViewModel : ObservableObject, IDisposable
public string AccountEmail => Account?.Email ?? "";
- partial void OnAccountChanged(Account? value) => OnPropertyChanged(nameof(AccountEmail));
+ // Bound directly (not via an x:Bind function) so the signed-in / signed-out
+ // panels flip whenever Account changes at runtime — function bindings on
+ // Account proved not to re-evaluate, stranding the view after sign-out / a
+ // 401. Explicit notification in OnAccountChanged drives these.
+ public Visibility SignedInVisibility =>
+ Account is not null ? Visibility.Visible : Visibility.Collapsed;
+
+ public Visibility SignedOutVisibility =>
+ Account is not null ? Visibility.Collapsed : Visibility.Visible;
+
+ partial void OnAccountChanged(Account? value)
+ {
+ OnPropertyChanged(nameof(AccountEmail));
+ OnPropertyChanged(nameof(SignedInVisibility));
+ OnPropertyChanged(nameof(SignedOutVisibility));
+ }
public AppViewModel(DispatcherQueue dispatcher)
{
@@ -223,9 +239,15 @@ private async Task SyncAsync()
}
catch (SyncHttpException e) when (e.Status == 401)
{
- Account = null;
- _accountStore.ClearAccount();
- SyncStatus = "Signed out — sign in again to sync.";
+ // The await above may resume off the UI thread; marshal the account
+ // mutation back so the bound visibility actually updates (and we
+ // never touch observable state from a background thread).
+ _dispatcher.TryEnqueue(() =>
+ {
+ Account = null;
+ _accountStore.ClearAccount();
+ SyncStatus = "Signed out — sign in again to sync.";
+ });
}
catch
{
@@ -253,6 +275,17 @@ private async Task RequestLink()
}
}
+ ///
+ /// Entry point for a cascade://auth?token=… deep link: reuse the
+ /// exact paste-and-sign-in path so the link handoff and manual paste behave
+ /// identically (same token extraction, verify, persist, and status text).
+ ///
+ public Task SignInWithLinkAsync(string link)
+ {
+ SignInLinkInput = link;
+ return CompleteSignInCommand.ExecuteAsync(null);
+ }
+
[RelayCommand]
private async Task CompleteSignIn()
{
diff --git a/server/src/main.rs b/server/src/main.rs
index 356518e..1e2654f 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -241,8 +241,14 @@ async fn aggregate_total_ms(pool: &PgPool, user_id: Uuid) -> AppResult {
// ---- request / response shapes ------------------------------------------
#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
struct AuthRequest {
email: String,
+ /// Optional client hint. When "windows", the emailed link carries
+ /// `&app=windows` so the web `/auth` page hands the token off to the
+ /// desktop app via `cascade://` instead of consuming it in the browser.
+ #[serde(default)]
+ platform: Option,
}
#[derive(Deserialize)]
@@ -304,7 +310,17 @@ async fn auth_request(
.bind(expires_at)
.execute(&state.pool)
.await?;
- let link = format!("{}/auth?token={}", state.frontend_origin, token);
+ // A "windows" hint tags the link so the web /auth page offers a desktop
+ // handoff (cascade://) rather than signing in on the web and burning the
+ // single-use token. Any other/absent value keeps the plain web link.
+ let app_suffix = match body.platform.as_deref() {
+ Some("windows") => "&app=windows",
+ _ => "",
+ };
+ let link = format!(
+ "{}/auth?token={}{}",
+ state.frontend_origin, token, app_suffix
+ );
if let Err(e) = state.mailer.send_login_link(&email, &link).await {
// Don't fail the request — log and still return 200. A retry just mints
// another link.