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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Updates arrive through the app: it checks a few times a day, downloads in the ba

Help → Report a Bug… / Send Feedback… (or the help button in the session rail's footer) sends a report to Kurrent support; replies arrive by email.

**Uninstall kcap…** in the application menu removes what [`kcap uninstall`](#uninstalling) removes, plus the app and the `kcap` links it made. It first lists what will go, and warns when the local server is installed (or a change to it was interrupted), because its recorded sessions are deleted with it; sessions already sent to a cloud server stay there. The app then reopens in a small window to do the work and moves itself to the Trash.
**Uninstall kcap…** in the application menu removes what [`kcap uninstall`](#uninstalling) removes, plus the app and the `kcap` links it made. It first lists what will go, and warns when the local server is installed (or a change to it was interrupted), because its recorded sessions are deleted with it; sessions already sent to a cloud server stay there. The app then reopens in a small window to do the work and moves itself to the Trash. On Linux the item is in the help menu at the foot of the session rail, beside Settings: it also removes the daemon's systemd user unit and the CLI the AppImage copied to `~/.local/share/kcap/desktop`, then moves the AppImage to your Trash.

**Upgrading an existing macOS installation:** use **Check for Updates…** in the installed app, or open **kcap** from the new disk image and choose **Quit and update**. The installation stays in its Applications folder. Moving the app to the other Applications folder updates owned terminal links and background-service paths, then refreshes previously installed agent integrations with the bundled CLI to repair MCP commands, git hooks, and the Claude marketplace path. A failed integration refresh is reported and retried on the next launch. An update preserves sign-in and settings, and keeps supported agent sessions for resumption unless `daemon.auto_resume` is off. An installer that finds valid installations in both Applications folders, or another app occupying the destination, asks for a destination first. An unrelated app is never replaced. Redundant copies are removed after the replacement opens successfully; a failed update restores the installation or retains recovery backups and reports what needs attention. An equal or newer installed version offers **Open installed copy**.

Expand Down
32 changes: 25 additions & 7 deletions src/Capacitor.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
using Capacitor.Cli.Core.Resume;
using Capacitor.Cli.Core.Setup;
using Microsoft.Extensions.DependencyInjection;
using Capacitor.App.Platform.Linux;
using Capacitor.App.Platform.Mac;

namespace Capacitor.App;
Expand Down Expand Up @@ -429,9 +430,12 @@ void Quit(int exitCode) {
}
var run = OperatingSystem.IsMacOS()
? uninstall.BuildRun(Environment.ProcessPath, root => new MacAppRemoval(runner, _userHome, root), cli, () => Quit(0))
: null;
: OperatingSystem.IsLinux()
? uninstall.BuildAppImageRun(AppImageInstall.Detect(Environment.GetEnvironmentVariable),
install => new LinuxAppRemoval(runner, _userHome, install, Environment.GetEnvironmentVariable, _time), cli, () => Quit(0))
: null;
if (run is null) {
Console.Error.WriteLine("kcap: uninstalling from the app needs the installed kcap.app.");
Console.Error.WriteLine("kcap: uninstalling from the app needs the installed kcap.app or the kcap AppImage.");
// Posted: the lifetime's main loop has not started yet.
Dispatcher.UIThread.Post(() => Quit(1));
return;
Expand Down Expand Up @@ -868,7 +872,8 @@ void BuildDaemonGraph(
: null,
remoteWorkspaceFactory: BuildRemote,
modelCatalog: modelCatalog.Catalog, uploader: uploader, appServerUrl: profiles?.Resolution.ServerUrl,
openFeedback: openFeedback, settingsAction: _appMenu.SettingsAction, adopt: TakeAdoptableWindow(),
openFeedback: openFeedback, settingsAction: _appMenu.SettingsAction, uninstallAction: _appMenu.UninstallAction,
adopt: TakeAdoptableWindow(),
requestSetup: () => _ = RestartForSetupAsync(desktop),
historyImport: _historyImport?.Run,
backgroundPriority: lifecycle.BackgroundPriority, reloadState: lifecycle.ReloadState,
Expand Down Expand Up @@ -931,6 +936,11 @@ void BuildDaemonGraph(
if (OperatingSystem.IsMacOS() && InstallLocation.BundleRoot(Environment.ProcessPath) is { } bundleRoot) {
var facts = UninstallFactsFor(bundleRoot, service.DaemonName, profiles?.Resolution.ServerUrl, lifecycle, lifecycleProbe);
_appMenu.SetUninstallAction(() => _ = RelaunchForUninstallAsync(desktop, facts));
} else if (OperatingSystem.IsLinux() && AppImageInstall.Detect(Environment.GetEnvironmentVariable) is not null) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// The links and the CLI copy live under the staged root, so a kcap that resolves there is this app's.
var facts = UninstallFactsFor(AppImageInstall.StagedRoot(Environment.GetEnvironmentVariable, _userHome),
service.DaemonName, profiles?.Resolution.ServerUrl, lifecycle, lifecycleProbe);
_appMenu.SetUninstallAction(() => _ = RelaunchForUninstallAsync(desktop, facts));
}

// LAST, deliberately: anything above throwing lands in the catch with no
Expand Down Expand Up @@ -992,6 +1002,10 @@ async Task RelaunchForUninstallAsync(IClassicDesktopStyleApplicationLifetime des
try {
var request = UninstallConfirmation.Build(await source.GatherAsync(_shutdown.Token));
if (!await ShowLifecyclePromptDialogAsync(_coordinator?.Window, request.Prompt, _shutdown.Token)) return;
if (!Program.Host.CanRelaunchNow) {
_coordinator?.Window?.Notifier?.Notify("kcap cannot restart from where it was started. Run kcap uninstall in a terminal instead.");
return;
}
var start = Program.Host.NewCopy([.. request.RelaunchArgs]);
desktop.Exit += (_, _) => {
try { Process.Start(start); }
Expand Down Expand Up @@ -1200,7 +1214,7 @@ async Task<bool> RunWizardModeAsync(
IProcessRunner runner, ILoginShellProbe probe, ProfileContext? profiles, bool alreadyAuthenticated) {
var cliPath = CliResolver.ResolvePath(Environment.GetEnvironmentVariable, File.Exists, Program.Host.CliDirectory);
// Same rule as the shim coordinator's: only a resolved ABSOLUTE path is linkable.
var shimTarget = cliPath is not null && Path.IsPathRooted(cliPath) ? cliPath : null;
var shimTarget = ShimTargetFor(cliPath, Program.Host.CliOutlivesApp);
var shimInstaller = CliPathInstallers.Create(runner, probe, _userHome);
var shimApplicable = await ResolveShimApplicableAsync(
shimInstaller is not null, shimTarget, ct => probe.KcapOnPathAsync(ct), _shutdown.Token);
Expand Down Expand Up @@ -1561,7 +1575,7 @@ internal static MainWindow BuildAndShowMainWindow(
IObservable<IReadOnlyDictionary<string, IReadOnlyList<ModelChoice>>>? modelCatalog = null,
IAttachmentUploader? uploader = null, string? appServerUrl = null,
Action<FeedbackCategory>? openFeedback = null, IObservable<Action?>? settingsAction = null,
(MainWindow Window, AppState State)? adopt = null,
IObservable<Action?>? uninstallAction = null, (MainWindow Window, AppState State)? adopt = null,
Action? requestSetup = null, HistoryImportRun? historyImport = null,
IObservable<bool>? backgroundPriority = null, IObservable<ReloadState?>? reloadState = null,
IObservable<bool>? isReloading = null, Func<CancellationToken, Task>? reloadDaemon = null,
Expand Down Expand Up @@ -1617,7 +1631,7 @@ internal static MainWindow BuildAndShowMainWindow(
laneStatus: lane?.Status, restartPending: restartPending,
originOf: originOf, remoteWorkspaceFactory: remoteWorkspaceFactory, directory: resolvedDirectory,
openFeedback: openFeedback, opener: new ShellUrlOpener(), requestSignIn: requestSignIn,
settingsAction: settingsAction, requestSetup: requestSetup, historyImport: historyImport,
settingsAction: settingsAction, uninstallAction: uninstallAction, requestSetup: requestSetup, historyImport: historyImport,
backgroundPriority: backgroundPriority, reloadState: reloadState, isReloading: isReloading, reloadDaemon: reloadDaemon, openLaunchApprovals: openLaunchApprovals) {
LocalStack = localStack,
Web = web,
Expand Down Expand Up @@ -1673,7 +1687,7 @@ MainWindow TrackViewedAgent(MainWindow window) {
// The shim links to the RESOLVED ABSOLUTE path only — CliResolver's bare "kcap" PATH
// fallback means there is nothing to link, so the offer and the menu item both stay off
// for the whole run.
var shimTarget = cliPath is not null && Path.IsPathRooted(cliPath) ? cliPath : null;
var shimTarget = ShimTargetFor(cliPath, Program.Host.CliOutlivesApp);
// autoOfferSuppressed: Start() always runs — Offerable/manual install must keep working in Incomplete mode; only the once-ever auto-offer dialog is skipped.
var shimOffer = new ShimOfferCoordinator(
lifecycle.PhaseClosed, probe, CliPathInstallers.Create(runner, probe, _userHome), _appState, surface, shimTarget,
Expand All @@ -1688,6 +1702,10 @@ MainWindow TrackViewedAgent(MainWindow window) {
return (lifecycle, shimOffer, consentFlip, surface, probe, cli);
}

/// A link needs an absolute path that is still there once the app exits.
internal static string? ShimTargetFor(string? cliPath, bool cliOutlivesApp) =>
cliOutlivesApp && cliPath is not null && Path.IsPathRooted(cliPath) ? cliPath : null;

internal static bool IsLocalServer(string? serverUrl, LocalRoot local) =>
LocalEndpoints.Read(local).Classify(serverUrl) is LocalEndpointClass.Local;

Expand Down
6 changes: 5 additions & 1 deletion src/Capacitor.App/Platform/DesktopHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public static DesktopHost Unstarted() {
var cliDirectory = BundleLayout.CliDirectory(AppContext.BaseDirectory);
if (OperatingSystem.IsMacOS()) return new MacDesktopHost(cliDirectory, false, SingleInstanceLock.Unenforced);
if (OperatingSystem.IsWindows()) return new WindowsDesktopHost(cliDirectory, false, SingleInstanceLock.Unenforced);
return new LinuxDesktopHost(cliDirectory, false, null, SingleInstanceLock.Unenforced);
return new LinuxDesktopHost(cliDirectory, false, null, false, SingleInstanceLock.Unenforced);
}

public string CliDirectory { get; }
Expand All @@ -58,8 +58,12 @@ public static DesktopHost Unstarted() {
/// the model changes and the pause state is refreshed on a timer.
public virtual bool TrayAnnouncesMenuOpen => true;

/// False when a new copy could not start, so the app must not quit for one.
public virtual bool CanRelaunchNow => true;

/// False while the CLI runs from somewhere that vanishes when the app exits, so nothing may link to it.
public virtual bool CliOutlivesApp => true;

public virtual AppBuilder ConfigureAvalonia(AppBuilder builder) => builder;

/// Blocks until the platform can show a window.
Expand Down
18 changes: 13 additions & 5 deletions src/Capacitor.App/Platform/Linux/AppImageInstall.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,25 @@ public sealed record AppImageInstall(string Image) {
public static AppImageInstall? Detect(Func<string, string?> getEnv) =>
getEnv("APPIMAGE") is { Length: > 0 } image ? new(image) : null;

/// Where the CLI and plugin are copied out of the mount: everything of the app's that outlives it.
public static string StagedRoot(Func<string, string?> getEnv, UserHome home) =>
Path.Combine(DataHome(getEnv, home), "kcap", "desktop");

public static string StagedCliDirectory(string stagedRoot) => Path.Combine(stagedRoot, "bin");

internal static string DataHome(Func<string, string?> getEnv, UserHome home) =>
getEnv("XDG_DATA_HOME") is { } xdg && Path.IsPathRooted(xdg)
? xdg
: Path.Combine(home.Path, ".local", "share");

/// Copies the bundle's CLI and plugin out of the mount and returns the directory to run the CLI
/// from. Each file is replaced by rename, so a running daemon keeps its old inode and sees an
/// update the way it does after `kcap update`. When the copy fails the CLI runs from the mount.
public string TakeCliOutOfMount(Func<string, string?> getEnv, UserHome home, string appDirectory) {
var bundleCli = BundleLayout.CliDirectory(appDirectory);
var dataHome = getEnv("XDG_DATA_HOME") is { } xdg && Path.IsPathRooted(xdg)
? xdg
: Path.Combine(home.Path, ".local", "share");
var bundlePlugin = BundleLayout.PluginDirectory(appDirectory);
var root = Path.Combine(dataHome, "kcap", "desktop");
var target = Path.Combine(root, "bin");
var root = StagedRoot(getEnv, home);
var target = StagedCliDirectory(root);
var plugin = BundleLayout.PluginDirectory(root);
var stamp = Path.Combine(root, "image.stamp");
var identity = ImageIdentity();
Expand Down
Loading
Loading