Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c35d8a9
feat(updates): switch the whole fleet between Stable and Nightly
t3dotgg Oct 28, 2026
5f560fc
fix(desktop): cancelling a track switch ends it during its own feed c…
t3dotgg Oct 28, 2026
ac813a6
fix(desktop): an uncommitted prepared switch restores its track when …
t3dotgg Oct 28, 2026
e4b190b
refactor(updates): track switches keep their track as intent and guar…
t3dotgg Oct 28, 2026
671880e
fix(desktop): a track switch's checked release is a floor the updater…
t3dotgg Oct 28, 2026
62358f5
fix(updates): track-only hosts never install during a switch; name a …
t3dotgg Oct 28, 2026
7050a28
fix(desktop): a Nightly build never downgrades outside a guarded switch
t3dotgg Oct 28, 2026
87986bb
fix(desktop): builds cross tracks only through a guarded switch
t3dotgg Oct 28, 2026
258647a
test(desktop): a restarted Stable build with Nightly saved never cros…
t3dotgg Oct 28, 2026
b0dda60
fix(desktop): a retained offer below a switch's floor is re-checked, …
t3dotgg Oct 28, 2026
107a546
fix(updates): guard database compatibility for track switches in both…
t3dotgg Oct 28, 2026
bf8f3da
fix(server): an unchecked cross-track update points to the guarded sw…
t3dotgg Oct 28, 2026
92aa25b
fix(updates): remote desktops with WSL backends switch tracks locally…
t3dotgg Oct 28, 2026
644c34e
fix(web): the local switch check counts every backend the desktop run…
t3dotgg Oct 28, 2026
1be2756
fix(updates): an unreadable database blocks a switch, and a host left…
t3dotgg Oct 28, 2026
09da56c
fix(server): read the database only for a crossing that can proceed; …
t3dotgg Oct 28, 2026
e9f3993
fix(desktop): a crossing that leaves the host on its old track fails,…
t3dotgg Oct 28, 2026
191b753
fix(web): update buttons point to Update track for a cross-track upda…
t3dotgg Oct 29, 2026
adf2595
fix(web): Update all skips a machine whose update would cross tracks
t3dotgg Oct 29, 2026
83ae076
fix(web): the crossing check takes only the fields it reads
t3dotgg Oct 29, 2026
17484d2
fix(web): shorter cross-track note
t3dotgg Oct 29, 2026
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
18 changes: 12 additions & 6 deletions apps/desktop/src/ipc/methods/updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ export const getUpdateState = DesktopIpc.makeIpcMethod({

export const setUpdateChannel = DesktopIpc.makeIpcMethod({
channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL,
payload: DesktopUpdateChannelSchema,
payload: Schema.Struct({
channel: DesktopUpdateChannelSchema,
minimumVersion: Schema.optional(Schema.String),
}),
result: DesktopUpdateStateSchema,
handler: Effect.fn("desktop.ipc.updates.setChannel")(function* (channel) {
handler: Effect.fn("desktop.ipc.updates.setChannel")(function* ({ channel, minimumVersion }) {
const updates = yield* DesktopUpdates.DesktopUpdates;
return yield* updates.setChannel(channel);
return yield* updates.setChannel(channel, { minimumVersion });
}),
});

Expand All @@ -43,11 +46,14 @@ export const downloadUpdate = DesktopIpc.makeIpcMethod({

export const installUpdate = DesktopIpc.makeIpcMethod({
channel: IpcChannels.UPDATE_INSTALL_CHANNEL,
payload: Schema.Void,
/** A version installs only that downloaded build, never one that replaced it. */
payload: Schema.UndefinedOr(Schema.String),
result: DesktopUpdateActionResultSchema,
handler: Effect.fn("desktop.ipc.updates.install")(function* () {
handler: Effect.fn("desktop.ipc.updates.install")(function* (expectedVersion) {
const updates = yield* DesktopUpdates.DesktopUpdates;
return yield* updates.install;
if (expectedVersion === undefined) return yield* updates.install;
const { accepted, completed, state } = yield* updates.installPrepared(expectedVersion);
return { accepted, completed, state };
}),
});

Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,11 +261,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
};
},
getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL),
setUpdateChannel: (channel) =>
ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel),
setUpdateChannel: (channel, minimumVersion) =>
ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, { channel, minimumVersion }),
checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL),
downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL),
installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL),
installUpdate: (expectedVersion) =>
ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL, expectedVersion),
cliCommand: {
getState: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL),
install: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_INSTALL_CHANNEL),
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/settings/DesktopAppSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
normalizeLinuxPasswordStorePreference,
type LinuxPasswordStorePreference,
} from "../linuxSecretStorage.ts";
import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts";
import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease";
import { isValidDistroName } from "../wsl/wslPathParsing.ts";

export interface DesktopSettings {
Expand Down Expand Up @@ -180,7 +180,7 @@ export class DesktopAppSettings extends Context.Service<
export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings {
return {
...DEFAULT_DESKTOP_SETTINGS,
updateChannel: resolveDefaultDesktopUpdateChannel(appVersion),
updateChannel: desktopUpdateChannelOf(appVersion),
};
}

Expand Down
269 changes: 269 additions & 0 deletions apps/desktop/src/updates/DesktopRemoteUpdates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,239 @@ describe("DesktopRemoteUpdates", () => {
);
});

it.effect("switches to the requested track before preparing its release", () => {
// In-memory settings keep the channel write off real disk I/O.
const harness = makeHarness({ beforeSetUpdateChannel: Effect.void });

return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* Queue.offer(requests, {
...request("req-switch"),
channel: "nightly",
targetVersion: "1.2.4-nightly.20261027.1",
});
yield* settle;
assert.equal((yield* updates.getState).channel, "nightly");

harness.emit("update-available", { version: "1.2.4-nightly.20261027.1" });
yield* settle;
harness.emit("update-downloaded", { version: "1.2.4-nightly.20261027.1" });
yield* settle;

const terminals = terminalReports(reports);
assert.deepEqual(
terminals.map((report) => [report.requestId, report.outcome]),
[["req-switch", "ready-to-install"]],
);
assert.equal(terminals[0]?.state.downloadedVersion, "1.2.4-nightly.20261027.1");
}),
);
});

it.effect(
"refuses a crossing whose feed has only an older build, and never offers it later",
() => {
const harness = makeHarness({ beforeSetUpdateChannel: Effect.void });

return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
const older = "1.2.4-nightly.20261020.1";
yield* Queue.offer(requests, {
...request("req-older"),
channel: "nightly",
targetVersion: "1.2.4-nightly.20261027.1",
});
yield* settle;
// Answers the track change's check, then the request's own check.
harness.emit("update-available", { version: older });
yield* settle;
harness.emit("update-available", { version: older });
yield* settle;

assert.deepEqual(
terminalReports(reports).map((report) => [report.outcome, report.reason]),
[["failed", "The desktop app found no build of 1.2.4-nightly.20261027.1."]],
);
// The saved track is the user's intent; a retry picks it up from here.
assert.equal((yield* updates.getState).channel, "nightly");

// A plain update afterwards cannot reach the build the switch refused.
yield* Queue.offer(requests, request("req-plain"));
yield* settle;
harness.emit("update-available", { version: older });
yield* settle;
assert.isNull((yield* updates.getState).availableVersion);
assert.equal(harness.downloadCount(), 0);
}),
);
},
);

it.effect("only saves the track on a host already running the release", () => {
const harness = makeHarness({ savedChannel: "nightly" });

return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* Queue.offer(requests, {
...request("req-track-only"),
channel: "latest",
targetVersion: "1.2.3",
});
yield* settle;
// Even with a newer build on the feed, the host only saves its track.
harness.emit("update-available", { version: "1.2.4" });
yield* settle;

assert.deepEqual(
terminalReports(reports).map((report) => report.outcome),
["up-to-date"],
);
assert.equal((yield* updates.getState).channel, "latest");
assert.equal(harness.downloadCount(), 0);
}),
);
});

it.effect("drops a retained download below the switch's checked release", () => {
const harness = makeHarness({ beforeSetUpdateChannel: Effect.void });

return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
// Downloaded earlier on this Stable host, before the switch checked 1.2.5.
harness.emit("update-downloaded", { version: "1.2.4" });
yield* settle;
assert.equal((yield* updates.getState).downloadedVersion, "1.2.4");

yield* Queue.offer(requests, {
...request("req-floor"),
channel: "latest",
targetVersion: "1.2.5",
});
yield* settle;
harness.emit("update-available", { version: "1.2.5" });
yield* settle;
harness.emit("update-downloaded", { version: "1.2.5" });
yield* settle;

assert.deepEqual(
terminalReports(reports).map((report) => [
report.outcome,
report.state.downloadedVersion,
]),
[["ready-to-install", "1.2.5"]],
);
}),
);
});

it.effect("refuses to cross tracks remotely while WSL backends need their own checks", () => {
const harness = makeHarness({ beforeSetUpdateChannel: Effect.void, wslBackend: true });

return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* Queue.offer(requests, {
...request("req-wsl"),
channel: "nightly",
targetVersion: "1.2.4-nightly.20261027.1",
});
yield* settle;

assert.deepEqual(
terminalReports(reports).map((report) => report.outcome),
["failed"],
);
assert.include(terminalReports(reports)[0]?.reason ?? "", "Switch it on that machine");
assert.equal((yield* updates.getState).channel, "latest");
}),
);
});

it.effect("fails a switch that leaves a same-track host below the checked release", () => {
const harness = makeHarness({ beforeSetUpdateChannel: Effect.void });

return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
Effect.gen(function* () {
yield* Queue.offer(requests, {
...request("req-behind"),
channel: "latest",
targetVersion: "1.2.5",
});
yield* settle;
// The feed lags npm and has nothing newer than this host's 1.2.3.
harness.emit("update-not-available");
yield* settle;
harness.emit("update-not-available");
yield* settle;

assert.deepEqual(
terminalReports(reports).map((report) => [report.outcome, report.reason]),
[["failed", "The desktop app found no build of 1.2.5."]],
);
}),
);
});

it.effect("fails a crossing to an older-numbered Stable that the feed does not offer", () => {
const harness = makeHarness({
appVersion: "1.2.4-nightly.20261027.1",
beforeSetUpdateChannel: Effect.void,
});

return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
Effect.gen(function* () {
yield* Queue.offer(requests, {
...request("req-to-stable"),
channel: "latest",
targetVersion: "1.2.3",
});
yield* settle;
harness.emit("update-not-available");
yield* settle;
harness.emit("update-not-available");
yield* settle;

// Still on Nightly, though the target is numbered below this build.
assert.deepEqual(
terminalReports(reports).map((report) => [report.outcome, report.reason]),
[["failed", "The desktop app found no build of 1.2.3."]],
);
}),
);
});

it.effect("accepts a newer build than the checked release on the requested track", () => {
const harness = makeHarness({ beforeSetUpdateChannel: Effect.void });

return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
Effect.gen(function* () {
const newer = "1.2.4-nightly.20261028.2";
yield* Queue.offer(requests, {
...request("req-newer"),
channel: "nightly",
targetVersion: "1.2.4-nightly.20261027.1",
});
yield* settle;
harness.emit("update-available", { version: newer });
yield* settle;
harness.emit("update-downloaded", { version: newer });
yield* settle;

assert.deepEqual(
terminalReports(reports).map((report) => [
report.outcome,
report.state.downloadedVersion,
]),
[["ready-to-install", newer]],
);
}),
);
});

it.effect("reports a failed outcome when quitAndInstall fails", () => {
const harness = makeHarness({
quitAndInstall: Effect.fail(
Expand Down Expand Up @@ -525,6 +758,42 @@ describe("DesktopRemoteUpdates", () => {
);
});

it.effect("cancels a track switch while its own feed check is still running", () => {
const releaseCheck = Deferred.makeUnsafe<void>();
const harness = makeHarness({
beforeSetUpdateChannel: Effect.void,
checkForUpdates: Deferred.await(releaseCheck),
});

return runRemoteUpdatesTest(harness, ({ requests, cancellations }) =>
Effect.gen(function* () {
yield* Queue.offer(requests, {
...request("req-switch-cancel"),
channel: "nightly",
targetVersion: "1.2.4-nightly.20261027.1",
});
yield* settle;
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* Queue.offer(cancellations, {
version: 1,
type: "cancelDesktopUpdate",
requestId: "req-switch-cancel",
});
yield* settle;
// The next request starts without waiting for the cancelled switch's feed check.
yield* Queue.offer(requests, {
...request("req-switch-back"),
channel: "latest",
targetVersion: "1.2.3",
});
yield* settle;

assert.equal((yield* updates.getState).channel, "latest");
yield* Deferred.succeed(releaseCheck, undefined);
}),
);
});

it.effect("cancels an active preparation so the next request can run", () => {
const releaseCheck = Deferred.makeUnsafe<void>();
const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) });
Expand Down
Loading
Loading