Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/web/src/components/AccountControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export function AccountControls({ sync }: { sync: SyncState }) {
</form>
)}
{sync.status && <p className="account__status">{sync.status}</p>}
{sync.desktopHandoff && (
<a className="account__link" href={sync.desktopHandoff}>
Open the Cascade app to finish signing in
</a>
)}
</div>
);
}
25 changes: 25 additions & 0 deletions apps/web/src/sync/useSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
signOut: () => Promise<void>;
deleteData: () => Promise<void>;
Expand Down Expand Up @@ -66,6 +68,9 @@ export function useSync(
);
const [status, setStatus] = useState<string | null>(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<string | null>(null);
const syncingRef = useRef(false);
// Latest listening figures, kept in a ref so the sync callback is stable.
const deviceTotalRef = useRef(0);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -235,6 +259,7 @@ export function useSync(
account,
status,
busy,
desktopHandoff,
signIn,
signOut,
deleteData,
Expand Down
33 changes: 33 additions & 0 deletions apps/windows/Cascade/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using Microsoft.UI.Xaml;
using Microsoft.Windows.AppLifecycle;

namespace Cascade;

Expand All @@ -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);
}
}

/// <summary>
/// Route a <c>cascade://auth?token=…</c> 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
/// <c>SignInWithLinkAsync</c> extracts the token and verifies it.
/// </summary>
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);
});
}

/// <summary>Accept only auth handoffs (host "auth" or a token query).</summary>
private static bool IsAuthUri(Uri uri) =>
string.Equals(uri.Host, "auth", StringComparison.OrdinalIgnoreCase) ||
uri.OriginalString.Contains("token=", StringComparison.OrdinalIgnoreCase);
}
3 changes: 3 additions & 0 deletions apps/windows/Cascade/Cascade.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -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>$(NoWarn);MVVMTK0045</NoWarn>
<!-- Provide our own entry point (Program.Main) for single-instancing and
cascade:// protocol activation; disable the XAML-generated Main. -->
<DefineConstants>$(DefineConstants);DISABLE_XAML_GENERATED_MAIN</DefineConstants>
<!-- Set this to your unique app identifier when you turn on signing. -->
<DefaultLanguage>en-US</DefaultLanguage>
<AppxPackageSigningEnabled>false</AppxPackageSigningEnabled>
Expand Down
6 changes: 0 additions & 6 deletions apps/windows/Cascade/Converters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
4 changes: 2 additions & 2 deletions apps/windows/Cascade/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@
<!-- Signed out -->
<StackPanel
Spacing="6"
Visibility="{x:Bind local:Converters.VisibleIfSignedOut(ViewModel.Account), Mode=OneWay}">
Visibility="{x:Bind ViewModel.SignedOutVisibility, Mode=OneWay}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox
PlaceholderText="you@example.com"
Expand All @@ -188,7 +188,7 @@
<!-- Signed in -->
<StackPanel
Spacing="6"
Visibility="{x:Bind local:Converters.VisibleIfSignedIn(ViewModel.Account), Mode=OneWay}">
Visibility="{x:Bind ViewModel.SignedInVisibility, Mode=OneWay}">
<TextBlock Text="{x:Bind ViewModel.AccountEmail, Mode=OneWay}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<HyperlinkButton Content="Sign out" Command="{x:Bind ViewModel.SignOutCommand}" />
Expand Down
21 changes: 21 additions & 0 deletions apps/windows/Cascade/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// Restore and foreground the window after a <c>cascade://</c> deep-link
/// activation, so a sign-in handoff surfaces the app even if it was
/// minimized or behind other windows.
/// </summary>
public void BringToFront()
{
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
ShowWindow(hwnd, SW_RESTORE);
SetForegroundWindow(hwnd);
Activate();
}

/// <summary>
/// Slider raises ValueChanged on every motion frame; ignore the initial
/// load callback (where IntermediateValue == OldValue) so we don't
Expand Down
142 changes: 142 additions & 0 deletions apps/windows/Cascade/Program.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Hand-written entry point (the XAML-generated Main is disabled via
/// <c>DISABLE_XAML_GENERATED_MAIN</c>) so we can be single-instance and handle
/// <c>cascade://</c> protocol activation. A magic-link handoff
/// (<c>cascade://auth?token=…</c>) should sign into the <i>already-running</i>
/// app rather than spawning a second window, so a second launch redirects its
/// activation to the first instance and exits.
/// </summary>
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();
});
}

/// <summary>
/// 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).
/// </summary>
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;
}

/// <summary>Primary instance received a redirected activation from a second launch.</summary>
private static void OnActivated(object? sender, AppActivationArguments args)
{
var uri = ExtractProtocolUri(args);
if (uri is not null)
{
App.HandleProtocolActivation(uri);
}
}

/// <summary>Pull the activating URI out of a protocol activation, or null.</summary>
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 _);
}
}
43 changes: 43 additions & 0 deletions apps/windows/Cascade/Services/ProtocolRegistration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using Microsoft.Windows.AppLifecycle;

namespace Cascade.Services;

/// <summary>
/// Registers the <c>cascade://</c> URI scheme so a magic-link handoff
/// (<c>cascade://auth?token=…</c>) launches or focuses this app.
///
/// We use WinAppSDK's <see cref="ActivationRegistrationManager"/> rather than a
/// hand-written <c>HKCU\Software\Classes</c> command: the manual key launches
/// the exe with the URI as a raw argument, which WinAppSDK surfaces as a plain
/// <c>Launch</c> activation (no parsed <c>Uri</c>). Registering through the
/// manager instead makes activations arrive as
/// <see cref="Microsoft.Windows.AppLifecycle.ExtendedActivationKind.Protocol"/>
/// with the <c>Uri</c> populated — and it works for unpackaged apps, writing a
/// per-user registration (no admin, WDAC-safe).
/// </summary>
public static class ProtocolRegistration
{
public const string Scheme = "cascade";

/// <summary>
/// Ensure <c>cascade://</c> 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).
/// </summary>
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.
}
}
}
5 changes: 4 additions & 1 deletion apps/windows/Cascade/Services/SyncApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<VerifyResponse> VerifyAsync(string token)
{
Expand Down
Loading
Loading