Skip to content
Open
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
6 changes: 3 additions & 3 deletions .github/fork-features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -858,13 +858,13 @@ features:
- 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 offers a plain restart arrow without a completion checkmark, using the theme update surface and foreground once an installer is ready, while Settings presents automatic checks and downloads as status and reserves download actions for failure retries.
- An install on quit records its target version like an in-app install, so a restart on the old version reports that the update did not apply, and quitting never retries a version whose install did not apply.
- When other machines' servers or providers are behind, the sidebar update pass checks affected remote servers and every backend hosted by this app before confirmation, waiting for both inspections and listing thread names by environment/project with continuation outcomes. Remaining terminal, device, provider-install and inspection warnings stay visible; older hosts retain fallback counts. Explicit consent covers the matching downloaded local version so the installer does not ask again. Remote handoffs run in parallel and a manually requested local restart follows immediately; other callers can request a countdown.
- Combined update confirmations record one exposure and at most one explicit decision through a compatible paired environment, without thread, environment, version or project content.
- The sidebar update pass checks affected remote servers and every backend hosted by this app before deciding whether confirmation is needed. Safe results proceed directly; otherwise one dialog lists thread names by environment/project with continuation outcomes and remaining interruption warnings. Cancellation invalidates pending checks. Explicit consent covers the matching downloaded local version; without consent, remote activity is checked again after provider installations and local activity before handoff. Later checks stay silent unless they find work requiring consent. Remote handoffs run in parallel and a manually requested local restart follows immediately; other callers can request a countdown.
- Combined update confirmations record one exposure and at most one explicit decision after inspection requires confirmation through a compatible paired environment, without thread, environment, version or project content. Loading checks and automatic safe updates emit no confirmation events.
- A downloaded update remains visible in the sidebar during rechecks, with restart temporarily disabled and release notes retained until the check settles.
- 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.
- On macOS a selected-channel download is staged with Squirrel.Mac once, right after it downloads, when the app can be replaced without administrator rights; a staged update is then frozen, with no further checks or downloads, and quit installs it only if it is still the selected-channel download. A failed background staging, or an app that needs administrator rights, stages at quit or restart as before, and a restart whose staging fails or finds no update recovers instead of waiting. Every channel change waits for staging, removes any user ShipIt job, including one left by an earlier process, and clears the download, and fails without changing channels if the job cannot be removed; after that, an update installs only once a new download stages.
- Remote desktop updates reuse the automatic-download reservation and recover failed installations without bypassing local manual-restart confirmation or install-on-quit behavior. Web and mobile inspect every backend owned by the target desktop app, including sibling WSL environments, while retaining explicit remote desktop confirmation. Pool reports require maintenance permission and expose anonymous interruption counts. Selected-environment thread details come from its authorized activity request and replace exactly one identified primary pool entry; missing or ambiguous coverage retains readable details and warns that inspection is incomplete.
- Remote desktop updates reuse the automatic-download reservation and recover failed installations without bypassing local manual-restart confirmation or install-on-quit behavior. Web and mobile inspect every backend owned by the target desktop app, including sibling WSL environments. Only completely checked idle or resumable pools skip confirmation. Pool reports require maintenance permission and expose anonymous interruption counts. Selected-environment thread details come from its authorized activity request and replace exactly one identified primary pool entry; missing or ambiguous coverage retains readable details and requires confirmation. Unconfirmed desktop and service updates recheck before handoff, refusing new blockers while retaining the download for an explicitly confirmed retry; omitted consent never permits interruptions.
- On macOS, while a background-staged selected-channel update waits, the app checks every 30 seconds whether an idle restart could install it - system idle for five minutes or screen locked, no update action or quit under way, and every owned backend checked with no waiting approval or question, running turn that thread continuation will not resume, provider install, or other environment using its devices; resumable turns and terminal commands do not hold it back. It is measure-only - it never installs or shows anything - and reports once per staged version the delay since staging (under 1, 1-8, 8-24 or over 24 hours) at the first such moment, "installed_first" when a quit or the restart button installed it before one, or "never" when a channel change discards it or the app quits without installing it; the quit cases are reported on the next launch.
- Each desktop launch reports, once its main window first shows, the time from the process start to the local backend being ready and to the window showing. After an update restart, the launch of the installed version adds how long the old process took to stop its backends before the installer handoff, the time from that handoff to the new process starting, and on macOS whether the update was staged in the background; a launch that comes back on the old version reports as a normal launch.
implementation_paths:
Expand Down
159 changes: 150 additions & 9 deletions apps/desktop/src/ipc/methods/updates.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,151 @@
import { assert, describe, it } from "@effect/vitest";
import type { HostActivity } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import { HttpClient, HttpClientResponse } from "effect/http";

import * as DesktopShutdownConfirmation from "../../app/DesktopShutdownConfirmation.ts";
import * as DesktopShutdownGuard from "../../app/DesktopShutdownGuard.ts";
import * as DesktopState from "../../app/DesktopState.ts";
import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
import * as DesktopUpdates from "../../updates/DesktopUpdates.ts";
import { flushCallbacks, makeHarness } from "../../updates/updatesTestHarness.ts";
import { installUpdate } from "./updates.ts";

describe("update installation IPC", () => {
it.effect.each([
{ name: "explicitly false", input: { expectedVersion: "1.2.4", confirmed: false } },
{ name: "omitted flag", input: { expectedVersion: "1.2.4" } },
{ name: "legacy input", input: undefined },
])("reinspects final local work through the native guard for $name", ({ input }) => {
let activity: HostActivity = {
activeSessions: 1,
continuableSessions: 1,
waitingSessions: 0,
terminalsRequiringConfirmation: 0,
terminalsWithUnknownActivity: 0,
environmentsUsingDevices: 0,
};
let reads = 0;
let prompts = 0;
let stops = 0;
const backend: DesktopBackendPool.DesktopBackendInstance = {
id: DesktopBackendPool.PRIMARY_INSTANCE_ID,
label: Effect.succeed("Local host"),
start: Effect.void,
stop: () =>
Effect.sync(() => {
stops++;
}),
waitForReady: () => Effect.succeed(true),
snapshot: Effect.succeed({
desiredRunning: true,
ready: true,
activePid: Option.some(100),
restartAttempt: 0,
restartScheduled: false,
}),
currentConfig: Effect.succeed(
Option.some({
executablePath: "/synthetic/electron",
entryPath: "/synthetic/server.mjs",
cwd: "/synthetic",
args: [],
extendEnv: true,
bootstrapDelivery: "fd3",
preflightFailure: Option.none(),
env: {},
captureOutput: true,
httpBaseUrl: new URL("http://127.0.0.1:3773"),
bootstrap: {
mode: "desktop",
noBrowser: true,
port: 3773,
t3Home: "/synthetic/state",
host: "127.0.0.1",
desktopBootstrapToken: "synthetic-bootstrap",
tailscaleServeEnabled: false,
tailscaleServePort: 443,
},
}),
),
};
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
if (request.url.endsWith("/oauth/token")) {
return HttpClientResponse.fromWeb(
request,
Response.json({
access_token: "synthetic-bearer",
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
token_type: "Bearer",
expires_in: 3600,
scope: "orchestration:read",
}),
);
}
reads++;
return HttpClientResponse.fromWeb(request, Response.json(activity));
}),
),
);
const dialog = Layer.succeed(DesktopShutdownConfirmation.DesktopShutdownConfirmation, {
current: Effect.succeed(null),
acknowledge: () => Effect.void,
rendererReady: Effect.void,
consumeQuitOverride: Effect.succeed(false),
resolve: () => Effect.void,
request: () =>
Effect.sync(() => {
prompts++;
return false;
}),
});
const guardLayer = DesktopShutdownGuard.layer.pipe(
Layer.provide(Layer.mergeAll(DesktopBackendPool.layerTest([backend]), http, dialog)),
);

return Effect.gen(function* () {
const guard = yield* DesktopShutdownGuard.DesktopShutdownGuard;
const harness = makeHarness({
backendInstances: [backend],
confirmShutdown: guard.confirm("restart"),
});
yield* Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
const state = yield* DesktopState.DesktopState;
yield* updates.configure;
harness.emit("update-downloaded", { version: "1.2.4" });
yield* flushCallbacks;

assert.equal((yield* guard.inspect)[0]?.terminalsRequiringConfirmation, 0);
activity = { ...activity, terminalsRequiringConfirmation: 1 };

assert.deepInclude(yield* installUpdate.handler(input), { accepted: false });
assert.equal(reads, 2);
assert.equal(prompts, 1);
assert.equal(stops, 0);
assert.isFalse(yield* Ref.get(state.quitting));
assert.isEmpty(harness.installs);
assert.equal(harness.installRecords.size, 0);
assert.equal(harness.updateRestartMarkers.size, 0);
assert.equal((yield* updates.getState).downloadedVersion, "1.2.4");

activity = { ...activity, terminalsRequiringConfirmation: 0 };
assert.deepInclude(yield* installUpdate.handler(input), { accepted: true });
assert.equal(reads, 3);
assert.equal(prompts, 1);
assert.equal(stops, 1);
assert.isTrue(yield* Ref.get(state.quitting));
assert.lengthOf(harness.installs, 1);
}).pipe(Effect.provide(harness.layer));
}).pipe(Effect.scoped, Effect.provide(guardLayer));
});

it.effect("installs the version confirmed by the aggregate dialog without another prompt", () => {
let prompts = 0;
let stops = 0;
Expand All @@ -28,7 +166,7 @@ describe("update installation IPC", () => {
harness.emit("update-downloaded", { version: "1.2.4" });
yield* flushCallbacks;

const result = yield* installUpdate.handler({ expectedVersion: "1.2.4" });
const result = yield* installUpdate.handler({ expectedVersion: "1.2.4", confirmed: true });

assert.deepInclude(result, { accepted: true, completed: false });
assert.equal(prompts, 0);
Expand Down Expand Up @@ -82,9 +220,9 @@ describe("update installation IPC", () => {
}).pipe(Effect.scoped, Effect.provide(harness.layer));
});

it.effect(
"refuses a stale confirmation before stopping the app and permits the matching version",
() => {
it.effect.each([false, true])(
"refuses a stale version even with confirmed=%s and permits the matching version",
(confirmed) => {
let stops = 0;
let prompts = 0;
const harness = makeHarness({
Expand All @@ -104,7 +242,7 @@ describe("update installation IPC", () => {
harness.emit("update-downloaded", { version: "1.2.5" });
yield* flushCallbacks;

const stale = yield* installUpdate.handler({ expectedVersion: "1.2.4" });
const stale = yield* installUpdate.handler({ expectedVersion: "1.2.4", confirmed });

assert.deepInclude(stale, { accepted: false, completed: false });
assert.equal(stops, 0);
Expand All @@ -115,10 +253,13 @@ describe("update installation IPC", () => {
assert.equal(harness.installRecords.size, 0);
assert.equal(harness.updateRestartMarkers.size, 0);

assert.deepInclude(yield* installUpdate.handler({ expectedVersion: "1.2.5" }), {
accepted: true,
completed: false,
});
assert.deepInclude(
yield* installUpdate.handler({ expectedVersion: "1.2.5", confirmed: true }),
{
accepted: true,
completed: false,
},
);
assert.equal(stops, 1);
assert.lengthOf(harness.installs, 1);
}).pipe(Effect.scoped, Effect.provide(harness.layer));
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/ipc/methods/updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ export const installUpdate = DesktopIpc.makeIpcMethod({
const updates = yield* DesktopUpdates.DesktopUpdates;
return yield* input === undefined
? updates.install
: updates.installPrepared(input.expectedVersion);
: updates.installPrepared(input.expectedVersion, {
confirmShutdown: input.confirmed !== true,
});
}),
});

Expand Down
Loading
Loading