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
1 change: 1 addition & 0 deletions .github/fork-features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,7 @@ features:
- Remote desktop updates reuse the automatic-download reservation and recover failed installations without bypassing local manual-restart confirmation or install-on-quit behavior.
- 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.
- Update summaries retain skipped environment reasons, including late skips and across desktop relaunch.
implementation_paths:
- apps/desktop/src/app/DesktopLifecycle.ts
- apps/desktop/src/app/DesktopShutdownConfirmation.ts
Expand Down
41 changes: 40 additions & 1 deletion apps/web/src/components/sidebar/updateEverything.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,6 @@ describe("update everything summary", () => {
{ label: "alvin", outcome: { status: "failed", message: "Download failed." } },
{ label: "box", outcome: { status: "updated" } },
{ label: "studio", outcome: { status: "updated" } },
{ label: "busy", outcome: { status: "skipped" } },
],
providers: [
{ status: "updated" },
Expand Down Expand Up @@ -362,6 +361,12 @@ describe("update everything summary", () => {
getUpdateEverythingToastView({ ...empty, failures: ["alvin: offline"] }, "0.0.50"),
).toEqual({ type: "error", title: "Update failed", description: "alvin: offline" });
expect(getUpdateEverythingToastView({ ...empty, failures: [] }, "0.0.50")).toBeNull();
expect(
getUpdateEverythingToastView(
{ ...empty, failures: [], restarting: ["Build machine"] },
"0.0.50",
),
).toBeNull();
});

it("names servers still restarting when this app relaunched", () => {
Expand All @@ -376,6 +381,40 @@ describe("update everything summary", () => {
description: "alvin and zbook are still restarting.",
});
});

it.each([
{ updated: true, failed: false, title: "Updated 1 server", type: "warning" },
{ updated: false, failed: false, title: "No updates completed", type: "warning" },
{ updated: false, failed: true, title: "Update failed", type: "error" },
])("reports skipped machines when the outcome is $title", ({ updated, failed, title, type }) => {
const summary = collectUpdateEverythingResult({
localVersion: null,
localFailures: [],
servers: [
...(updated ? [{ label: "Build machine", outcome: { status: "updated" as const } }] : []),
...(failed
? [
{
label: "Build machine",
outcome: { status: "failed" as const, message: "Download failed." },
},
]
: []),
{ label: "Test machine", outcome: { status: "skipped" } },
],
providers: [],
skipped: [{ label: "Offline machine", reason: "not connected" }],
});
expect(getUpdateEverythingToastView(summary, "0.0.50")).toEqual({
type,
title,
description: [
...(failed ? ["Build machine: Download failed."] : []),
"Skipped Offline machine: not connected.",
"Skipped Test machine: update did not start.",
].join("\n"),
});
});
});

describe("describeUpdateEverything", () => {
Expand Down
22 changes: 16 additions & 6 deletions apps/web/src/components/sidebar/updateEverything.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export interface UpdateEverythingResult {
readonly updatedProviders: number;
readonly failures: ReadonlyArray<string>;
readonly restarting: ReadonlyArray<string>;
readonly skipped?: ReadonlyArray<string>;
}

export function collectUpdateEverythingResult(input: {
Expand All @@ -309,12 +310,20 @@ export function collectUpdateEverythingResult(input: {
| { readonly status: "interrupted" | "updated" }
| { readonly status: "failed"; readonly line: string }
>;
readonly skipped?: ReadonlyArray<Pick<SkippedMachine, "label" | "reason">>;
}): UpdateEverythingResult {
const skipped = [
...(input.skipped ?? []).map(({ label, reason }) => `Skipped ${label}: ${reason}.`),
...input.servers.flatMap(({ label, outcome }) =>
outcome.status === "skipped" ? [`Skipped ${label}: update did not start.`] : [],
),
];
return {
localVersion: input.localVersion,
updatedServers: input.servers.filter(({ outcome }) => outcome.status === "updated").length,
updatedProviders: input.providers.filter((outcome) => outcome.status === "updated").length,
restarting: [],
...(skipped.length > 0 ? { skipped } : {}),
failures: [
...input.localFailures.map((failure) => `This app: ${failure}`),
...input.servers.flatMap(({ label, outcome }) =>
Expand All @@ -329,7 +338,7 @@ export function getUpdateEverythingToastView(
result: UpdateEverythingResult,
appVersion: string,
): {
readonly type: "success" | "error";
readonly type: "success" | "warning" | "error";
readonly title: string;
readonly description?: string;
} | null {
Expand All @@ -344,18 +353,19 @@ export function getUpdateEverythingToastView(
: [
`${joinList(result.restarting)} ${result.restarting.length === 1 ? "is" : "are"} still restarting.`,
];
const notices = [...(result.skipped ?? []), ...restarting];
if (result.failures.length === 0) {
if (parts.length === 0) return null;
if (parts.length === 0 && !result.skipped?.length) return null;
return {
type: "success",
title: `Updated ${joinList(parts)}`,
...(restarting.length === 0 ? {} : { description: restarting.join("\n") }),
type: result.skipped?.length ? "warning" : "success",
title: parts.length > 0 ? `Updated ${joinList(parts)}` : "No updates completed",
...(notices.length === 0 ? {} : { description: notices.join("\n") }),
};
}
return {
type: "error",
title: parts.length === 0 ? "Update failed" : `Updated ${joinList(parts)}`,
description: [...result.failures, ...restarting].join("\n"),
description: [...result.failures, ...notices].join("\n"),
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vite-plus/test";

import { APP_VERSION } from "~/branding";
import { AppAtomRegistryProvider } from "~/rpc/atomRegistry";
import { toastManager } from "../ui/toast";
import { collectUpdateEverythingResult } from "./updateEverything.logic";
import {
clearSavedUpdateEverythingResult,
DesktopUpdateLaunchNotice,
saveUpdateEverythingResult,
setUpdateEverythingRun,
} from "./updateEverythingRun";

it("restores skipped reasons once after relaunching, including older results without them", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const toast = vi.spyOn(toastManager, "add");
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const savedKey = "t3code:update-everything-result:v1";
const description = [
"Skipped Offline machine: not connected.",
"Skipped Test machine: update did not start.",
].join("\n");
const launch = () =>
act(async () => {
root.render(
<AppAtomRegistryProvider>
<DesktopUpdateLaunchNotice />
</AppAtomRegistryProvider>,
);
});

try {
setUpdateEverythingRun(() => null);
saveUpdateEverythingResult(
collectUpdateEverythingResult({
localVersion: APP_VERSION,
localFailures: [],
servers: [{ label: "Test machine", outcome: { status: "skipped" } }],
providers: [],
skipped: [{ label: "Offline machine", reason: "not connected" }],
}),
);

// Relaunch loses the in-memory run; only the saved schema can restore the notice.
setUpdateEverythingRun(() => null);
await launch();
expect(toast).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
type: "warning",
title: "Updated this app",
description: <span className="whitespace-pre-line">{description}</span>,
}),
);
expect(window.localStorage.getItem(savedKey)).toBeNull();

await act(async () => root.render(null));
await launch();
expect(toast).toHaveBeenCalledTimes(1);

await act(async () => root.render(null));
window.localStorage.setItem(
savedKey,
JSON.stringify({
savedAt: Date.now(),
localVersion: APP_VERSION,
updatedServers: 0,
updatedProviders: 0,
failures: [],
}),
);
await launch();
expect(toast).toHaveBeenCalledTimes(2);
expect(toast).toHaveBeenLastCalledWith(
expect.objectContaining({ type: "success", title: "Updated this app" }),
);
} finally {
await act(async () => root.unmount());
container.remove();
clearSavedUpdateEverythingResult();
setUpdateEverythingRun(() => null);
vi.restoreAllMocks();
vi.unstubAllGlobals();
}
});
2 changes: 2 additions & 0 deletions apps/web/src/components/sidebar/updateEverythingRun.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ const SavedResult = Schema.Struct({
updatedServers: Schema.Number,
updatedProviders: Schema.Number,
failures: Schema.Array(Schema.String),
skipped: Schema.optionalKey(Schema.Array(Schema.String)),
handedOff: Schema.optional(Schema.Array(HandedOffServer)),
});
const decodeSavedResult = Schema.decodeUnknownOption(Schema.fromJsonString(SavedResult));
Expand Down Expand Up @@ -206,6 +207,7 @@ export function saveUpdateEverythingResult(result: UpdateEverythingResult): void
updatedServers: result.updatedServers,
updatedProviders: result.updatedProviders,
failures: result.failures,
...(result.skipped?.length ? { skipped: result.skipped } : {}),
handedOff,
} satisfies typeof SavedResult.Type),
);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/sidebar/useUpdateEverything.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ export function useUpdateEverything(localUpdateVersion: string | null) {
localFailures,
servers: finished,
providers: outcome.providers,
skipped,
});
if (outcome.localReady && installLocal) {
if (await waitForUpdateRestart(UPDATE_RESTART_DELAY_MS)) {
Expand Down
2 changes: 2 additions & 0 deletions docs/user/updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Server updates appear only after that version is published to npm. Remote
desktop apps do not wait for npm. The confirmation lists each machine it skips
and why, such as a machine that is offline or needs a manual update. Use the
notice or **Settings → Connections** for those.
The final update summary also names skipped machines and their reasons, including
after the desktop app restarts.

If a desktop app restarts on its old version, styal tells you the update did
not apply. It does not install that version again on its own, on quit or when
Expand Down
Loading