diff --git a/apps/windows/Cascade/Services/Dto.cs b/apps/windows/Cascade/Services/Dto.cs index fde024d..bb7d192 100644 --- a/apps/windows/Cascade/Services/Dto.cs +++ b/apps/windows/Cascade/Services/Dto.cs @@ -37,19 +37,13 @@ public static class CascadeJson [JsonDerivedType(typeof(SetListeningTrackingCommand), "setListeningTracking")] [JsonDerivedType(typeof(RestoreCommand), "restore")] [JsonDerivedType(typeof(BeginListeningSyncCommand), "beginListeningSync")] -[JsonDerivedType(typeof(ListeningSyncSucceededCommand), "listeningSyncSucceeded")] -[JsonDerivedType(typeof(ListeningSyncFailedCommand), "listeningSyncFailed")] [JsonDerivedType(typeof(ResetListeningDataCommand), "resetListeningData")] [JsonDerivedType(typeof(RequestSignInLinkCommand), "requestSignInLink")] [JsonDerivedType(typeof(SubmitSignInLinkCommand), "submitSignInLink")] [JsonDerivedType(typeof(SignOutCommand), "signOut")] [JsonDerivedType(typeof(DeleteListeningDataCommand), "deleteListeningData")] [JsonDerivedType(typeof(DeleteAccountCommand), "deleteAccount")] -[JsonDerivedType(typeof(SignInLinkSentCommand), "signInLinkSent")] -[JsonDerivedType(typeof(SignInVerifiedCommand), "signInVerified")] -[JsonDerivedType(typeof(ListeningDataDeletedCommand), "listeningDataDeleted")] -[JsonDerivedType(typeof(AccountDeletedCommand), "accountDeleted")] -[JsonDerivedType(typeof(AccountRequestFailedCommand), "accountRequestFailed")] +[JsonDerivedType(typeof(ServerResponseCommand), "serverResponse")] public abstract record CascadeCommand; public sealed record PlayCommand : CascadeCommand; @@ -69,25 +63,21 @@ public sealed record SetListeningTrackingCommand(bool Enabled) : CascadeCommand; // AccountJson is the stored account blob, verbatim; "" for none. public sealed record RestoreCommand(string SettingsJson, string ListeningJson, string FallbackDeviceId, string AccountJson) : CascadeCommand; public sealed record BeginListeningSyncCommand(SyncReason Reason) : CascadeCommand; -public sealed record ListeningSyncSucceededCommand(ulong ServerTotalMs) : CascadeCommand; -public sealed record ListeningSyncFailedCommand(bool Unauthorized) : CascadeCommand; public sealed record ResetListeningDataCommand(string NewDeviceId) : CascadeCommand; // What the user does with the account. Every one but SignOut is ignored while // an account request is pending (account.busy). -public sealed record RequestSignInLinkCommand(string Email) : CascadeCommand; +// Platform names the app the emailed link hands off to; null for none. +public sealed record RequestSignInLinkCommand(string Email, string? Platform) : CascadeCommand; public sealed record SubmitSignInLinkCommand(string Input) : CascadeCommand; public sealed record SignOutCommand : CascadeCommand; -public sealed record DeleteListeningDataCommand : CascadeCommand; -public sealed record DeleteAccountCommand : CascadeCommand; +// NewDeviceId is a fresh id the core rotates to once the server confirms. +public sealed record DeleteListeningDataCommand(string NewDeviceId) : CascadeCommand; +public sealed record DeleteAccountCommand(string NewDeviceId) : CascadeCommand; -// How an account request settled: one command per success, one shared failure -// (unauthorized = HTTP 401). -public sealed record SignInLinkSentCommand : CascadeCommand; -public sealed record SignInVerifiedCommand(string SessionToken, string Email) : CascadeCommand; -public sealed record ListeningDataDeletedCommand(string NewDeviceId) : CascadeCommand; -public sealed record AccountDeletedCommand(string NewDeviceId) : CascadeCommand; -public sealed record AccountRequestFailedCommand(bool Unauthorized) : CascadeCommand; +// Settles a ServerRequestEffect: its id, the HTTP status (0 = not sent or no +// response) and the response body verbatim ("" for none). +public sealed record ServerResponseCommand(ulong Id, ushort Status, string Body) : CascadeCommand; /// /// Why the shell is asking to sync. The shell decides when it can talk; the @@ -125,12 +115,7 @@ public override void Write(System.Text.Json.Utf8JsonWriter writer, SyncReason va [JsonDerivedType(typeof(SetPlatformVolumeEffect), "setPlatformVolume")] [JsonDerivedType(typeof(PersistSettingsEffect), "persistSettings")] [JsonDerivedType(typeof(PersistListeningEffect), "persistListening")] -[JsonDerivedType(typeof(PushListeningEffect), "pushListening")] -[JsonDerivedType(typeof(SendSignInLinkEffect), "sendSignInLink")] -[JsonDerivedType(typeof(VerifySignInTokenEffect), "verifySignInToken")] -[JsonDerivedType(typeof(RevokeSessionEffect), "revokeSession")] -[JsonDerivedType(typeof(DeleteServerListeningEffect), "deleteServerListening")] -[JsonDerivedType(typeof(DeleteServerAccountEffect), "deleteServerAccount")] +[JsonDerivedType(typeof(ServerRequestEffect), "serverRequest")] [JsonDerivedType(typeof(PersistAccountEffect), "persistAccount")] public abstract record CascadeEffect; @@ -140,14 +125,10 @@ public sealed record PausePlaybackEffect : CascadeEffect; public sealed record SetPlatformVolumeEffect(double Gain) : CascadeEffect; public sealed record PersistSettingsEffect(string Json) : CascadeEffect; public sealed record PersistListeningEffect(string Json) : CascadeEffect; -public sealed record PushListeningEffect(string DeviceId, ulong DeviceTotalMs, string SessionToken) : CascadeEffect; -// Account requests: carry each over HTTP and settle it with its success -// command or AccountRequestFailed. RevokeSession is fire-and-forget. -public sealed record SendSignInLinkEffect(string Email) : CascadeEffect; -public sealed record VerifySignInTokenEffect(string Token) : CascadeEffect; -public sealed record RevokeSessionEffect(string SessionToken) : CascadeEffect; -public sealed record DeleteServerListeningEffect(string SessionToken) : CascadeEffect; -public sealed record DeleteServerAccountEffect(string SessionToken) : CascadeEffect; +// One request to the sync server, sent exactly as described: Method to the +// base URL plus Path, with a Bearer token and a JSON Body when given. Every +// one is settled with a ServerResponseCommand carrying the same Id. +public sealed record ServerRequestEffect(ulong Id, string Method, string Path, string? BearerToken, string? Body) : CascadeEffect; // Store verbatim; "" means delete the stored account. public sealed record PersistAccountEffect(string Json) : CascadeEffect; diff --git a/apps/windows/Cascade/Services/SyncApi.cs b/apps/windows/Cascade/Services/SyncApi.cs deleted file mode 100644 index 0e41914..0000000 --- a/apps/windows/Cascade/Services/SyncApi.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Net.Http; -using System.Net.Http.Json; -using System.Threading.Tasks; - -namespace Cascade.Services; - -/// Base URL of cascade-sync-server. Empty disables the sync feature. -public static class SyncConfig -{ - public const string ApiBase = "https://sync.cascade.stephens.page"; - public static bool Available => !string.IsNullOrEmpty(ApiBase); -} - -public sealed class SyncHttpException : Exception -{ - public int Status { get; } - public SyncHttpException(int status, string message) : base(message) => Status = status; -} - -public sealed record VerifyResponse(string SessionToken, string Email); -public sealed record ListeningResponse(long ServerTotalMs, long SyncedThroughMs); - -/// Typed client for the sync endpoints (System.Net.Http + System.Text.Json). -public sealed class SyncApi -{ - private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) }; - - public Task RequestLinkAsync(string email) => - // 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) - { - using var resp = await SendAsync(HttpMethod.Post, "/auth/verify", new { token }, null); - return (await resp.Content.ReadFromJsonAsync())!; - } - - public Task LogoutAsync(string sessionToken) => - PostAsync("/auth/logout", null, sessionToken); - - public async Task PutListeningAsync(string sessionToken, string deviceId, long deviceTotalMs) - { - using var resp = await SendAsync(HttpMethod.Put, "/listening", - new { deviceId, deviceTotalMs }, sessionToken); - return (await resp.Content.ReadFromJsonAsync())!; - } - - public Task DeleteListeningAsync(string sessionToken) => - SendVoidAsync(HttpMethod.Delete, "/listening", sessionToken); - - public Task DeleteAccountAsync(string sessionToken) => - SendVoidAsync(HttpMethod.Delete, "/account", sessionToken); - - private async Task PostAsync(string path, object? body, string? token) - { - using var _ = await SendAsync(HttpMethod.Post, path, body, token); - } - - private async Task SendVoidAsync(HttpMethod method, string path, string? token) - { - using var _ = await SendAsync(method, path, null, token); - } - - private static async Task SendAsync( - HttpMethod method, string path, object? body, string? token) - { - using var req = new HttpRequestMessage(method, $"{SyncConfig.ApiBase}{path}"); - if (token is not null) - req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - if (body is not null) - req.Content = JsonContent.Create(body); - var resp = await Http.SendAsync(req); - if (!resp.IsSuccessStatusCode) - { - var text = await resp.Content.ReadAsStringAsync(); - resp.Dispose(); - throw new SyncHttpException((int)resp.StatusCode, - string.IsNullOrEmpty(text) ? $"request failed ({(int)resp.StatusCode})" : text); - } - return resp; - } -} diff --git a/apps/windows/Cascade/Services/SyncServer.cs b/apps/windows/Cascade/Services/SyncServer.cs new file mode 100644 index 0000000..60aef97 --- /dev/null +++ b/apps/windows/Cascade/Services/SyncServer.cs @@ -0,0 +1,61 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace Cascade.Services; + +/// Base URL of cascade-sync-server. Empty disables the sync feature. +public static class SyncConfig +{ + public const string ApiBase = "https://sync.cascade.stephens.page"; + public static bool Available => !string.IsNullOrEmpty(ApiBase); +} + +/// +/// Carries the core's s to the sync server. +/// The core owns the request table; this sends exactly what it is given and +/// reports the status and body verbatim, with no per-request knowledge. +/// +public sealed class SyncServer +{ + private static readonly HttpClient DefaultHttp = new() { Timeout = TimeSpan.FromSeconds(15) }; + + private readonly string _baseUrl; + private readonly HttpClient _http; + + public SyncServer(string baseUrl = SyncConfig.ApiBase, HttpClient? http = null) + { + _baseUrl = baseUrl; + _http = http ?? DefaultHttp; + } + + /// + /// Send one request and answer it with the + /// that settles it. Never throws: no sync server, a network error, a + /// timeout or a cancellation all settle with status 0. + /// + public async Task CarryAsync(ServerRequestEffect request) + { + if (string.IsNullOrEmpty(_baseUrl)) return NoResponse(request); + try + { + using var req = new HttpRequestMessage(new HttpMethod(request.Method), _baseUrl + request.Path); + if (request.BearerToken is not null) + req.Headers.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", request.BearerToken); + if (request.Body is not null) + req.Content = new StringContent(request.Body, Encoding.UTF8, "application/json"); + using var resp = await _http.SendAsync(req); + return new ServerResponseCommand(request.Id, (ushort)resp.StatusCode, + await resp.Content.ReadAsStringAsync()); + } + catch (Exception) + { + return NoResponse(request); + } + } + + private static ServerResponseCommand NoResponse(ServerRequestEffect request) => + new(request.Id, 0, ""); +} diff --git a/apps/windows/Cascade/ViewModels/AppViewModel.cs b/apps/windows/Cascade/ViewModels/AppViewModel.cs index b2bd834..429c5a8 100644 --- a/apps/windows/Cascade/ViewModels/AppViewModel.cs +++ b/apps/windows/Cascade/ViewModels/AppViewModel.cs @@ -37,7 +37,7 @@ public sealed partial class AppViewModel : ObservableObject, IDisposable // ---- account / sync ---- private readonly AccountStore _accountStore = new(); - private readonly SyncApi _syncApi = new(); + private readonly SyncServer _syncServer = new(); public bool SyncAvailable => SyncConfig.Available; @@ -172,8 +172,8 @@ private void Apply(CascadeUpdate update) case PersistAccountEffect persistAccount: _accountStore.WriteAccountJson(persistAccount.Json); break; - default: - CarryRequest(effect); + case ServerRequestEffect request: + _ = CarryAndSettleAsync(request); break; } } @@ -220,8 +220,11 @@ public void StartTimer(int minutes, bool sleep) => // ---- account / sync commands ---- + // The "windows" platform 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. [RelayCommand(CanExecute = nameof(AccountIdle))] - private void RequestSignInLink() => Send(new RequestSignInLinkCommand(EmailInput)); + private void RequestSignInLink() => Send(new RequestSignInLinkCommand(EmailInput, "windows")); [RelayCommand(CanExecute = nameof(AccountIdle))] private void SubmitSignInLink() => Send(new SubmitSignInLinkCommand(SignInLinkInput)); @@ -236,15 +239,15 @@ public void StartTimer(int minutes, bool sleep) => private void SignOut() => Send(new SignOutCommand()); [RelayCommand(CanExecute = nameof(AccountIdle))] - private void DeleteListeningData() => Send(new DeleteListeningDataCommand()); + private void DeleteListeningData() => Send(new DeleteListeningDataCommand(NewDeviceId())); [RelayCommand(CanExecute = nameof(AccountIdle))] - private void DeleteAccount() => Send(new DeleteAccountCommand()); + private void DeleteAccount() => Send(new DeleteAccountCommand(NewDeviceId())); /// /// The shell decides when it can talk; the core decides whether there is - /// anything to say, and what. A PushListening in the answer is carried by - /// . + /// anything to say, and what. The push in the answer is carried by + /// . /// private void BeginSync(SyncReason reason) { @@ -252,103 +255,24 @@ private void BeginSync(SyncReason reason) } /// - /// Carry one request effect over HTTP and settle it with the core. Every - /// request but RevokeSession must be settled (success or failure), or the - /// core never starts another. Not a request: nothing to do. + /// Carry one request to the sync server and settle it with the core. Every + /// request is settled, failures and the sign-out revoke included (status + /// 0 when nothing came back), or the core never starts another. /// - private void CarryRequest(CascadeEffect effect) + private async Task CarryAndSettleAsync(ServerRequestEffect request) { - static CascadeCommand AccountFailed(bool unauthorized) => - new AccountRequestFailedCommand(unauthorized); - - switch (effect) - { - case PushListeningEffect push: - _ = SettleWithAsync( - () => _syncApi.PutListeningAsync( - push.SessionToken, push.DeviceId, (long)push.DeviceTotalMs), - res => new ListeningSyncSucceededCommand((ulong)Math.Max(0L, res.ServerTotalMs)), - unauthorized => new ListeningSyncFailedCommand(unauthorized)); - break; - case SendSignInLinkEffect send: - _ = SettleWithAsync( - () => _syncApi.RequestLinkAsync(send.Email), - () => new SignInLinkSentCommand(), - AccountFailed); - break; - case VerifySignInTokenEffect verify: - _ = SettleWithAsync( - () => _syncApi.VerifyAsync(verify.Token), - res => new SignInVerifiedCommand(res.SessionToken, res.Email), - AccountFailed); - break; - case RevokeSessionEffect revoke: - _ = RevokeAsync(revoke.SessionToken); - break; - case DeleteServerListeningEffect delete: - _ = SettleWithAsync( - () => _syncApi.DeleteListeningAsync(delete.SessionToken), - () => new ListeningDataDeletedCommand(NewDeviceId()), - AccountFailed); - break; - case DeleteServerAccountEffect delete: - _ = SettleWithAsync( - () => _syncApi.DeleteAccountAsync(delete.SessionToken), - () => new AccountDeletedCommand(NewDeviceId()), - AccountFailed); - break; - } + var response = await _syncServer.CarryAsync(request); + // Settle on the UI thread, which owns the dispatch and the bound + // snapshot, and never inside the dispatch that asked for the request: + // with no sync server the answer is already here and Apply is still + // running. + _dispatcher.TryEnqueue(() => Send(response)); } /// A fresh random device id. The core has no randomness, so the shell /// supplies one for the fallback id and each delete's slot rotation. private static string NewDeviceId() => Guid.NewGuid().ToString(); - /// Fire-and-forget: nothing settles a revoke. - private async Task RevokeAsync(string sessionToken) - { - try { await _syncApi.LogoutAsync(sessionToken); } - catch { /* already gone server-side or offline — local sign-out stands */ } - } - - /// Run one request and settle it with the command its result maps to. - /// Offline / transient / rejected all fail; only a 401 is unauthorized, - /// and what that does is the core's call. - private async Task SettleWithAsync( - Func> call, - Func succeeded, - Func failed) - { - CascadeCommand outcome; - try - { - outcome = succeeded(await call()); - } - catch (Exception e) - { - outcome = failed(e is SyncHttpException { Status: 401 }); - } - OnUiThread(() => Send(outcome)); - } - - private Task SettleWithAsync( - Func call, - Func succeeded, - Func failed) => - SettleWithAsync( - async () => { await call(); return true; }, - _ => succeeded(), - failed); - - /// Started on the UI thread, so await continuations normally resume on it; - /// marshal back defensively so the settle dispatch and the bound snapshot - /// are never touched from a background thread. - private void OnUiThread(Action action) - { - if (_dispatcher.HasThreadAccess) action(); - else _dispatcher.TryEnqueue(() => action()); - } - public void Dispose() { _tick.Stop();