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
59 changes: 44 additions & 15 deletions src/Capacitor.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1237,7 +1236,8 @@ async Task<bool> 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 });

Expand Down Expand Up @@ -1738,32 +1738,61 @@ 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<TokenStore>(), _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<IReadOnlyDictionary<string, DaemonNameHolding>> HeldDaemonNamesAsync(CancellationToken ct) {
try {
var (_, profiles) = await ResolveAndEvaluateGateAsync(
_config, _foreignHttp.GetRequiredService<TokenStore>(), _serverEnv, _localRoot, _time, ct).ConfigureAwait(false);
if (profiles?.Resolution.ServerUrl is not { Length: > 0 } url) return FrozenDictionary<string, DaemonNameHolding>.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<string, DaemonNameHolding>.Empty;
await using var _ = lane;

var (client, status, _, _) = await lane.GetRequiredService<ICapacitorHttpClient>().ForWaitAsync(ct).ConfigureAwait(false);
using (client) {
if (status is not (AuthStatus.Ok or AuthStatus.NoAuthRequired)) return FrozenDictionary<string, DaemonNameHolding>.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}");
return FrozenDictionary<string, DaemonNameHolding>.Empty;
}
}

// 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<bool> 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<ICapacitorHttpClient>(), 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.
Expand Down
19 changes: 17 additions & 2 deletions src/Capacitor.App/Services/Onboarding/WizardComposition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CancellationToken, Task<bool>>? 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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<WorkspaceReadiness> 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
Expand Down
106 changes: 96 additions & 10 deletions src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.ComponentModel;
using System.Globalization;
using System.Reactive;
using System.Reactive.Linq;
using Capacitor.App.Services;
using ReactiveUI.Reactive;

Expand All @@ -14,15 +15,25 @@ namespace Capacitor.App.ViewModels.Onboarding;
public sealed class DoneStepViewModel : ReactiveObject, IWizardStep {
readonly Func<DoneFacts> _facts;
readonly IUrlOpener? _opener;
readonly Func<bool, Task<WorkspaceReadiness>>? _prepare;

DoneFacts _current = DoneFacts.Empty;

public DoneStepViewModel(Func<DoneFacts> facts, IUrlOpener? opener = null) {
_facts = facts;
_opener = opener;

OpenWorkspaceCommand = ReactiveCommand.Create(OpenWorkspace);
OpenDashboardCommand = ReactiveCommand.Create(OpenDashboard);
WorkspaceReadiness _readiness;
bool _preparing;
string? _readyFor;

/// <param name="prepare">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.</param>
public DoneStepViewModel(Func<DoneFacts> facts, IUrlOpener? opener = null, Func<bool, Task<WorkspaceReadiness>>? 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;
Expand Down Expand Up @@ -120,22 +131,95 @@ public DoneStepViewModel(Func<DoneFacts> 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<Unit, Unit> OpenWorkspaceCommand { get; }

public ReactiveCommand<Unit, Unit> OpenDashboardCommand { get; }

public ReactiveCommand<Unit, Unit> 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);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

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.
Expand All @@ -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);
Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/Capacitor.App/ViewModels/Onboarding/WorkspaceReadiness.cs
Original file line number Diff line number Diff line change
@@ -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 }
26 changes: 26 additions & 0 deletions src/Capacitor.App/Views/Onboarding/DonePaneView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,35 @@
</StackPanel>
</Border>

<!-- The dashboard opens only once this machine is registered with the server. -->
<Border x:Name="NotReadyPanel" Classes="onboardingWarning" IsVisible="{Binding NotReadyVisible}">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="9">
<views:AppIcon Kind="TriangleAlert" Size="14" VerticalAlignment="Top" Margin="0,2,0,0"
Foreground="{StaticResource KcapDangerBrush}" />
<StackPanel Grid.Column="1" Spacing="5">
<TextBlock x:Name="NotReadyTitleText" Classes="onboardingLabel" FontSize="12.5" Text="{Binding NotReadyTitle}" />
<TextBlock Classes="onboardingDetail" Foreground="{StaticResource OnboardingMutedBrush}" Text="{Binding NotReadyBody}" />
</StackPanel>
<Button Grid.Column="2" x:Name="RetryButton" Classes="onboardingLink" Margin="15,0,0,0" VerticalAlignment="Center"
AutomationProperties.Name="Try again" Command="{Binding RetryCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<views:AppIcon Kind="RefreshCw" Size="13" />
<TextBlock Text="Try again" VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
</Border>

<Grid ColumnDefinitions="*,Auto" ColumnSpacing="24">
<TextBlock Classes="onboardingNote" VerticalAlignment="Center" Text="{Binding RestartLine}"
IsVisible="{Binding RestartLine, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<StackPanel Grid.Column="1" x:Name="PreparingRow" Orientation="Horizontal" Spacing="8" Margin="0,6"
IsVisible="{Binding PreparingVisible}">
<views:AppIcon Kind="LoaderCircle" Size="14" Classes.kcapSpinning="{Binding PreparingVisible}"
Foreground="{StaticResource OnboardingAccentBrush}" VerticalAlignment="Center" />
<TextBlock x:Name="PreparingLineText" Classes="onboardingDetail" VerticalAlignment="Center"
Foreground="{StaticResource OnboardingMutedBrush}" Text="{Binding PreparingLine}" />
</StackPanel>
<Button Grid.Column="1" x:Name="OpenWorkspaceButton" Classes="onboardingLink"
AutomationProperties.Name="Open your workspace"
Command="{Binding OpenWorkspaceCommand}" IsVisible="{Binding WorkspaceVisible}">
Expand Down
Loading
Loading