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.