diff --git a/src/Capacitor.App/App.axaml.cs b/src/Capacitor.App/App.axaml.cs index 6c1a9a455..6320dcb9a 100644 --- a/src/Capacitor.App/App.axaml.cs +++ b/src/Capacitor.App/App.axaml.cs @@ -703,8 +703,7 @@ void BuildDaemonGraph( _modelCatalog = modelCatalog; _ = modelCatalog.LoadAsync(_shutdown.Token); - var reportCliSetup = ServerCliSetupReport.Sender( - sessionHttp, profiles, typeof(CapacitorVersion).Assembly.GetName().Version?.ToString()); + var reportCliSetup = ServerCliSetupReport.Sender(sessionHttp, profiles, ReportedCliVersion); _ = reportCliSetup(_shutdown.Token); // After the directory, which feeds it the session→agent map: a server-lane item names a @@ -1237,7 +1236,8 @@ async Task RunWizardModeAsync( ConfiguredDaemonName: () => ResolveConfiguredDaemonName(_config, _serverEnv, _localRoot), HeldDaemonNames: HeldDaemonNamesAsync, ProfileOverride: _serverEnv.Profile, - UsageNotice: _usageNotice); + UsageNotice: _usageNotice, + ReportCliSetup: ReportWizardCliSetupAsync); _localInstall ??= WizardComposition.NewInstallController(options); var graph = WizardComposition.BuildGraph(options with { Install = _localInstall }); @@ -1738,25 +1738,33 @@ internal static (string Profile, string Server, string DaemonName)? ResolveWizar } // A throwaway lane over a fresh resolution: the cached ServerLane is bound to the first profile it - // saw, and the wizard has only just signed in. + // saw, and the wizard has only just signed in. Null when the profile names no server. + async Task<(ServiceProvider Lane, ProfileContext Profiles)?> FreshServerLaneAsync(CancellationToken ct) { + var (_, profiles) = await ResolveAndEvaluateGateAsync( + _config, _foreignHttp.GetRequiredService(), _serverEnv, _localRoot, _time, ct).ConfigureAwait(false); + if (profiles?.Resolution.ServerUrl is not { Length: > 0 } url) return null; + + var lane = new ServiceCollection() + .AddSingleton(_config) + .AddSingleton(profiles) + .AddSingleton(new CapacitorServer(url, _config, profiles, _localRoot)) + .AddCapacitorHttp(_serverEnv, _machineEnv) + .BuildValidated(); + + return (lane, profiles); + } + async Task> HeldDaemonNamesAsync(CancellationToken ct) { try { - var (_, profiles) = await ResolveAndEvaluateGateAsync( - _config, _foreignHttp.GetRequiredService(), _serverEnv, _localRoot, _time, ct).ConfigureAwait(false); - if (profiles?.Resolution.ServerUrl is not { Length: > 0 } url) return FrozenDictionary.Empty; - - await using var lane = new ServiceCollection() - .AddSingleton(_config) - .AddSingleton(profiles) - .AddSingleton(new CapacitorServer(url, _config, profiles, _localRoot)) - .AddCapacitorHttp(_serverEnv, _machineEnv) - .BuildValidated(); + if (await FreshServerLaneAsync(ct).ConfigureAwait(false) is not ({ } lane, { } profiles)) + return FrozenDictionary.Empty; + await using var _ = lane; var (client, status, _, _) = await lane.GetRequiredService().ForWaitAsync(ct).ConfigureAwait(false); using (client) { if (status is not (AuthStatus.Ok or AuthStatus.NoAuthRequired)) return FrozenDictionary.Empty; - return await DaemonNameHolder.HeldElsewhereAsync(client, url, _config, ct).ConfigureAwait(false); + return await DaemonNameHolder.HeldElsewhereAsync(client, profiles.Resolution.ServerUrl!, _config, ct).ConfigureAwait(false); } } catch (Exception ex) when (!ct.IsCancellationRequested) { Console.Error.WriteLine($"kcap: daemon name lookup failed — offering the configured name: {ex.Message}"); @@ -1764,6 +1772,27 @@ async Task> HeldDaemonNamesAsync( } } + // Sent from the wizard so the dashboard it opens already knows this install. Bounded, so a + // server that never answers leaves the Done page on a retry rather than a spinner. + async Task ReportWizardCliSetupAsync(CancellationToken ct) { + using var timeout = new CancellationTokenSource(WizardCliSetupReportTimeout, _time); + using var bounded = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + try { + if (await FreshServerLaneAsync(bounded.Token).ConfigureAwait(false) is not ({ } lane, { } profiles)) return false; + await using var _ = lane; + + return await ServerCliSetupReport.Sender(lane.GetRequiredService(), profiles, ReportedCliVersion)(bounded.Token) + .ConfigureAwait(false); + } catch (Exception ex) when (!ct.IsCancellationRequested) { + Console.Error.WriteLine($"kcap: cli-setup report from setup failed: {ex.Message}"); + return false; + } + } + + static readonly TimeSpan WizardCliSetupReportTimeout = TimeSpan.FromSeconds(30); + + static string? ReportedCliVersion => typeof(CapacitorVersion).Assembly.GetName().Version?.ToString(); + // Delegates to the ONE shared validator: must agree with OnboardingGate on what counts as a // valid server_url (e.g. both reject file://), or a gate-incomplete machine could still pass // this precondition into the normal daemon graph. diff --git a/src/Capacitor.App/Services/Onboarding/WizardComposition.cs b/src/Capacitor.App/Services/Onboarding/WizardComposition.cs index 16cde0594..2a73f7e52 100644 --- a/src/Capacitor.App/Services/Onboarding/WizardComposition.cs +++ b/src/Capacitor.App/Services/Onboarding/WizardComposition.cs @@ -94,7 +94,9 @@ internal sealed record WizardGraphOptions( // The app's one install controller, shared by every wizard it shows; null builds a graph-local one. LocalInstallController? Install = null, // Null, or already acknowledged, leaves the Privacy page out of the run. - UsageDataNotice? UsageNotice = null); + UsageDataNotice? UsageNotice = null, + // Tells the configured server this machine has kcap installed; true once it accepted. + Func>? ReportCliSetup = null); /// The wizard half of the composition root, split out of App so it can be driven /// with fakes: nothing here touches a daemon, a socket or the network until a step is used. @@ -262,7 +264,7 @@ [.. recording.Select(r => r.Label)], daemon.Problem, local, local ? LocalEndpoints.Read(root).Active?.ServerUrl : null); - }, options.UrlOpener); + }, options.UrlOpener, PrepareWorkspaceAsync); IWizardStep[] steps = [edition, installStep, signIn, harnesses, history, new UsageDataStepViewModel(options.UsageNotice), done]; // Before a server is connected the app has nothing to show, so deferring is offered only for the optional steps after. @@ -290,6 +292,19 @@ [.. recording.Select(r => r.Label)], return new WizardGraph(wizard, auth, steps, history, connect, daemon, install, pathFix); bool Connected() => signIn.Satisfied || Installed() || CommittedLocal(); + + // A run already in flight is waited out, never queued behind: a second run straight after a + // declined takeover would ask again. A failed run is repeated only when the user retries. + async Task PrepareWorkspaceAsync(bool retry) { + await daemon.QuiescedAsync().ConfigureAwait(true); + if (!daemon.Satisfied && (retry || daemon.Problem is null)) + await daemon.EnsureAsync(options.ShutdownToken).ConfigureAwait(true); + if (!daemon.Satisfied) return WorkspaceReadiness.DaemonNotRunning; + + return options.ReportCliSetup is null || await options.ReportCliSetup(options.ShutdownToken).ConfigureAwait(true) + ? WorkspaceReadiness.Ready + : WorkspaceReadiness.Unreported; + } } /// Why this machine cannot take the local edition, or null when it can. A cloud profile anywhere diff --git a/src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs b/src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs index c14f9940f..7235466ee 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Globalization; using System.Reactive; +using System.Reactive.Linq; using Capacitor.App.Services; using ReactiveUI.Reactive; @@ -14,15 +15,25 @@ namespace Capacitor.App.ViewModels.Onboarding; public sealed class DoneStepViewModel : ReactiveObject, IWizardStep { readonly Func _facts; readonly IUrlOpener? _opener; + readonly Func>? _prepare; DoneFacts _current = DoneFacts.Empty; - - public DoneStepViewModel(Func facts, IUrlOpener? opener = null) { - _facts = facts; - _opener = opener; - - OpenWorkspaceCommand = ReactiveCommand.Create(OpenWorkspace); - OpenDashboardCommand = ReactiveCommand.Create(OpenDashboard); + WorkspaceReadiness _readiness; + bool _preparing; + string? _readyFor; + + /// Brings the daemon up and reports the install, answering whether the + /// dashboard may open; true asks it to try again. Null when there is nothing to wait for. + public DoneStepViewModel(Func facts, IUrlOpener? opener = null, Func>? prepare = null) { + _facts = facts; + _opener = opener; + _prepare = prepare; + _readiness = prepare is null ? WorkspaceReadiness.Ready : WorkspaceReadiness.Preparing; + + var ready = this.WhenAnyValue(x => x.Readiness).Select(r => r == WorkspaceReadiness.Ready); + OpenWorkspaceCommand = ReactiveCommand.Create(OpenWorkspace, ready); + OpenDashboardCommand = ReactiveCommand.Create(OpenDashboard, ready); + RetryCommand = ReactiveCommand.CreateFromTask(() => PrepareAsync(retry: true)); } public WizardStepId Id => WizardStepId.Done; @@ -120,22 +131,95 @@ public DoneStepViewModel(Func facts, IUrlOpener? opener = null) { ? $"{problem} The app tries again once setup closes." : $"The daemon is being set up as a service on {_current.MachineName}. The app keeps it running from here."; - public bool WorkspaceVisible => !_current.Local && !string.IsNullOrEmpty(_current.WorkspaceUrl); + public WorkspaceReadiness Readiness { + get => _readiness; + private set { + if (_readiness == value) return; + _readiness = value; + this.RaisePropertyChanged(); + Restate(); + } + } + + bool WorkspaceKnown => !_current.Local && !string.IsNullOrEmpty(_current.WorkspaceUrl); + + bool DashboardKnown => _current.Local && !string.IsNullOrEmpty(_current.DashboardUrl); + + bool Opens => WorkspaceKnown || DashboardKnown; + + public bool WorkspaceVisible => WorkspaceKnown && Readiness == WorkspaceReadiness.Ready; - public bool DashboardVisible => _current.Local && !string.IsNullOrEmpty(_current.DashboardUrl); + public bool DashboardVisible => DashboardKnown && Readiness == WorkspaceReadiness.Ready; + + public bool PreparingVisible => Opens && Readiness == WorkspaceReadiness.Preparing; + + public string PreparingLine => _current.Local ? "Getting your dashboard ready…" : "Getting your workspace ready…"; + + public bool NotReadyVisible => Opens && Readiness is WorkspaceReadiness.DaemonNotRunning or WorkspaceReadiness.Unreported; + + public string NotReadyTitle => _current.Local ? "The dashboard can't open yet" : "Your workspace can't open yet"; + + public string NotReadyBody => Readiness == WorkspaceReadiness.DaemonNotRunning + ? $"It opens once the daemon is running on {_current.MachineName}." + : _current.Local + ? "kcap couldn't reach the local server to register this machine." + : "kcap couldn't reach your workspace to register this machine. Check your connection."; public ReactiveCommand OpenWorkspaceCommand { get; } public ReactiveCommand OpenDashboardCommand { get; } + public ReactiveCommand RetryCommand { get; } + /// Re-read on every entry: Back-then-forward can change what the earlier pages did. public Task OnEnterAsync(CancellationToken ct) { + Refresh(); + // Back to sign-in can switch to a server that has not had this install's report. + if (_prepare is not null && Readiness == WorkspaceReadiness.Ready && _readyFor != Target) Readiness = WorkspaceReadiness.Preparing; + if (Readiness != WorkspaceReadiness.Ready) _ = PrepareAsync(retry: false); + + return Task.CompletedTask; + } + + // What the link opens, and so which server must have the report. + string? Target => _current.Local ? _current.DashboardUrl : _current.WorkspaceUrl; + + void Refresh() { if (_current.Import is INotifyPropertyChanged before) before.PropertyChanged -= OnImportChanged; _current = _facts(); if (_current.Import is INotifyPropertyChanged after) after.PropertyChanged += OnImportChanged; Restate(); + } - return Task.CompletedTask; + /// Ready holds for the server that took the report: a later failure cannot make it ask again, + /// but a different server has to be prepared afresh. + async Task PrepareAsync(bool retry) { + if (_prepare is null || _preparing || Readiness == WorkspaceReadiness.Ready) return; + + _preparing = true; + Readiness = WorkspaceReadiness.Preparing; + var target = Target; + WorkspaceReadiness result; + try { + result = await _prepare(retry).ConfigureAwait(true); + } catch (Exception ex) { + Console.Error.WriteLine($"kcap: could not get the workspace ready: {ex.Message}"); + result = WorkspaceReadiness.Unreported; + } finally { + _preparing = false; + } + + // The daemon line reads the run that just settled. + Refresh(); + + // Sign-in moved to another server while this ran; its answer says nothing about that one. + if (Target != target) { + await PrepareAsync(retry: false).ConfigureAwait(true); + return; + } + + Readiness = result == WorkspaceReadiness.Preparing ? WorkspaceReadiness.Unreported : result; + if (Readiness == WorkspaceReadiness.Ready) _readyFor = target; } // Next finishes the wizard — the shell's own last-step handling raises CloseRequested. @@ -144,6 +228,7 @@ public Task OnEnterAsync(CancellationToken ct) { void OnImportChanged(object? sender, PropertyChangedEventArgs e) => Restate(); void OpenWorkspace() { + if (Readiness != WorkspaceReadiness.Ready) return; if (_current.WorkspaceUrl is not { Length: > 0 } url || _opener is null) return; try { _opener.Open(url); @@ -153,6 +238,7 @@ void OpenWorkspace() { } void OpenDashboard() { + if (Readiness != WorkspaceReadiness.Ready) return; if (_current.DashboardUrl is not { Length: > 0 } url || _opener is null) return; try { _opener.Open(url); diff --git a/src/Capacitor.App/ViewModels/Onboarding/WorkspaceReadiness.cs b/src/Capacitor.App/ViewModels/Onboarding/WorkspaceReadiness.cs new file mode 100644 index 000000000..473203060 --- /dev/null +++ b/src/Capacitor.App/ViewModels/Onboarding/WorkspaceReadiness.cs @@ -0,0 +1,5 @@ +namespace Capacitor.App.ViewModels.Onboarding; + +/// Whether the Done page may open the dashboard: the daemon must be running and the server must +/// have recorded this install, or the dashboard asks for an install the app already includes. +public enum WorkspaceReadiness { Preparing, Ready, DaemonNotRunning, Unreported } diff --git a/src/Capacitor.App/Views/Onboarding/DonePaneView.axaml b/src/Capacitor.App/Views/Onboarding/DonePaneView.axaml index f43db997d..295a7e79e 100644 --- a/src/Capacitor.App/Views/Onboarding/DonePaneView.axaml +++ b/src/Capacitor.App/Views/Onboarding/DonePaneView.axaml @@ -109,9 +109,35 @@ + + + + + + + + + + + + + + + +