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
24 changes: 24 additions & 0 deletions .github/fork-features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -275,18 +275,32 @@ features:
status: maintained
prs: [110, 128, 358]
invariants:
- Desktop activity authentication remains available for the host process lifetime, including the first quit after days of uptime and renewal after bearer expiry.
- In-app shutdown dialogs appear only for active or unknown work and name the threads and terminal sessions that will be interrupted; inspection warnings appear only for activity that could not be checked.
- Manual desktop quits and restarts check every owned backend; active or unknown host activity requires confirmation, and cancellation preserves windows and downloaded updates.
- Shutdown confirmation is available on recovery screens; failed presentation alone arms a subsequent Quit override that preserves cleanup, cannot be consumed by restart actions, and clears when the renderer recovers or displays a dialog.
- Available desktop updates download automatically while preserving accurate available, downloading, downloaded, install, and retry state across updater events and pre-install refreshes.
- Downloaded updates install on normal app quit; the sidebar only offers restart once an installer is ready, while Settings retains checks, progress, and retries.
- A late native window-close event cannot bypass cleanup and the selected-channel installer handoff after scoped lifecycle listeners are removed.
- Changing update channels clears installation eligibility; only a ready download from the selected channel may install on quit, including on macOS where native staging waits until shutdown.
implementation_paths:
- apps/desktop/src/app/DesktopLifecycle.ts
- apps/desktop/src/app/DesktopShutdownConfirmation.ts
- apps/desktop/src/app/DesktopShutdownGuard.ts
- apps/desktop/src/electron/ElectronUpdater.ts
- apps/desktop/src/ipc/methods/serverExposure.ts
- apps/desktop/src/ipc/methods/shutdownConfirmation.ts
- apps/desktop/src/ipc/methods/wsl.ts
- apps/desktop/src/updates/DesktopUpdates.ts
- apps/desktop/src/updates/updateMachine.ts
- apps/server/src/auth/PairingGrantStore.ts
- apps/server/src/environment/HostActivity.ts
- apps/web/src/AppRoot.tsx
- apps/web/src/components/ConfirmDialogHost.tsx
- apps/web/src/components/desktopUpdate.logic.ts
- apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx
- apps/web/src/components/sidebar/SidebarUpdatePill.tsx
- apps/web/src/confirmDialog.ts
- packages/contracts/src/ipc.ts
upstream_paths:
- apps/desktop/src/updates/DesktopUpdates.ts
Expand All @@ -297,10 +311,20 @@ features:
- packages/contracts/src/ipc.ts
tests:
- apps/desktop/src/app/DesktopLifecycle.test.ts
- apps/desktop/src/app/DesktopShutdownConfirmation.test.ts
- apps/desktop/src/app/DesktopShutdownGuard.test.ts
- apps/desktop/src/backend/DesktopServerExposure.test.ts
- apps/desktop/src/electron/ElectronUpdater.test.ts
- apps/desktop/src/ipc/methods/wsl.test.ts
- apps/desktop/src/updates/DesktopUpdates.test.ts
- apps/desktop/src/updates/updateMachine.test.ts
- apps/desktop/src/window/DesktopWindow.test.ts
- apps/server/src/auth/EnvironmentAuth.test.ts
- apps/server/src/auth/PairingGrantStore.test.ts
- apps/server/src/environment/HostActivity.test.ts
- apps/server/src/terminal/Manager.test.ts
- apps/web/src/components/desktopUpdate.logic.test.ts
- apps/web/src/confirmDialog.test.ts
- apps/web/src/state/desktopUpdate.test.ts
upstream:
status: partial
Expand Down
65 changes: 65 additions & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as DesktopShutdownGuard from "./DesktopShutdownGuard.ts";
import { assert, describe, it } from "@effect/vitest";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -97,8 +98,12 @@ const makeLifecycleLayer = (
destroyAll: Effect.Effect<void> = Effect.void,
activate: Effect.Effect<void> = Effect.void,
installOnQuit: Effect.Effect<boolean> = Effect.succeed(false),
confirmShutdown: Effect.Effect<boolean> = Effect.succeed(true),
) =>
DesktopLifecycle.layer.pipe(
Layer.provideMerge(
Layer.succeed(DesktopShutdownGuard.DesktopShutdownGuard, { confirm: () => confirmShutdown }),
),
Layer.provideMerge(
Layer.succeed(DesktopUpdates.DesktopUpdates, {
getState: Effect.die("unexpected update state read"),
Expand Down Expand Up @@ -133,6 +138,66 @@ const makeLifecycleLayer = (
);

describe("DesktopLifecycle", () => {
it.effect(
"cancelling quit leaves the window and backend alive, then permits a fresh attempt",
() =>
Effect.gen(function* () {
const events = new NodeEvents.EventEmitter();
const finished = yield* Deferred.make<void>();
let confirm = false;
let checks = 0;
let closes = 0;
const app = makeElectronApp({
on: (name, listener) =>
Effect.acquireRelease(
Effect.sync(() => {
events.on(name, listener);
}),
() =>
Effect.sync(() => {
events.removeListener(name, listener);
}),
).pipe(Effect.asVoid),
});
yield* Effect.gen(function* () {
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
const state = yield* DesktopState.DesktopState;
const shutdown = yield* DesktopShutdown.DesktopShutdown;
yield* lifecycle.register;
events.emit("before-quit", { preventDefault: () => undefined });
assert.equal(checks, 1);
assert.equal(closes, 0);
assert.isFalse(yield* Ref.get(state.quitting));
confirm = true;
events.emit("before-quit", { preventDefault: () => undefined });
events.emit("before-quit", { preventDefault: () => undefined });
yield* shutdown.awaitRequest;
assert.equal(checks, 2);
assert.equal(closes, 1);
yield* shutdown.markComplete;
yield* Deferred.await(finished);
}).pipe(
Effect.provide(
makeLifecycleLayer(
"win32",
app,
Effect.void,
Effect.sync(() => {
closes++;
}),
Effect.void,
Effect.void,
Deferred.succeed(finished, undefined).pipe(Effect.as(true)),
Effect.sync(() => {
checks++;
return confirm;
}),
),
),
);
}),
);

it.effect("keeps a late window close from exiting before the update handoff", () =>
Effect.gen(function* () {
const events = new NodeEvents.EventEmitter();
Expand Down
63 changes: 49 additions & 14 deletions apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type * as Electron from "electron";

import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopObservability from "./DesktopObservability.ts";
import * as DesktopShutdownGuard from "./DesktopShutdownGuard.ts";
import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
Expand All @@ -31,6 +32,7 @@ export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass<Deskt
}

export type DesktopLifecycleRuntimeServices =
| DesktopShutdownGuard.DesktopShutdownGuard
| DesktopEnvironment.DesktopEnvironment
| DesktopObservability.DesktopTrace
| DesktopShutdown.DesktopShutdown
Expand All @@ -45,14 +47,14 @@ type DesktopLifecycleRegistrationServices =
| DesktopUpdates.DesktopUpdates;

/**
* @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopTrace | DesktopUpdates | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow
* @effect-expect-leaking DesktopShutdownGuard | DesktopEnvironment | DesktopShutdown | DesktopState | DesktopTrace | DesktopUpdates | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow
*/
export class DesktopLifecycle extends Context.Service<
DesktopLifecycle,
{
readonly relaunch: (
reason: string,
) => Effect.Effect<void, never, DesktopLifecycleRuntimeServices>;
) => Effect.Effect<boolean, never, DesktopLifecycleRuntimeServices>;
readonly register: Effect.Effect<
void,
never,
Expand Down Expand Up @@ -241,10 +243,13 @@ export const make = DesktopLifecycle.of({
const electronApp = yield* ElectronApp.ElectronApp;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const state = yield* DesktopState.DesktopState;
if (yield* Ref.get(state.quitting)) return false;
const guard = yield* DesktopShutdownGuard.DesktopShutdownGuard;
if (!(yield* guard.confirm("restart"))) return false;
yield* logLifecycleInfo("desktop relaunch requested", { reason });
yield* Effect.gen(function* () {
yield* Ref.set(state.quitting, true);
return yield* Effect.gen(function* () {
yield* Effect.yieldNow;
yield* Ref.set(state.quitting, true);
yield* requestDesktopShutdownAndWait();
yield* DesktopObservability.flushTrace;
if (environment.isDevelopment) {
Expand All @@ -262,7 +267,7 @@ export const make = DesktopLifecycle.of({
return logLifecycleError(error.message, { error });
}),
Effect.forkDetach,
Effect.asVoid,
Effect.as(true),
);
}),
register: Effect.gen(function* () {
Expand All @@ -274,6 +279,7 @@ export const make = DesktopLifecycle.of({
const runEffect = Effect.runPromiseWith(context);
const runSync = Effect.runSyncWith(context);
let quitAllowed = false;
let quitCheckPending = false;
let updaterQuitAllowed = false;
yield* electronTheme.onUpdated(() => {
void runEffect(
Expand All @@ -292,15 +298,44 @@ export const make = DesktopLifecycle.of({
);
});
yield* electronApp.on("before-quit", (event: Electron.Event) => {
handleBeforeQuit(
event,
runEffect,
runSync,
() => quitAllowed || updaterQuitAllowed,
() => {
quitAllowed = true;
},
);
if (quitAllowed || updaterQuitAllowed) {
handleBeforeQuit(
event,
runEffect,
runSync,
() => true,
() => {},
);
return;
}
event.preventDefault();
if (quitCheckPending) return;
quitCheckPending = true;
void runEffect(
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
if (yield* Ref.get(state.quitting)) {
quitCheckPending = false;
return;
}
const guard = yield* DesktopShutdownGuard.DesktopShutdownGuard;
if (!(yield* guard.confirm("quit"))) {
quitCheckPending = false;
return;
}
handleBeforeQuit(
event,
runEffect,
runSync,
() => false,
() => {
quitAllowed = true;
},
);
}),
).catch(() => {
quitCheckPending = false;
});
});
yield* electronApp.on("activate", () => {
void runEffect(
Expand Down
Loading
Loading