authorclover caruso <git@paperclover.net> 2026-10-12 17:29:07-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
log44b17c3a0daa9b95d2b15f3d87ff07cf8c3b3d64
tree5013542b44f00e0438aeeb37fc9ce066a33ae71b
parent97b13b7697f982a8dfb6ebe7fd7b540861e75dd9
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

feat: distribute small verified fork updates

Clover wants every self-tested change available to her machines without uploading Electron for each UI revision, plus a public download page she can share. Publish separate checksum-addressed UI, static asset, application-code, and portable-server archives. Reuse unchanged code and shell artifacts by their runtime identities. Publish immutable completion manifests and discover releases through the public Copyparty listing; authenticated writes and signing credentials stay in host configuration. Download updates automatically. Apply UI updates after one minute unfocused or ten minutes idle only after saving durable drafts, composer selection, and timeline anchors; defer while browser guests, terminal views, dialogs, image processing, or other work cannot survive refresh. Backend and shell changes retain explicit restart confirmation. Select staged code through the signed bootstrap, verify it before activation, retain bundled recovery, and keep UI-only updates out of backend restart plans. Build macOS, Linux, and Windows installers with their native dependencies. Ingest verified platform resources without replacing another platform's backend, UI, or assets. Keep the portable server and its matching UI together when native builds finish in a different order. Merge platform publications by each native build version before generating the root download page. Retain up to two nonconflicting completed manifests with their full referenced artifact closure, and stop cleanup when the completion set changes. Use the same feed for the persistent server container. Cache independent server/UI/assets archives, check the locked native dependency and bootstrap contract, and prefetch without activating code. A confirmed restart selects the staged release; interrupted downloads and feed outages retain the installed runtime. Derive server identity from its selected compiled release. Give each desktop platform one stable installer filename in the README and updater metadata. Verify staged installer bytes before promotion, retire completed releases whose installer checksums would no longer match, and keep exact merged publication intent for retries. Keep one active installer per platform, preserve native versions and signatures, and serialize publication. Select development or distribution signing from the actual Mac certificate. Developer ID builds require notarization credentials, hardened runtime, notarization, stapling, and Gatekeeper verification before publication; private signing configuration stays outside the checkout. Assisted-by: gpt-6.1-sol

76 files changed, 7086 insertions(+), 158 deletions(-)

apps/desktop/scripts/main-process-bundle.test.mjs+58-10
...@@ -113,28 +113,23 @@ void import("./linux.ts").then(({ result }) => process.emit("ready", result));`,...@@ -113,28 +113,23 @@ void import("./linux.ts").then(({ result }) => process.emit("ready", result));`,
113it("loads the emitted packaged boot entry and backend cache preload", async () => {113it("loads the emitted packaged boot entry and backend cache preload", async () => {
114 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-boot-"));114 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-boot-"));
115 try {115 try {
116 const entries = ["src/boot.ts", "src/compileCache.ts"];116 const entries = new Set(["src/boot.ts", "src/compileCache.ts"]);
117 await NodeFSP.mkdir(NodePath.join(directory, "src"));117 const outputDirectory = NodePath.join(directory, "bundle/apps/desktop/dist-electron");
118 await Promise.all(
119 entries.map((entry) =>
120 NodeFSP.copyFile(new URL(`../${entry}`, import.meta.url), NodePath.join(directory, entry)),
121 ),
122 );
123 assert.ok(Array.isArray(desktopConfig.pack));118 assert.ok(Array.isArray(desktopConfig.pack));
124 for (const packConfig of desktopConfig.pack) {119 for (const packConfig of desktopConfig.pack) {
125 if (!Array.isArray(packConfig.entry)) continue;120 if (!Array.isArray(packConfig.entry)) continue;
126 if (!packConfig.entry.some((entry) => entries.includes(entry))) continue;121 if (!packConfig.entry.some((entry) => entries.has(entry))) continue;
127 await build({122 await build({
128 ...packConfig,123 ...packConfig,
129 config: false,124 config: false,
130 cwd: directory,125 cwd: NodePath.resolve(import.meta.dirname, ".."),
126 outDir: outputDirectory,
131 tsconfig: false,127 tsconfig: false,
132 sourcemap: false,128 sourcemap: false,
133 onSuccess: undefined,129 onSuccess: undefined,
134 logLevel: "silent",130 logLevel: "silent",
135 });131 });
136 }132 }
137 const outputDirectory = NodePath.join(directory, "dist-electron");
138 const fixture = `console.log(require('node:module').getCompileCacheDir() ? 'cached' : 'uncached');`;133 const fixture = `console.log(require('node:module').getCompileCacheDir() ? 'cached' : 'uncached');`;
139 await NodeFSP.writeFile(NodePath.join(outputDirectory, "main.cjs"), fixture);134 await NodeFSP.writeFile(NodePath.join(outputDirectory, "main.cjs"), fixture);
140 await NodeFSP.writeFile(135 await NodeFSP.writeFile(
...@@ -167,6 +162,59 @@ it("loads the emitted packaged boot entry and backend cache preload", async () =...@@ -167,6 +162,59 @@ it("loads the emitted packaged boot entry and backend cache preload", async () =
167 assert.equal(child.stdout.trim(), disabled ? "uncached" : "cached");162 assert.equal(child.stdout.trim(), disabled ? "uncached" : "cached");
168 }163 }
169 }164 }
165 const shell = "a".repeat(64);
166 const payload = NodePath.join(directory, "data/userdata/fork-releases/release-test");
167 for (const name of ["apps/desktop/dist-electron", "apps/server/dist/client"]) {
168 await NodeFSP.mkdir(NodePath.join(payload, name), { recursive: true });
169 }
170 await NodeFSP.cp(outputDirectory, NodePath.join(payload, "apps/desktop/dist-electron"), {
171 recursive: true,
172 });
173 await NodeFSP.writeFile(
174 NodePath.join(payload, "apps/desktop/dist-electron/main.cjs"),
175 "console.log('payload:' + JSON.parse(process.env.T3CODE_DESKTOP_PAYLOAD).version);",
176 );
177 await NodeFSP.writeFile(NodePath.join(payload, "apps/desktop/dist-electron/preload.cjs"), "");
178 await NodeFSP.writeFile(NodePath.join(payload, "apps/server/dist/bin.mjs"), "");
179 await NodeFSP.writeFile(
180 NodePath.join(payload, "apps/server/dist/client/index.html"),
181 "fixture",
182 );
183 await NodeFSP.writeFile(
184 NodePath.join(directory, "bundle/knife-runtime.json"),
185 JSON.stringify({ shell }),
186 );
187 await NodeFSP.writeFile(
188 NodePath.join(payload, "../current.json"),
189 JSON.stringify({
190 directory: "release-test",
191 shell,
192 runtime: "b".repeat(64),
193 version: "0.0.2000000000",
194 }),
195 );
196 const electronStub = NodePath.join(directory, "electron.cjs");
197 await NodeFSP.writeFile(
198 electronStub,
199 `
200 Object.defineProperty(process.versions, 'electron', { value: '44.4.5' });
201 const Module = require('node:module');
202 const load = Module._load;
203 Module._load = function(name, ...args) {
204 return name === 'electron' ? { app: { isPackaged: true } } : load.call(this, name, ...args);
205 };
206 `,
207 );
208 const selected = NodeChildProcess.spawnSync(
209 process.execPath,
210 ["--require", electronStub, NodePath.join(outputDirectory, "boot.cjs")],
211 {
212 encoding: "utf8",
213 env: { ...process.env, T3CODE_HOME: NodePath.join(directory, "data") },
214 },
215 );
216 assert.equal(selected.status, 0, selected.stderr);
217 assert.equal(selected.stdout.trim(), "payload:0.0.2000000000");
170 } finally {218 } finally {
171 await NodeFSP.rm(directory, { recursive: true, force: true });219 await NodeFSP.rm(directory, { recursive: true, force: true });
172 }220 }
apps/desktop/src/app/DesktopApp.ts+3-3
...@@ -32,6 +32,7 @@ import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";...@@ -32,6 +32,7 @@ import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
32import * as DesktopState from "./DesktopState.ts";32import * as DesktopState from "./DesktopState.ts";
33import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts";33import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts";
34import * as DesktopUpdates from "../updates/DesktopUpdates.ts";34import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
35import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts";
35import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts";36import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts";
36import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";37import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";
37import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";38import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
...@@ -171,14 +172,13 @@ const bootstrap = Effect.gen(function* () {...@@ -171,14 +172,13 @@ const bootstrap = Effect.gen(function* () {
171 yield* logBootstrapInfo("bootstrap start");172 yield* logBootstrapInfo("bootstrap start");
172173
173 const settings = yield* desktopSettings.get;174 const settings = yield* desktopSettings.get;
174 // The renderer is served from the bundled client (or Vite in development)175 // The renderer loads independently of the chat server.
175 // rather than through the local backend, so the window can open without one.
176 const electronProtocol = yield* ElectronProtocol.ElectronProtocol;176 const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
177 yield* electronProtocol.registerDesktopProtocol({177 yield* electronProtocol.registerDesktopProtocol({
178 scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment),178 scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment),
179 ...(environment.isDevelopment179 ...(environment.isDevelopment
180 ? { targetOrigin: Option.getOrThrow(environment.devServerUrl) }180 ? { targetOrigin: Option.getOrThrow(environment.devServerUrl) }
181 : { assetDirectory: environment.clientAssetsDir }),181 : { assetDirectories: (yield* DesktopLocalUi.DesktopLocalUi).assetDirectories }),
182 clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname,182 clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname,
183 });183 });
184 yield* installDesktopIpcHandlers();184 yield* installDesktopIpcHandlers();
apps/desktop/src/app/DesktopEnvironment.test.ts+26
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as NodePath from "@effect/platform-node/NodePath";1import * as NodePath from "@effect/platform-node/NodePath";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";3import { assert, describe, it } from "@effect/vitest";
4import { vi } from "vite-plus/test";
4import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";7import * as Option from "effect/Option";
...@@ -141,6 +142,31 @@ describe("DesktopEnvironment", () => {...@@ -141,6 +142,31 @@ describe("DesktopEnvironment", () => {
141 }),142 }),
142 );143 );
143144
145 it.effect("keeps downloaded Windows desktop and server dependencies separate", () =>
146 Effect.gen(function* () {
147 vi.stubEnv("T3CODE_DESKTOP_PAYLOAD", JSON.stringify({ directory: "/updates/release-new" }));
148 try {
149 const environment = yield* makeEnvironment({
150 platform: "win32",
151 isPackaged: true,
152 appPath: "/updates/release-new",
153 resourcesPath: "/install/resources",
154 });
155 assert.equal(environment.serverRoot, "/updates/release-new/server");
156 assert.equal(
157 environment.backendEntryPath,
158 "/updates/release-new/server/apps/server/dist/bin.mjs",
159 );
160 assert.equal(
161 environment.clientAssetsDir,
162 "/updates/release-new/server/apps/server/dist/client",
163 );
164 } finally {
165 vi.unstubAllEnvs();
166 }
167 }),
168 );
169
144 it.effect("uses the stable desktop entry as the packaged Linux portal identity", () =>170 it.effect("uses the stable desktop entry as the packaged Linux portal identity", () =>
145 Effect.gen(function* () {171 Effect.gen(function* () {
146 const environment = yield* makeEnvironment({172 const environment = yield* makeEnvironment({
apps/desktop/src/app/DesktopEnvironment.ts+6-7
...@@ -11,6 +11,8 @@ import * as Layer from "effect/Layer";...@@ -11,6 +11,8 @@ import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";11import * as Option from "effect/Option";
12import * as Path from "effect/Path";12import * as Path from "effect/Path";
1313
14import { activeForkRuntime } from "../updates/forkRuntime.ts";
15
14import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";16import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
15import * as DesktopConfig from "./DesktopConfig.ts";17import * as DesktopConfig from "./DesktopConfig.ts";
16import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts";18import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts";
...@@ -54,12 +56,7 @@ export class DesktopEnvironment extends Context.Service<...@@ -54,12 +56,7 @@ export class DesktopEnvironment extends Context.Service<
54 readonly browserArtifactsDir: string;56 readonly browserArtifactsDir: string;
55 readonly rootDir: string;57 readonly rootDir: string;
56 readonly appRoot: string;58 readonly appRoot: string;
57 // Root of the tree containing apps/server/dist and node_modules for the59 // Windows keeps separate desktop and server dependency trees.
58 // backend. Equals appRoot everywhere except packaged Windows, where the
59 // server tree ships as the resources/server.asar sidecar (see
60 // scripts/build-desktop-artifact.ts) that the asar-aware
61 // ELECTRON_RUN_AS_NODE primary reads in place and the WSL backend
62 // extracts on demand (see DesktopWslServerTree).
63 readonly serverRoot: string;60 readonly serverRoot: string;
64 readonly backendEntryPath: string;61 readonly backendEntryPath: string;
65 // Built web client the packaged renderer is served from over t3code://app.62 // Built web client the packaged renderer is served from over t3code://app.
...@@ -173,7 +170,9 @@ const make = Effect.fn("desktop.environment.make")(function* (...@@ -173,7 +170,9 @@ const make = Effect.fn("desktop.environment.make")(function* (
173 const appRoot = input.isPackaged ? input.appPath : rootDir;170 const appRoot = input.isPackaged ? input.appPath : rootDir;
174 const serverRoot =171 const serverRoot =
175 input.isPackaged && input.platform === "win32"172 input.isPackaged && input.platform === "win32"
176 ? path.join(input.resourcesPath, "server.asar")173 ? activeForkRuntime() === undefined
174 ? path.join(input.resourcesPath, "server.asar")
175 : path.join(appRoot, "server")
177 : appRoot;176 : appRoot;
178 const branding = resolveDesktopAppBranding({177 const branding = resolveDesktopAppBranding({
179 isDevelopment,178 isDevelopment,
apps/desktop/src/app/DesktopPreReadyPlatform.test.ts+1-1
...@@ -175,7 +175,7 @@ describe("DesktopPreReadyPlatform", () => {...@@ -175,7 +175,7 @@ describe("DesktopPreReadyPlatform", () => {
175 const identity = yield* Effect.promise(() => portalIdentity);175 const identity = yield* Effect.promise(() => portalIdentity);
176 assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop");176 assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop");
177 assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U');177 assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U');
178 assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)");178 assert.include(identity.desktopEntry ?? "", "Name=The Knife");
179 assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;");179 assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;");
180 assert.include(180 assert.include(
181 identity.desktopEntry ?? "",181 identity.desktopEntry ?? "",
apps/desktop/src/app/DesktopPreReadyPlatform.ts+3-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { activeForkRuntime } from "../updates/forkRuntime.ts";
1// @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings, prepares the Linux desktop entry, and asks Hyprland for its version synchronously before app services are available.2// @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings, prepares the Linux desktop entry, and asks Hyprland for its version synchronously before app services are available.
2import * as NodeChildProcess from "node:child_process";3import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";4import * as NodeFS from "node:fs";
...@@ -84,7 +85,7 @@ export const make = Effect.gen(function* () {...@@ -84,7 +85,7 @@ export const make = Effect.gen(function* () {
84 NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true });85 NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true });
85 NodeFS.copyFileSync(86 NodeFS.copyFileSync(
86 NodePath.posix.join(87 NodePath.posix.join(
87 Electron.app.getAppPath(),88 activeForkRuntime()?.directory ?? Electron.app.getAppPath(),
88 "apps/desktop/prod-resources/icon.png",89 "apps/desktop/prod-resources/icon.png",
89 ),90 ),
90 iconPath,91 iconPath,
...@@ -98,7 +99,7 @@ export const make = Effect.gen(function* () {...@@ -98,7 +99,7 @@ export const make = Effect.gen(function* () {
98 renderUrlHandlerDesktopEntry({99 renderUrlHandlerDesktopEntry({
99 displayName: resolveDesktopAppBranding({100 displayName: resolveDesktopAppBranding({
100 isDevelopment: linux.isDevelopment,101 isDevelopment: linux.isDevelopment,
101 appVersion: Electron.app.getVersion(),102 appVersion: activeForkRuntime()?.version ?? Electron.app.getVersion(),
102 }).displayName,103 }).displayName,
103 execTarget: process.env.APPIMAGE?.trim() || process.execPath,104 execTarget: process.env.APPIMAGE?.trim() || process.execPath,
104 scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment),105 scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment),
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+2
...@@ -246,6 +246,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -246,6 +246,7 @@ describe("DesktopBackendConfiguration", () => {
246 assert.equal(first.cwd, environment.backendCwd);246 assert.equal(first.cwd, environment.backendCwd);
247 assert.equal(first.captureOutput, true);247 assert.equal(first.captureOutput, true);
248 assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1");248 assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1");
249 assert.equal(first.env.T3CODE_VERSION_OVERRIDE, environment.appVersion);
249 assert.isUndefined(first.env.T3CODE_PORT);250 assert.isUndefined(first.env.T3CODE_PORT);
250 assert.isUndefined(first.env.T3CODE_MODE);251 assert.isUndefined(first.env.T3CODE_MODE);
251 assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST);252 assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST);
...@@ -964,6 +965,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -964,6 +965,7 @@ describe("DesktopBackendConfiguration", () => {
964 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;965 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
965 const config = yield* configuration.resolvePrimary;966 const config = yield* configuration.resolvePrimary;
966 assert.equal(config.captureOutput, true);967 assert.equal(config.captureOutput, true);
968 assert.isUndefined(config.env.T3CODE_VERSION_OVERRIDE);
967 // Dev never shares the prod compile cache.969 // Dev never shares the prod compile cache.
968 assert.notInclude(config.args, "--require");970 assert.notInclude(config.args, "--require");
969 }).pipe(971 }).pipe(
apps/desktop/src/backend/DesktopBackendConfiguration.ts+1
...@@ -603,6 +603,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv...@@ -603,6 +603,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
603 env: {603 env: {
604 ...backendChildEnvPatch(),604 ...backendChildEnvPatch(),
605 ELECTRON_RUN_AS_NODE: "1",605 ELECTRON_RUN_AS_NODE: "1",
606 T3CODE_VERSION_OVERRIDE: environment.isPackaged ? environment.appVersion : undefined,
606 // The server names this launcher in commands it asks a person to run.607 // The server names this launcher in commands it asks a person to run.
607 T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath),608 T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath),
608 },609 },
apps/desktop/src/boot.ts+34-4
...@@ -1,4 +1,34 @@...@@ -1,4 +1,34 @@
1// Packaged app entry. Enables the compile cache before the main bundle loads,1// @effect-diagnostics nodeBuiltinImport:off - The synchronous signed bootstrap runs before Effect services exist.
2// so the cache also covers main.cjs itself.2import * as NodeOS from "node:os";
3require("./compileCache.cjs");3import * as NodePath from "node:path";
4require("./main.cjs");4import * as Option from "effect/Option";
5
6import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./app/DesktopStatePaths.ts";
7import { readActiveForkRuntime } from "./updates/forkRuntime.ts";
8
9delete process.env.T3CODE_DESKTOP_PAYLOAD;
10let entry = __dirname;
11const app = process.versions.electron
12 ? (require("electron") as typeof import("electron")).app
13 : undefined;
14if (app?.isPackaged) {
15 const t3Home = Option.fromNullishOr(process.env.T3CODE_HOME);
16 const baseDir = resolveDesktopBaseDir({
17 homeDirectory: NodeOS.homedir(),
18 joinPath: NodePath.join,
19 t3Home,
20 });
21 const stateDir = resolveDesktopStateDir({
22 baseDir,
23 isDevelopment: false,
24 joinPath: NodePath.join,
25 t3Home,
26 });
27 const active = readActiveForkRuntime(stateDir, NodePath.resolve(__dirname, "../../.."));
28 if (active !== undefined) {
29 process.env.T3CODE_DESKTOP_PAYLOAD = JSON.stringify(active);
30 entry = NodePath.join(active.directory, "apps/desktop/dist-electron");
31 }
32}
33require(NodePath.join(entry, "compileCache.cjs"));
34require(NodePath.join(entry, "main.cjs"));
apps/desktop/src/electron/ElectronApp.test.ts+20
...@@ -84,6 +84,7 @@ import * as ElectronApp from "./ElectronApp.ts";...@@ -84,6 +84,7 @@ import * as ElectronApp from "./ElectronApp.ts";
8484
85describe("ElectronApp", () => {85describe("ElectronApp", () => {
86 beforeEach(() => {86 beforeEach(() => {
87 vi.unstubAllEnvs();
87 appendSwitchMock.mockClear();88 appendSwitchMock.mockClear();
88 autoUpdaterOnMock.mockClear();89 autoUpdaterOnMock.mockClear();
89 autoUpdaterRemoveListenerMock.mockClear();90 autoUpdaterRemoveListenerMock.mockClear();
...@@ -111,6 +112,25 @@ describe("ElectronApp", () => {...@@ -111,6 +112,25 @@ describe("ElectronApp", () => {
111 }).pipe(Effect.provide(ElectronApp.layer)),112 }).pipe(Effect.provide(ElectronApp.layer)),
112 );113 );
113114
115 it.effect("reports the selected payload version and path while retaining signed resources", () =>
116 Effect.gen(function* () {
117 vi.stubEnv(
118 "T3CODE_DESKTOP_PAYLOAD",
119 JSON.stringify({
120 version: "0.0.2000000000",
121 directory: "/staged/release",
122 shell: "a".repeat(64),
123 runtime: "b".repeat(64),
124 }),
125 );
126 const metadata = yield* (yield* ElectronApp.ElectronApp).metadata;
127 assert.equal(metadata.appVersion, "0.0.2000000000");
128 assert.equal(metadata.appPath, "/staged/release");
129 assert.equal(metadata.resourcesPath, process.resourcesPath);
130 vi.unstubAllEnvs();
131 }).pipe(Effect.provide(ElectronApp.layer)),
132 );
133
114 it.effect("reads the OS locale through the service", () =>134 it.effect("reads the OS locale through the service", () =>
115 Effect.gen(function* () {135 Effect.gen(function* () {
116 const electronApp = yield* ElectronApp.ElectronApp;136 const electronApp = yield* ElectronApp.ElectronApp;
apps/desktop/src/electron/ElectronApp.ts+3-2
...@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";...@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";
5import * as Scope from "effect/Scope";5import * as Scope from "effect/Scope";
66
7import * as Electron from "electron";7import * as Electron from "electron";
8import { activeForkRuntime } from "../updates/forkRuntime.ts";
89
9export interface ElectronAppMetadata {10export interface ElectronAppMetadata {
10 readonly appVersion: string;11 readonly appVersion: string;
...@@ -101,7 +102,7 @@ const addScopedAppListener = <Args extends ReadonlyArray<unknown>>(...@@ -101,7 +102,7 @@ const addScopedAppListener = <Args extends ReadonlyArray<unknown>>(
101export const make = ElectronApp.of({102export const make = ElectronApp.of({
102 metadata: Effect.gen(function* () {103 metadata: Effect.gen(function* () {
103 const appVersion = yield* Effect.try({104 const appVersion = yield* Effect.try({
104 try: () => Electron.app.getVersion(),105 try: () => activeForkRuntime()?.version ?? Electron.app.getVersion(),
105 catch: (cause) =>106 catch: (cause) =>
106 new ElectronAppMetadataReadError({107 new ElectronAppMetadataReadError({
107 property: "app-version",108 property: "app-version",
...@@ -109,7 +110,7 @@ export const make = ElectronApp.of({...@@ -109,7 +110,7 @@ export const make = ElectronApp.of({
109 }),110 }),
110 });111 });
111 const appPath = yield* Effect.try({112 const appPath = yield* Effect.try({
112 try: () => Electron.app.getAppPath(),113 try: () => activeForkRuntime()?.directory ?? Electron.app.getAppPath(),
113 catch: (cause) =>114 catch: (cause) =>
114 new ElectronAppMetadataReadError({115 new ElectronAppMetadataReadError({
115 property: "app-path",116 property: "app-path",
apps/desktop/src/electron/ElectronProtocol.test.ts+1-1
...@@ -43,7 +43,7 @@ describe("ElectronProtocol", () => {...@@ -43,7 +43,7 @@ describe("ElectronProtocol", () => {
43 const protocol = yield* ElectronProtocol.ElectronProtocol;43 const protocol = yield* ElectronProtocol.ElectronProtocol;
44 yield* protocol.registerDesktopProtocol({44 yield* protocol.registerDesktopProtocol({
45 scheme: "t3code",45 scheme: "t3code",
46 assetDirectory: directory,46 assetDirectories: Effect.succeed([directory]),
47 clerkFrontendApiHostname: undefined,47 clerkFrontendApiHostname: undefined,
48 });48 });
49 const request = (pathname: string, init?: RequestInit) =>49 const request = (pathname: string, init?: RequestInit) =>
apps/desktop/src/electron/ElectronProtocol.ts+23-10
...@@ -53,11 +53,14 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError<Elec...@@ -53,11 +53,14 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError<Elec
53}53}
5454
55// The scheme either proxies to a dev server (`targetOrigin`) or serves the55// The scheme either proxies to a dev server (`targetOrigin`) or serves the
56// built client from disk (`assetDirectory`).56// built client from disk (`assetDirectories`).
57export type DesktopProtocolRegistrationInput = {57export type DesktopProtocolRegistrationInput = {
58 readonly scheme: string;58 readonly scheme: string;
59 readonly clerkFrontendApiHostname: string | undefined;59 readonly clerkFrontendApiHostname: string | undefined;
60} & ({ readonly targetOrigin: URL } | { readonly assetDirectory: string });60} & (
61 | { readonly targetOrigin: URL }
62 | { readonly assetDirectories: Effect.Effect<readonly [string, ...string[]]> }
63);
6164
62export class ElectronProtocol extends Context.Service<65export class ElectronProtocol extends Context.Service<
63 ElectronProtocol,66 ElectronProtocol,
...@@ -213,12 +216,9 @@ const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* (...@@ -213,12 +216,9 @@ const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* (
213 return withContentSecurityPolicy(response, contentSecurityPolicy);216 return withContentSecurityPolicy(response, contentSecurityPolicy);
214});217});
215218
216// Serves the packaged web client without a backend: files resolve within the
217// asset directory, and any other path falls back to index.html so the SPA
218// router handles it, except for asset-shaped misses (`/missing.js`) which 404.
219const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (219const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (
220 request: Request,220 request: Request,
221 assetDirectory: string,221 assetDirectories: readonly [string, ...string[]],
222) {222) {
223 const fileSystem = yield* FileSystem.FileSystem;223 const fileSystem = yield* FileSystem.FileSystem;
224 const path = yield* Path.Path;224 const path = yield* Path.Path;
...@@ -231,13 +231,21 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (...@@ -231,13 +231,21 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (
231 Effect.orElseSucceed(() => null),231 Effect.orElseSucceed(() => null),
232 );232 );
233 if (pathname === null || pathname.includes("\0")) return new Response(null, { status: 400 });233 if (pathname === null || pathname.includes("\0")) return new Response(null, { status: 400 });
234 const root = path.resolve(assetDirectory);234 const root = path.resolve(assetDirectories[0]);
235 const assetPath = path.resolve(root, `.${pathname}`);235 const assetPath = path.resolve(root, `.${pathname}`);
236 if (assetPath !== root && !assetPath.startsWith(root + path.sep)) {236 if (assetPath !== root && !assetPath.startsWith(root + path.sep)) {
237 return new Response(null, { status: 404 });237 return new Response(null, { status: 404 });
238 }238 }
239 const stat = yield* fileSystem.stat(assetPath).pipe(Effect.orElseSucceed(() => null));
240 let filePath = assetPath;239 let filePath = assetPath;
240 let stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
241 // Old pages can still request lazy chunks while the renderer reloads.
242 if (path.extname(assetPath) !== "" && path.basename(assetPath) !== "index.html") {
243 for (const previous of assetDirectories.slice(1)) {
244 if (stat?.type === "File") break;
245 filePath = path.resolve(previous, `.${pathname}`);
246 stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
247 }
248 }
241 if (stat?.type !== "File") {249 if (stat?.type !== "File") {
242 const wantsHtml = request.headers.get("accept")?.includes("text/html") ?? false;250 const wantsHtml = request.headers.get("accept")?.includes("text/html") ?? false;
243 if (path.extname(assetPath) !== "" && !wantsHtml) {251 if (path.extname(assetPath) !== "" && !wantsHtml) {
...@@ -250,6 +258,7 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (...@@ -250,6 +258,7 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (
250 return new Response(request.method === "HEAD" ? null : new Uint8Array(contents), {258 return new Response(request.method === "HEAD" ? null : new Uint8Array(contents), {
251 headers: {259 headers: {
252 "content-type": Option.getOrElse(Mime.getType(filePath), () => "application/octet-stream"),260 "content-type": Option.getOrElse(Mime.getType(filePath), () => "application/octet-stream"),
261 ...(path.basename(filePath) === "index.html" ? { "cache-control": "no-store" } : {}),
253 },262 },
254 });263 });
255});264});
...@@ -270,9 +279,13 @@ export const make = Effect.gen(function* () {...@@ -270,9 +279,13 @@ export const make = Effect.gen(function* () {
270 Effect.try({279 Effect.try({
271 try: () => {280 try: () => {
272 Electron.protocol.handle(input.scheme, async (request) => {281 Electron.protocol.handle(input.scheme, async (request) => {
273 if ("assetDirectory" in input) {282 if ("assetDirectories" in input) {
274 return withContentSecurityPolicy(283 return withContentSecurityPolicy(
275 await runPromise(serveDesktopAsset(request, input.assetDirectory)),284 await runPromise(
285 Effect.flatMap(input.assetDirectories, (directories) =>
286 serveDesktopAsset(request, directories),
287 ),
288 ),
276 contentSecurityPolicy,289 contentSecurityPolicy,
277 );290 );
278 }291 }
apps/desktop/src/electron/ElectronUpdater.ts+5-2
...@@ -68,7 +68,10 @@ export class ElectronUpdater extends Context.Service<...@@ -68,7 +68,10 @@ export class ElectronUpdater extends Context.Service<
68 readonly setFullChangelog: (value: boolean) => Effect.Effect<void>;68 readonly setFullChangelog: (value: boolean) => Effect.Effect<void>;
69 readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect<void>;69 readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect<void>;
70 readonly checkForUpdates: Effect.Effect<void, ElectronUpdaterCheckForUpdatesError>;70 readonly checkForUpdates: Effect.Effect<void, ElectronUpdaterCheckForUpdatesError>;
71 readonly downloadUpdate: Effect.Effect<void, ElectronUpdaterDownloadUpdateError>;71 readonly downloadUpdate: Effect.Effect<
72 ReadonlyArray<string>,
73 ElectronUpdaterDownloadUpdateError
74 >;
72 readonly quitAndInstall: (options: {75 readonly quitAndInstall: (options: {
73 readonly isSilent: boolean;76 readonly isSilent: boolean;
74 readonly isForceRunAfter: boolean;77 readonly isForceRunAfter: boolean;
...@@ -135,7 +138,7 @@ export const make = ElectronUpdater.of({...@@ -135,7 +138,7 @@ export const make = ElectronUpdater.of({
135 return Effect.tryPromise({138 return Effect.tryPromise({
136 try: () => autoUpdater.downloadUpdate(),139 try: () => autoUpdater.downloadUpdate(),
137 catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }),140 catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }),
138 }).pipe(Effect.asVoid);141 });
139 }),142 }),
140 quitAndInstall: ({ isSilent, isForceRunAfter }) =>143 quitAndInstall: ({ isSilent, isForceRunAfter }) =>
141 Effect.suspend(() => {144 Effect.suspend(() => {
apps/desktop/src/main.ts+9-1
...@@ -68,6 +68,9 @@ import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts";...@@ -68,6 +68,9 @@ import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts";
68import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";68import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
69import * as DesktopRendererHistory from "./telemetry/DesktopRendererHistory.ts";69import * as DesktopRendererHistory from "./telemetry/DesktopRendererHistory.ts";
70import * as DesktopUpdates from "./updates/DesktopUpdates.ts";70import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
71import * as DesktopLocalUi from "./updates/DesktopLocalUi.ts";
72import * as DesktopForkUpdates from "./updates/DesktopForkUpdates.ts";
73import { activeForkRuntime } from "./updates/forkRuntime.ts";
71import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";74import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
72import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";75import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";
73import * as BrowserSession from "./preview/BrowserSession.ts";76import * as BrowserSession from "./preview/BrowserSession.ts";
...@@ -81,7 +84,10 @@ import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts";...@@ -81,7 +84,10 @@ import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts";
8184
82if (process.argv.includes("--version")) {85if (process.argv.includes("--version")) {
83 try {86 try {
84 NodeFS.writeSync(process.stdout.fd, `${Electron.app.getVersion()}\n`);87 NodeFS.writeSync(
88 process.stdout.fd,
89 `${activeForkRuntime()?.version ?? Electron.app.getVersion()}\n`,
90 );
85 } catch (error) {91 } catch (error) {
86 if (!(error instanceof Error) || !("code" in error) || error.code !== "EPIPE") throw error;92 if (!(error instanceof Error) || !("code" in error) || error.code !== "EPIPE") throw error;
87 }93 }
...@@ -228,6 +234,8 @@ const layerDesktopApplication = Layer.mergeAll(...@@ -228,6 +234,8 @@ const layerDesktopApplication = Layer.mergeAll(
228).pipe(234).pipe(
229 Layer.provideMerge(layerDesktopSnapShot),235 Layer.provideMerge(layerDesktopSnapShot),
230 Layer.provideMerge(DesktopUpdates.layer),236 Layer.provideMerge(DesktopUpdates.layer),
237 Layer.provideMerge(DesktopForkUpdates.layer),
238 Layer.provideMerge(DesktopLocalUi.layer),
231 Layer.provideMerge(layerDesktopWslBackend),239 Layer.provideMerge(layerDesktopWslBackend),
232 Layer.provideMerge(layerDesktopLocalEnvironmentAuth),240 Layer.provideMerge(layerDesktopLocalEnvironmentAuth),
233);241);
apps/desktop/src/updates/DesktopForkUpdates.test.ts created+320
...@@ -0,0 +1,320 @@
1// @effect-diagnostics nodeBuiltinImport:off - Fixtures create real archives to exercise extraction and atomic activation.
2import * as NodeCrypto from "node:crypto";
3import * as NodeFSP from "node:fs/promises";
4import * as NodePath from "node:path";
5import * as NodeChildProcess from "node:child_process";
6import * as NodeHttp from "node:http";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { assert, it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import { FetchHttpClient } from "effect/http";
13import { ForkReleaseManifest } from "@t3tools/shared/forkUpdateDirectory";
14
15import * as DesktopConfig from "../app/DesktopConfig.ts";
16import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
17import * as DesktopLocalUi from "./DesktopLocalUi.ts";
18import * as DesktopForkUpdates from "./DesktopForkUpdates.ts";
19import { readActiveForkRuntime } from "./forkRuntime.ts";
20
21const shell = "a".repeat(64);
22const runtime = "b".repeat(64);
23const nextRuntime = "c".repeat(64);
24const version = "0.0.2000000000";
25
26const withUpdater = <A, E>(
27 test: (input: {
28 updater: DesktopForkUpdates.DesktopForkUpdates["Service"];
29 release: typeof ForkReleaseManifest.Type;
30 nextUi: (typeof ForkReleaseManifest.Type)["ui"];
31 base: URL;
32 requested: string[];
33 applied: string[];
34 files: Map<string, Uint8Array>;
35 environment: DesktopEnvironment.DesktopEnvironment["Service"];
36 }) => Effect.Effect<A, E>,
37 platform: NodeJS.Platform = "darwin",
38) =>
39 Effect.gen(function* () {
40 const fs = yield* FileSystem.FileSystem;
41 const home = yield* fs.makeTempDirectoryScoped();
42 const resources = NodePath.join(home, "resources");
43 const bundled = NodePath.join(resources, "app.asar");
44 yield* fs.makeDirectory(bundled, { recursive: true });
45 yield* fs.writeFileString(
46 NodePath.join(bundled, "knife-runtime.json"),
47 JSON.stringify({ version: "0.0.1", sha256: runtime, shell }),
48 );
49 const files = new Map<string, Uint8Array>();
50 const archive = (kind: string, contents: Record<string, string>) =>
51 Effect.promise(async () => {
52 const directory = NodePath.join(home, kind);
53 await NodeFSP.mkdir(directory);
54 for (const [name, content] of Object.entries(contents)) {
55 const file = NodePath.join(directory, name);
56 await NodeFSP.mkdir(NodePath.dirname(file), { recursive: true });
57 await NodeFSP.writeFile(file, content);
58 }
59 const file = `${kind}.tar.gz`;
60 NodeChildProcess.execFileSync("tar", [
61 "-czf",
62 NodePath.join(home, file),
63 "-C",
64 directory,
65 ".",
66 ]);
67 const bytes = await NodeFSP.readFile(NodePath.join(home, file));
68 files.set(file, bytes);
69 return {
70 file,
71 size: bytes.length,
72 sha256: NodeCrypto.createHash("sha256").update(bytes).digest("hex"),
73 };
74 });
75 const ui = yield* archive("ui", {
76 "index.html": '<script src="/app.js"></script>',
77 "app.js": "new UI",
78 ".vite/manifest.json": '{"app":{"file":"app.js"}}',
79 });
80 const assets = yield* archive("assets", { "images/logo.svg": "static asset" });
81 const nextUi = yield* archive("ui-next", {
82 "index.html": '<script src="/app-next.js"></script>',
83 "app-next.js": "next UI",
84 ".vite/manifest.json": '{"app":{"file":"app-next.js"}}',
85 });
86 const code = yield* archive("code", {
87 "apps/desktop/dist-electron/main.cjs": "new main",
88 "apps/desktop/dist-electron/preload.cjs": "new preload",
89 "apps/desktop/dist-electron/compileCache.cjs": "cache",
90 [`${platform === "win32" ? "server/" : ""}apps/server/dist/bin.mjs`]:
91 'console.log("fixture");',
92 });
93 const platformKey = `${platform}-${platform === "darwin" ? "arm64" : "x64"}`;
94 const release = {
95 version,
96 ui,
97 assets,
98 server: { ...code, version, runtime: nextRuntime },
99 desktop: {
100 [platformKey]: {
101 version,
102 ui,
103 assets,
104 shell,
105 runtime: nextRuntime,
106 code,
107 app: { ...code, version },
108 },
109 },
110 };
111 files.set(
112 "?ls",
113 new TextEncoder().encode(JSON.stringify({ files: [{ href: `${version}.json` }] })),
114 );
115 const requested: string[] = [];
116 const applied: string[] = [];
117 const server = NodeHttp.createServer((request, response) => {
118 const url = new URL(request.url ?? "/", "http://127.0.0.1");
119 const file = url.searchParams.has("ls") ? "?ls" : url.pathname.split("/").at(-1)!;
120 requested.push(file);
121 const body = file === `${version}.json` ? JSON.stringify(release) : files.get(file);
122 response.writeHead(body === undefined ? 404 : 200).end(body);
123 });
124 yield* Effect.acquireRelease(
125 Effect.promise(() => new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))),
126 () =>
127 Effect.promise(
128 () =>
129 new Promise<void>((resolve, reject) =>
130 server.close((error) => (error ? reject(error) : resolve())),
131 ),
132 ),
133 );
134 const address = server.address();
135 if (address === null || typeof address === "string")
136 return yield* Effect.die("Missing fixture server address");
137 const base = new URL(`http://127.0.0.1:${address.port}/`);
138 const environmentLayer = DesktopEnvironment.layer({
139 dirname: NodePath.join(bundled, "apps/desktop/dist-electron"),
140 homeDirectory: home,
141 platform,
142 processArch: platform === "darwin" ? "arm64" : "x64",
143 appVersion: "0.0.1",
144 appPath: bundled,
145 resourcesPath: resources,
146 isPackaged: true,
147 runningUnderArm64Translation: false,
148 }).pipe(Layer.provideMerge(DesktopConfig.layerTest({ T3CODE_HOME: home })));
149 return yield* Effect.gen(function* () {
150 const updater = yield* DesktopForkUpdates.make;
151 const environment = yield* DesktopEnvironment.DesktopEnvironment;
152 return yield* test({
153 updater,
154 release,
155 nextUi,
156 base,
157 requested,
158 applied,
159 files,
160 environment,
161 });
162 }).pipe(
163 Effect.provide(FetchHttpClient.layer),
164 Effect.provideService(DesktopLocalUi.DesktopLocalUi, {
165 assetDirectories: Effect.succeed(["/client"] as const),
166 currentVersion: Effect.succeed("0.0.1"),
167 reset: Effect.void,
168 apply: (source, version) =>
169 Effect.promise(async () => {
170 assert.equal(
171 await NodeFSP.readFile(NodePath.join(source, "images/logo.svg"), "utf8"),
172 "static asset",
173 );
174 applied.push(`${source}:${version}`);
175 }),
176 }),
177 Effect.provide(environmentLayer),
178 );
179 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
180
181it.effect("downloads verified code and UI before atomically activating an exact shell match", () =>
182 withUpdater(({ updater, base, release, requested, environment }) =>
183 Effect.gen(function* () {
184 assert.deepEqual(yield* updater.check(base), release);
185 assert.equal(yield* updater.prepare(base, release), "code");
186 assert.deepEqual(requested, [
187 "?ls",
188 `${version}.json`,
189 "code.tar.gz",
190 "assets.tar.gz",
191 "ui.tar.gz",
192 ]);
193 assert.equal(readActiveForkRuntime(environment.stateDir, environment.appRoot), undefined);
194 assert.equal(yield* updater.activateCode("0.0.1999999999"), false);
195 assert.equal(yield* updater.activateCode(version), true);
196 const active = readActiveForkRuntime(environment.stateDir, environment.appRoot);
197 assert.equal(active?.version, version);
198 assert.equal(active?.runtime, nextRuntime);
199 assert.equal(active?.shell, shell);
200 }),
201 ),
202);
203
204it.effect("downloads only new UI on a second release with unchanged code and static assets", () =>
205 withUpdater(({ updater, base, release, nextUi, requested, applied }) =>
206 Effect.gen(function* () {
207 const target = release.desktop["darwin-arm64"]!;
208 const matching = { ...release, desktop: { "darwin-arm64": { ...target, runtime } } };
209 assert.equal(yield* updater.prepare(base, matching), "ui");
210 assert.equal(yield* updater.installUi(version), true);
211 assert.equal(applied.length, 1);
212 const next = {
213 ...matching,
214 version: "0.0.2000000001",
215 ui: nextUi,
216 desktop: {
217 "darwin-arm64": {
218 ...matching.desktop["darwin-arm64"]!,
219 version: "0.0.2000000001",
220 ui: nextUi,
221 },
222 },
223 };
224 assert.equal(yield* updater.prepare(base, next), "ui");
225 assert.equal(yield* updater.activateCode(next.version), false);
226 assert.deepEqual(requested, ["assets.tar.gz", "ui.tar.gz", "ui-next.tar.gz"]);
227 }),
228 ),
229);
230
231it.effect("leaves the active pointer unchanged after corrupt or incomplete downloads", () =>
232 withUpdater(({ updater, base, release, files, environment }) =>
233 Effect.gen(function* () {
234 files.set("code.tar.gz", new TextEncoder().encode("corrupt"));
235 const error = yield* updater.prepare(base, release).pipe(Effect.flip);
236 assert.equal(error._tag, "DesktopForkUpdateError");
237 assert.equal(yield* updater.activateCode(version), false);
238 assert.equal(readActiveForkRuntime(environment.stateDir, environment.appRoot), undefined);
239 }),
240 ),
241);
242
243it.effect("requires a signed shell update without downloading code when the shell differs", () =>
244 withUpdater(({ updater, base, release, requested }) =>
245 Effect.gen(function* () {
246 const mismatch = {
247 ...release,
248 desktop: { "darwin-arm64": { ...release.desktop["darwin-arm64"]!, shell: "d".repeat(64) } },
249 };
250 assert.equal(yield* updater.prepare(base, mismatch), "shell");
251 assert.deepEqual(requested, []);
252 }),
253 ),
254);
255
256it.effect("reports an empty public release directory without fetching an unfinished manifest", () =>
257 withUpdater(({ updater, base, files, requested }) =>
258 Effect.gen(function* () {
259 files.set(
260 "?ls",
261 new TextEncoder().encode(JSON.stringify({ files: [{ href: "unfinished.json" }] })),
262 );
263 const error = yield* updater.check(base).pipe(Effect.flip);
264 assert.equal(error.message, "No updates have been published. Try again later.");
265 assert.deepEqual(requested, ["?ls"]);
266 }),
267 ),
268);
269
270it.effect(
271 "preserves the platform's compatible renderer and version when the aggregate is newer",
272 () =>
273 withUpdater(({ updater, base, release, nextUi, requested }) =>
274 Effect.gen(function* () {
275 const aggregate = { ...release, version: "0.0.2000000001", ui: nextUi };
276 assert.equal(yield* updater.prepare(base, aggregate), "code");
277 assert.equal(yield* updater.activateCode(aggregate.version), false);
278 assert.equal(yield* updater.activateCode(version), true);
279 assert.deepEqual(requested, ["code.tar.gz", "assets.tar.gz", "ui.tar.gz"]);
280 }),
281 ),
282);
283
284it.effect.each(["linux", "win32"] as const)(
285 "activates a verified %s code tree and preserves its shell",
286 (platform) =>
287 withUpdater(
288 ({ updater, release, base, environment, requested }) =>
289 Effect.gen(function* () {
290 assert.equal(yield* updater.prepare(base, release), "code");
291 assert.equal(yield* updater.activateCode(version), true);
292 assert.equal(
293 readActiveForkRuntime(environment.stateDir, environment.appRoot, platform)?.version,
294 version,
295 );
296 assert.deepEqual(requested, ["code.tar.gz", "assets.tar.gz", "ui.tar.gz"]);
297 }),
298 platform,
299 ),
300);
301
302it.effect.each(["linux", "win32"] as const)(
303 "downloads only renderer assets for an unchanged %s runtime",
304 (platform) =>
305 withUpdater(
306 ({ updater, release, base, requested, applied }) =>
307 Effect.gen(function* () {
308 const key = `${platform}-x64`;
309 const matching = {
310 ...release,
311 desktop: { [key]: { ...release.desktop[key]!, runtime } },
312 };
313 assert.equal(yield* updater.prepare(base, matching), "ui");
314 assert.equal(yield* updater.installUi(version), true);
315 assert.equal(applied.length, 1);
316 assert.deepEqual(requested, ["assets.tar.gz", "ui.tar.gz"]);
317 }),
318 platform,
319 ),
320);
apps/desktop/src/updates/DesktopForkUpdates.ts created+265
...@@ -0,0 +1,265 @@
1// @effect-diagnostics nodeBuiltinImport:off - Release downloads are hashed before extraction.
2import * as NodeCrypto from "node:crypto";
3import {
4 ForkReleaseManifest,
5 ForkRuntimeManifest,
6 ForkReleaseListing,
7 latestForkRelease,
8} from "@t3tools/shared/forkUpdateDirectory";
9import * as Context from "effect/Context";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Ref from "effect/Ref";
14import * as Schema from "effect/Schema";
15import { HttpClient } from "effect/http";
16import { ChildProcess, ChildProcessSpawner } from "effect/process";
17
18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
19import * as DesktopLocalUi from "./DesktopLocalUi.ts";
20import { activeForkRuntime } from "./forkRuntime.ts";
21
22export class DesktopForkUpdateError extends Schema.TaggedError<DesktopForkUpdateError>()(
23 "DesktopForkUpdateError",
24 { message: Schema.String, cause: Schema.Defect() },
25) {}
26
27const isForkUpdateError = Schema.is(DesktopForkUpdateError);
28const decodeReleaseListing = Schema.decodeUnknownEffect(ForkReleaseListing);
29const decodeRelease = Schema.decodeUnknownEffect(ForkReleaseManifest);
30const decodeRuntime = Schema.decodeUnknownEffect(Schema.fromJsonString(ForkRuntimeManifest));
31
32type Release = typeof ForkReleaseManifest.Type;
33type Artifact = Release["ui"];
34type Prepared = {
35 version: string;
36 directory: string;
37 shell: string;
38 runtime: string;
39 kind: "ui" | "code";
40};
41
42export class DesktopForkUpdates extends Context.Service<
43 DesktopForkUpdates,
44 {
45 readonly check: (base: URL) => Effect.Effect<Release, DesktopForkUpdateError>;
46 readonly kind: (
47 release: Release,
48 ) => Effect.Effect<"ui" | "code" | "shell", DesktopForkUpdateError>;
49 readonly prepare: (
50 base: URL,
51 release: Release,
52 ) => Effect.Effect<"ui" | "code" | "shell", DesktopForkUpdateError>;
53 readonly installUi: (version: string) => Effect.Effect<boolean, DesktopForkUpdateError>;
54 readonly activateCode: (version: string) => Effect.Effect<boolean, DesktopForkUpdateError>;
55 }
56>()("@t3tools/desktop/updates/DesktopForkUpdates") {}
57
58export const make = Effect.gen(function* () {
59 const environment = yield* DesktopEnvironment.DesktopEnvironment;
60 const fs = yield* FileSystem.FileSystem;
61 const path = environment.path;
62 const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
63 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
64 const ui = yield* DesktopLocalUi.DesktopLocalUi;
65 const prepared = yield* Ref.make<Prepared | null>(null);
66 const encodePointer = Schema.encodeEffect(
67 Schema.fromJsonString(
68 Schema.Struct({
69 version: Schema.String,
70 directory: Schema.String,
71 shell: Schema.String,
72 runtime: Schema.String,
73 }),
74 ),
75 );
76 const releasesDir = path.join(environment.stateDir, "fork-releases");
77 const fail = (cause: unknown) =>
78 isForkUpdateError(cause)
79 ? cause
80 : new DesktopForkUpdateError({
81 message: "Could not prepare the update. Try again.",
82 cause,
83 });
84
85 const check = (base: URL) =>
86 Effect.scoped(
87 Effect.gen(function* () {
88 const listingUrl = new URL(base);
89 listingUrl.searchParams.set("ls", "");
90 const client = HttpClient.withScope(http);
91 const response = yield* client.get(listingUrl.href);
92 const listing = yield* decodeReleaseListing(yield* response.json);
93 const version = latestForkRelease(listing);
94 if (version === undefined)
95 return yield* new DesktopForkUpdateError({
96 message: "No updates have been published. Try again later.",
97 cause: "Empty release share",
98 });
99 const manifest = yield* client.get(new URL(`${version}.json`, base).href);
100 const release = yield* decodeRelease(yield* manifest.json);
101 if (release.version !== version)
102 return yield* fail("The published manifest has a different version.");
103 return release;
104 }),
105 ).pipe(Effect.mapError(fail));
106
107 const extract = (base: URL, artifact: Artifact, directory: string) =>
108 Effect.scoped(
109 Effect.gen(function* () {
110 const archive = path.join(releasesDir, artifact.file);
111 const valid = (bytes: Uint8Array) =>
112 bytes.byteLength === artifact.size &&
113 NodeCrypto.createHash("sha256").update(bytes).digest("hex") === artifact.sha256;
114 if (!(yield* fs.exists(archive)) || !valid(yield* fs.readFile(archive))) {
115 const response = yield* HttpClient.withScope(http).get(new URL(artifact.file, base).href);
116 const bytes = new Uint8Array(yield* response.arrayBuffer);
117 if (!valid(bytes))
118 return yield* fail("The downloaded update did not match its checksum.");
119 yield* fs.writeFile(`${archive}.tmp`, bytes);
120 yield* fs.rename(`${archive}.tmp`, archive);
121 }
122 yield* fs.makeDirectory(directory, { recursive: true });
123 const child = yield* spawner.spawn(
124 ChildProcess.make("tar", ["-xzf", archive, "-C", directory]),
125 );
126 if (Number(yield* child.exitCode) !== 0)
127 return yield* fail("Could not extract the update.");
128 }),
129 );
130
131 const kind = (release: Release) =>
132 Effect.gen(function* () {
133 const target = release.desktop[`${environment.platform}-${environment.processArch}`];
134 if (target === undefined) return yield* fail("This release is unavailable for this machine.");
135 const baseline = yield* fs
136 .readFileString(path.join(environment.resourcesPath, "app.asar", "knife-runtime.json"))
137 .pipe(Effect.flatMap(decodeRuntime));
138 if (target.shell !== baseline.shell) return "shell" as const;
139 const runtime = activeForkRuntime()?.runtime ?? baseline.sha256;
140 return target.runtime === runtime ? ("ui" as const) : ("code" as const);
141 }).pipe(Effect.mapError(fail));
142
143 const prepare = (base: URL, release: Release) =>
144 Effect.gen(function* () {
145 const previous = yield* Ref.getAndSet(prepared, null);
146 if (previous !== null) yield* fs.remove(previous.directory, { recursive: true, force: true });
147 const installKind = yield* kind(release);
148 if (installKind === "shell") return installKind;
149 const target = release.desktop[`${environment.platform}-${environment.processArch}`]!;
150 const serverRoot = environment.platform === "win32" ? "server" : ".";
151 yield* fs.makeDirectory(releasesDir, { recursive: true });
152 const directory = yield* fs.makeTempDirectory({ directory: releasesDir, prefix: "release-" });
153 return yield* Effect.gen(function* () {
154 if (installKind === "code") yield* extract(base, target.code, directory);
155 const client =
156 installKind === "ui"
157 ? directory
158 : path.join(directory, serverRoot, "apps/server/dist/client");
159 yield* extract(base, target.assets, client);
160 yield* extract(base, target.ui, client);
161 if (!(yield* fs.exists(path.join(client, "index.html"))))
162 return yield* fail("The update has no completed UI build.");
163 if (installKind === "code") {
164 for (const file of [
165 "apps/desktop/dist-electron/main.cjs",
166 "apps/desktop/dist-electron/preload.cjs",
167 "apps/desktop/dist-electron/compileCache.cjs",
168 path.join(serverRoot, "apps/server/dist/bin.mjs"),
169 ]) {
170 if (!(yield* fs.exists(path.join(directory, file))))
171 return yield* fail("The update is incomplete.");
172 }
173 const probe = yield* spawner.spawn(
174 ChildProcess.make(
175 process.execPath,
176 [path.join(directory, serverRoot, "apps/server/dist/bin.mjs"), "--version"],
177 {
178 env: { ELECTRON_RUN_AS_NODE: "1", T3CODE_VERSION_OVERRIDE: target.version },
179 extendEnv: true,
180 stdout: "ignore",
181 stderr: "ignore",
182 },
183 ),
184 );
185 if (Number(yield* probe.exitCode) !== 0)
186 return yield* fail("The downloaded server could not start.");
187 }
188 yield* fs.readDirectory(releasesDir).pipe(
189 Effect.flatMap((entries) =>
190 Effect.forEach(entries, (name) =>
191 name.endsWith(".tar.gz") &&
192 name !== target.code.file &&
193 name !== target.ui.file &&
194 name !== target.assets.file
195 ? fs.remove(path.join(releasesDir, name), { force: true })
196 : Effect.void,
197 ),
198 ),
199 Effect.ignore,
200 );
201 yield* Ref.set(prepared, {
202 version: target.version,
203 directory,
204 runtime: target.runtime,
205 shell: target.shell,
206 kind: installKind,
207 });
208 return installKind;
209 }).pipe(
210 Effect.onError(() =>
211 fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
212 ),
213 );
214 }).pipe(Effect.scoped, Effect.mapError(fail));
215
216 const installUi = (version: string) =>
217 Effect.gen(function* () {
218 const candidate = yield* Ref.get(prepared);
219 if (candidate?.version !== version || candidate.kind !== "ui") return false;
220 yield* ui.apply(candidate.directory, version);
221 yield* Ref.set(prepared, null);
222 yield* fs.remove(candidate.directory, { recursive: true, force: true }).pipe(Effect.ignore);
223 return true;
224 }).pipe(Effect.mapError(fail));
225
226 const activateCode = (version: string) =>
227 Effect.gen(function* () {
228 const candidate = yield* Ref.get(prepared);
229 if (candidate?.version !== version || candidate.kind !== "code") return false;
230 const pointer = path.join(releasesDir, "current.json");
231 yield* Effect.uninterruptible(
232 Effect.gen(function* () {
233 yield* fs.writeFileString(
234 `${pointer}.tmp`,
235 yield* encodePointer({
236 version,
237 directory: path.basename(candidate.directory),
238 shell: candidate.shell,
239 runtime: candidate.runtime,
240 }),
241 );
242 yield* fs.rename(`${pointer}.tmp`, pointer);
243 yield* Ref.set(prepared, null);
244 }),
245 );
246 yield* fs.readDirectory(releasesDir).pipe(
247 Effect.flatMap((entries) =>
248 Effect.forEach(entries, (name) => {
249 const directory = path.join(releasesDir, name);
250 return name.startsWith("release-") &&
251 directory !== candidate.directory &&
252 directory !== environment.appRoot
253 ? fs.remove(directory, { recursive: true, force: true })
254 : Effect.void;
255 }),
256 ),
257 Effect.ignore,
258 );
259 return true;
260 }).pipe(Effect.mapError(fail));
261
262 return DesktopForkUpdates.of({ check, kind, prepare, installUi, activateCode });
263});
264
265export const layer = Layer.effect(DesktopForkUpdates, make);
apps/desktop/src/updates/DesktopLocalUi.test.ts created+305
...@@ -0,0 +1,305 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Deferred from "effect/Deferred";
5import * as Fiber from "effect/Fiber";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as PlatformError from "effect/PlatformError";
9import * as Ref from "effect/Ref";
10import { beforeEach, vi } from "vite-plus/test";
11
12import * as DesktopConfig from "../app/DesktopConfig.ts";
13import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
14import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
15import * as DesktopLocalUi from "./DesktopLocalUi.ts";
16
17const { handle } = vi.hoisted(() => ({ handle: vi.fn() }));
18vi.mock("electron", () => ({
19 net: { fetch: vi.fn() },
20 protocol: { handle, unhandle: vi.fn() },
21}));
22
23const withUi = <A, E, R>(
24 test: (
25 source: string,
26 ) => Effect.Effect<A, E, R | FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment>,
27) =>
28 Effect.gen(function* () {
29 const fs = yield* FileSystem.FileSystem;
30 const home = yield* fs.makeTempDirectoryScoped();
31 const source = `${home}/dev/t3code/apps/web/dist`;
32 const bundled = `${home}/bundle/apps/server/dist/client`;
33 yield* fs.makeDirectory(source, { recursive: true });
34 yield* fs.makeDirectory(bundled, { recursive: true });
35 yield* fs.writeFileString(`${bundled}/index.html`, "bundled");
36
37 return yield* test(source).pipe(
38 Effect.provide(
39 DesktopEnvironment.layer({
40 dirname: `${home}/bundle/apps/desktop/dist-electron`,
41 homeDirectory: home,
42 platform: "darwin",
43 processArch: "arm64",
44 appVersion: "0.0.45",
45 appPath: `${home}/bundle`,
46 isPackaged: true,
47 resourcesPath: `${home}/resources`,
48 runningUnderArm64Translation: false,
49 }).pipe(Layer.provideMerge(DesktopConfig.layerTest({ T3CODE_HOME: home }))),
50 ),
51 );
52 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
53
54const writeBuild = (source: string, version: string) =>
55 Effect.gen(function* () {
56 const fs = yield* FileSystem.FileSystem;
57 yield* fs.makeDirectory(`${source}/.vite`, { recursive: true });
58 yield* fs.writeFileString(
59 `${source}/index.html`,
60 `<html>${version}<script type="module" src="/app-${version}.js"></script></html>`,
61 );
62 yield* fs.writeFileString(`${source}/app-${version}.js`, `export default "${version}";`);
63 yield* fs.writeFileString(`${source}/lazy-${version}.js`, `export default "lazy-${version}";`);
64 yield* fs.writeFileString(
65 `${source}/.vite/manifest.json`,
66 JSON.stringify({
67 app: { file: `app-${version}.js` },
68 lazy: { file: `lazy-${version}.js` },
69 }),
70 );
71 });
72
73describe("DesktopLocalUi", () => {
74 beforeEach(() => handle.mockReset());
75
76 it.effect(
77 "uses the bundled UI after a full-app update instead of a snapshot from the previous version",
78 () =>
79 withUi((source) =>
80 Effect.gen(function* () {
81 const ui = yield* DesktopLocalUi.make;
82 yield* writeBuild(source, "local");
83 yield* ui.apply(source, "local");
84 const environment = yield* DesktopEnvironment.DesktopEnvironment;
85 const updated = yield* DesktopLocalUi.make.pipe(
86 Effect.provideService(DesktopEnvironment.DesktopEnvironment, {
87 ...environment,
88 appVersion: "0.0.2000000000",
89 }),
90 );
91 assert.deepEqual(yield* updated.assetDirectories, [environment.clientAssetsDir]);
92 const restarted = yield* DesktopLocalUi.make;
93 assert.deepEqual(yield* restarted.assetDirectories, yield* ui.assetDirectories);
94 }),
95 ),
96 );
97
98 it.effect(
99 "switches the stable renderer origin and retains assets requested by the old page",
100 () =>
101 withUi((source) =>
102 Effect.gen(function* () {
103 const fs = yield* FileSystem.FileSystem;
104 const ui = yield* DesktopLocalUi.make;
105 let request: ((request: Request) => Promise<Response>) | undefined;
106 handle.mockImplementation((_scheme, handler) => {
107 request = handler;
108 });
109 const protocol = yield* ElectronProtocol.make;
110 yield* protocol.registerDesktopProtocol({
111 scheme: "t3code",
112 clerkFrontendApiHostname: undefined,
113 assetDirectories: ui.assetDirectories,
114 });
115 const read = (pathname: string) =>
116 Effect.promise(async () => {
117 const response = await request!(new Request(`t3code://app${pathname}`));
118 return { status: response.status, text: await response.text() };
119 });
120
121 assert.equal((yield* read("/settings")).text, "bundled");
122 yield* writeBuild(source, "one");
123 yield* ui.apply(source, "local");
124 const [first] = yield* ui.assetDirectories;
125 yield* fs.remove(source, { recursive: true });
126 assert.include((yield* read("/thread/active")).text, "one");
127 assert.equal((yield* read("/app-one.js")).text, 'export default "one";');
128
129 yield* writeBuild(source, "two");
130 yield* ui.apply(source, "local");
131 assert.include((yield* read("/thread/active")).text, "two");
132 assert.equal((yield* read("/lazy-one.js")).text, 'export default "lazy-one";');
133
134 yield* writeBuild(source, "three");
135 yield* ui.apply(source, "local");
136 assert.isFalse(yield* fs.exists(first));
137 const resumed = yield* DesktopLocalUi.make;
138 assert.deepEqual((yield* resumed.assetDirectories)[0], (yield* ui.assetDirectories)[0]);
139
140 yield* ui.reset;
141 assert.equal((yield* read("/thread/active")).text, "bundled");
142 const restored = yield* DesktopLocalUi.make;
143 assert.equal((yield* restored.assetDirectories)[0], (yield* ui.assetDirectories)[0]);
144 }),
145 ),
146 );
147
148 it.effect("rejects a partial build without switching away from the current UI", () =>
149 withUi((source) =>
150 Effect.gen(function* () {
151 const fs = yield* FileSystem.FileSystem;
152 const ui = yield* DesktopLocalUi.make;
153 yield* writeBuild(source, "one");
154 yield* ui.apply(source, "local");
155 const current = yield* ui.assetDirectories;
156 yield* writeBuild(source, "two");
157 yield* fs.remove(`${source}/lazy-two.js`);
158
159 const error = yield* ui.apply(source, "local").pipe(Effect.flip);
160 assert.include(error.message, "incomplete");
161 assert.deepEqual(yield* ui.assetDirectories, current);
162 const resumed = yield* DesktopLocalUi.make;
163 assert.equal((yield* resumed.assetDirectories)[0], current[0]);
164 const environment = yield* DesktopEnvironment.DesktopEnvironment;
165 const entries = yield* fs.readDirectory(
166 `${environment.stateDir}/local-ui/${environment.appVersion}`,
167 );
168 assert.equal(entries.filter((name) => name.startsWith("build-")).length, 1);
169 }),
170 ),
171 );
172
173 it.effect("leaves the current UI active when saving the selection fails", () =>
174 withUi((source) =>
175 Effect.gen(function* () {
176 const fs = yield* FileSystem.FileSystem;
177 const fail = yield* Ref.make(false);
178 const ui = yield* DesktopLocalUi.make.pipe(
179 Effect.provideService(FileSystem.FileSystem, {
180 ...fs,
181 rename: (from, to) =>
182 Effect.gen(function* () {
183 if (yield* Ref.get(fail))
184 return yield* PlatformError.systemError({
185 _tag: "PermissionDenied",
186 module: "FileSystem",
187 method: "rename",
188 pathOrDescriptor: to,
189 });
190 yield* fs.rename(from, to);
191 }),
192 }),
193 );
194 yield* writeBuild(source, "one");
195 yield* ui.apply(source, "local");
196 const current = yield* ui.assetDirectories;
197 yield* writeBuild(source, "two");
198 yield* Ref.set(fail, true);
199 yield* ui.apply(source, "local").pipe(Effect.flip);
200 assert.deepEqual(yield* ui.assetDirectories, current);
201 const resumed = yield* DesktopLocalUi.make;
202 assert.equal((yield* resumed.assetDirectories)[0], current[0]);
203 }),
204 ),
205 );
206
207 it.effect("rejects an empty application script", () =>
208 withUi((source) =>
209 Effect.gen(function* () {
210 const fs = yield* FileSystem.FileSystem;
211 const ui = yield* DesktopLocalUi.make;
212 const current = yield* ui.assetDirectories;
213 yield* writeBuild(source, "one");
214 yield* fs.writeFileString(`${source}/app-one.js`, "");
215 assert.include((yield* ui.apply(source, "local").pipe(Effect.flip)).message, "incomplete");
216 assert.deepEqual(yield* ui.assetDirectories, current);
217 }),
218 ),
219 );
220
221 it.effect("rejects files changed while copying a build", () =>
222 withUi((source) =>
223 Effect.gen(function* () {
224 const fs = yield* FileSystem.FileSystem;
225 const ui = yield* DesktopLocalUi.make.pipe(
226 Effect.provideService(FileSystem.FileSystem, {
227 ...fs,
228 copy: (from, to) =>
229 Effect.gen(function* () {
230 yield* fs.writeFileString(`${source}/app-one.js`, 'export default "partial";');
231 yield* fs.copy(from, to);
232 }),
233 }),
234 );
235 const current = yield* ui.assetDirectories;
236 yield* writeBuild(source, "one");
237 assert.include((yield* ui.apply(source, "local").pipe(Effect.flip)).message, "incomplete");
238 assert.deepEqual(yield* ui.assetDirectories, current);
239 }),
240 ),
241 );
242
243 it.effect("starts with bundled assets when the saved selection cannot be read", () =>
244 withUi((source) =>
245 Effect.gen(function* () {
246 const fs = yield* FileSystem.FileSystem;
247 const environment = yield* DesktopEnvironment.DesktopEnvironment;
248 const ui = yield* DesktopLocalUi.make;
249 yield* writeBuild(source, "one");
250 yield* ui.apply(source, "local");
251 const resumed = yield* DesktopLocalUi.make.pipe(
252 Effect.provideService(FileSystem.FileSystem, {
253 ...fs,
254 readFileString: (file, options) =>
255 file.endsWith("/current")
256 ? Effect.fail(
257 PlatformError.systemError({
258 _tag: "PermissionDenied",
259 module: "FileSystem",
260 method: "readFileString",
261 pathOrDescriptor: file,
262 }),
263 )
264 : fs.readFileString(file, options),
265 }),
266 );
267 assert.deepEqual(yield* resumed.assetDirectories, [environment.clientAssetsDir]);
268 yield* resumed.reset;
269 }),
270 ),
271 );
272
273 it.effect("keeps the current UI available while staging and after an interrupted update", () =>
274 withUi((source) =>
275 Effect.gen(function* () {
276 const fs = yield* FileSystem.FileSystem;
277 const started = yield* Deferred.make<void>();
278 const finish = yield* Deferred.make<void>();
279 const ui = yield* DesktopLocalUi.make.pipe(
280 Effect.provideService(FileSystem.FileSystem, {
281 ...fs,
282 copy: (from, to) =>
283 Effect.gen(function* () {
284 yield* Deferred.succeed(started, undefined);
285 yield* Deferred.await(finish);
286 yield* fs.copy(from, to);
287 }),
288 }),
289 );
290 yield* writeBuild(source, "one");
291 const current = yield* ui.assetDirectories;
292 const update = yield* ui.apply(source, "local").pipe(Effect.forkChild);
293 yield* Deferred.await(started);
294 assert.deepEqual(yield* ui.assetDirectories, current);
295 yield* Fiber.interrupt(update);
296 assert.deepEqual(yield* ui.assetDirectories, current);
297 const environment = yield* DesktopEnvironment.DesktopEnvironment;
298 const entries = yield* fs.readDirectory(
299 `${environment.stateDir}/local-ui/${environment.appVersion}`,
300 );
301 assert.isEmpty(entries);
302 }),
303 ),
304 );
305});
apps/desktop/src/updates/DesktopLocalUi.ts created+236
...@@ -0,0 +1,236 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";
8import * as NodeBuffer from "node:buffer";
9
10import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
11
12const UiBuildManifest = Schema.fromJsonString(
13 Schema.Record(
14 Schema.String,
15 Schema.Struct({
16 file: Schema.String,
17 css: Schema.optionalKey(Schema.Array(Schema.String)),
18 assets: Schema.optionalKey(Schema.Array(Schema.String)),
19 }),
20 ),
21);
22
23export class DesktopLocalUiError extends Schema.TaggedError<DesktopLocalUiError>()(
24 "DesktopLocalUiError",
25 { message: Schema.String, cause: Schema.Defect() },
26) {}
27
28const isLocalUiError = Schema.is(DesktopLocalUiError);
29const decodeUiBuildManifest = Schema.decodeEffect(UiBuildManifest);
30const UiPointer = Schema.fromJsonString(
31 Schema.Struct({ name: Schema.String, version: Schema.String }),
32);
33const decodeUiPointer = Schema.decodeEffect(UiPointer);
34const encodeUiPointer = Schema.encodeEffect(UiPointer);
35const incompleteBuildMessage = "The UI build is incomplete. Finish the build and try again.";
36
37export class DesktopLocalUi extends Context.Service<
38 DesktopLocalUi,
39 {
40 readonly assetDirectories: Effect.Effect<readonly [string, ...string[]]>;
41 readonly currentVersion: Effect.Effect<string>;
42 readonly apply: (source: string, version: string) => Effect.Effect<void, DesktopLocalUiError>;
43 readonly reset: Effect.Effect<void, DesktopLocalUiError>;
44 }
45>()("@t3tools/desktop/updates/DesktopLocalUi") {}
46
47/** @public Service construction is part of the canonical Effect module API. */
48export const make = Effect.gen(function* () {
49 const fs = yield* FileSystem.FileSystem;
50 const environment = yield* DesktopEnvironment.DesktopEnvironment;
51 const path = environment.path;
52 const directory = path.join(environment.stateDir, "local-ui", environment.appVersion);
53 const pointer = path.join(directory, "current");
54 const bundled = environment.clientAssetsDir;
55 const currentVersion = yield* Ref.make(environment.appVersion);
56 const initial = yield* Effect.gen(function* () {
57 if (yield* fs.exists(pointer)) {
58 const { name, version } = yield* decodeUiPointer(yield* fs.readFileString(pointer));
59 if (name.startsWith("build-") && path.basename(name) === name) {
60 const snapshot = path.join(directory, name);
61 if (yield* fs.exists(path.join(snapshot, "index.html"))) {
62 yield* Ref.set(currentVersion, version);
63 return snapshot;
64 }
65 }
66 }
67 return bundled;
68 }).pipe(Effect.catch((error) => Effect.logError(error).pipe(Effect.as(bundled))));
69
70 const active = yield* Ref.make<readonly [string, ...string[]]>(
71 initial === bundled ? [bundled] : [initial, bundled],
72 );
73 const mutex = yield* Semaphore.make(1);
74
75 const apply = (source: string, version: string) =>
76 Effect.gen(function* () {
77 if (environment.isDevelopment) {
78 return yield* new DesktopLocalUiError({
79 message: "The development UI updates automatically.",
80 cause: "Development renderer",
81 });
82 }
83 if (!(yield* fs.exists(path.join(source, "index.html")))) {
84 return yield* new DesktopLocalUiError({
85 message: "The downloaded update has no completed UI build.",
86 cause: source,
87 });
88 }
89
90 yield* fs.makeDirectory(directory, { recursive: true });
91 const snapshot = yield* fs.makeTempDirectory({ directory, prefix: "build-" });
92
93 yield* Effect.gen(function* () {
94 yield* Effect.gen(function* () {
95 const html = yield* fs.readFileString(path.join(source, "index.html"));
96 const manifestJson = yield* fs.readFileString(
97 path.join(source, ".vite", "manifest.json"),
98 );
99 const manifest = yield* decodeUiBuildManifest(manifestJson);
100 const references = [...html.matchAll(/<(?:script|link)\b[^>]*(?:src|href)="([^"]+)"/g)];
101 if (!references.some(([tag]) => tag.startsWith("<script"))) {
102 return yield* new DesktopLocalUiError({
103 message: incompleteBuildMessage,
104 cause: "Missing application script",
105 });
106 }
107
108 const files = Object.values(manifest).flatMap((entry) => [
109 entry.file,
110 ...(entry.css ?? []),
111 ...(entry.assets ?? []),
112 ]);
113 const stagedFiles: Array<{ file: string; contents: NodeBuffer.Buffer }> = [
114 { file: "index.html", contents: NodeBuffer.Buffer.from(html) },
115 {
116 file: ".vite/manifest.json",
117 contents: NodeBuffer.Buffer.from(manifestJson),
118 },
119 ];
120 for (const reference of new Set([...files, ...references.map((match) => match[1]!)])) {
121 const url = yield* Effect.try({
122 try: () => new URL(reference, "http://local-ui/"),
123 catch: (cause) => new DesktopLocalUiError({ message: incompleteBuildMessage, cause }),
124 });
125 if (url.origin !== "http://local-ui") continue;
126 const file = yield* Effect.try({
127 try: () => path.resolve(source, `.${decodeURIComponent(url.pathname)}`),
128 catch: (cause) => new DesktopLocalUiError({ message: incompleteBuildMessage, cause }),
129 });
130 if (!file.startsWith(source + path.sep)) {
131 return yield* new DesktopLocalUiError({
132 message: incompleteBuildMessage,
133 cause: reference,
134 });
135 }
136 const contents = NodeBuffer.Buffer.from(yield* fs.readFile(file));
137 if (contents.length === 0) {
138 return yield* new DesktopLocalUiError({
139 message: incompleteBuildMessage,
140 cause: reference,
141 });
142 }
143 stagedFiles.push({ file: path.relative(source, file), contents });
144 }
145
146 yield* fs.copy(source, snapshot);
147 for (const { file, contents } of stagedFiles) {
148 for (const root of [source, snapshot]) {
149 const copied = yield* fs.readFile(path.join(root, file));
150 if (!contents.equals(copied)) {
151 return yield* new DesktopLocalUiError({
152 message: incompleteBuildMessage,
153 cause: file,
154 });
155 }
156 }
157 }
158 }).pipe(
159 Effect.mapError((cause) =>
160 isLocalUiError(cause)
161 ? cause
162 : new DesktopLocalUiError({ message: incompleteBuildMessage, cause }),
163 ),
164 );
165
166 yield* Effect.uninterruptible(
167 Effect.gen(function* () {
168 yield* fs.writeFileString(
169 `${pointer}.tmp`,
170 yield* encodeUiPointer({ name: path.basename(snapshot), version }),
171 );
172 yield* fs.rename(`${pointer}.tmp`, pointer);
173 const previous = yield* Ref.get(active);
174 yield* Ref.set(active, [snapshot, previous[0]]);
175 yield* Ref.set(currentVersion, version);
176 }),
177 );
178 }).pipe(
179 Effect.onError(() =>
180 Effect.gen(function* () {
181 if (!(yield* Ref.get(active)).includes(snapshot)) {
182 yield* fs.remove(snapshot, { recursive: true, force: true });
183 }
184 }).pipe(Effect.ignore),
185 ),
186 );
187
188 const keep = yield* Ref.get(active);
189 yield* fs.readDirectory(directory).pipe(
190 Effect.flatMap((entries) =>
191 Effect.forEach(entries, (name) => {
192 const candidate = path.join(directory, name);
193 return name.startsWith("build-") && !keep.includes(candidate)
194 ? fs.remove(candidate, { recursive: true, force: true })
195 : Effect.void;
196 }),
197 ),
198 Effect.ignore,
199 );
200 }).pipe(
201 Effect.mapError((cause) =>
202 isLocalUiError(cause)
203 ? cause
204 : new DesktopLocalUiError({
205 message: "Could not apply the UI build. Try again.",
206 cause,
207 }),
208 ),
209 Effect.tapError((error) => Effect.logError(error)),
210 mutex.withPermit,
211 );
212
213 const reset = Effect.uninterruptible(
214 Effect.gen(function* () {
215 yield* fs.remove(pointer, { force: true });
216 yield* Ref.set(currentVersion, environment.appVersion);
217 const previous = yield* Ref.get(active);
218 yield* Ref.set(active, previous[0] === bundled ? [bundled] : [bundled, previous[0]]);
219 }),
220 ).pipe(
221 Effect.mapError(
222 (cause) => new DesktopLocalUiError({ message: "Could not restore the bundled UI.", cause }),
223 ),
224 Effect.tapError((error) => Effect.logError(error)),
225 mutex.withPermit,
226 );
227
228 return DesktopLocalUi.of({
229 assetDirectories: Ref.get(active),
230 currentVersion: Ref.get(currentVersion),
231 apply,
232 reset,
233 });
234});
235
236export const layer = Layer.effect(DesktopLocalUi, make);
apps/desktop/src/updates/DesktopRemoteUpdates.test.ts+17
...@@ -84,6 +84,23 @@ function terminalReports(reports: DesktopUpdateStatusReport[]): DesktopUpdateSta...@@ -84,6 +84,23 @@ function terminalReports(reports: DesktopUpdateStatusReport[]): DesktopUpdateSta
84}84}
8585
86describe("DesktopRemoteUpdates", () => {86describe("DesktopRemoteUpdates", () => {
87 it.effect(
88 "leaves a UI-only update pending for the local renderer without restarting its backend",
89 () => {
90 const harness = makeHarness({ uiOnly: true });
91 return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
92 Effect.gen(function* () {
93 yield* Queue.offer(requests, request("ui-only"));
94 yield* settle;
95 assert.equal(terminalReports(reports).at(-1)?.outcome, "up-to-date");
96 assert.equal((yield* (yield* DesktopUpdates.DesktopUpdates).getState).installKind, "ui");
97 assert.deepEqual(harness.installSteps, []);
98 assert.deepEqual(harness.uiInstalls, []);
99 }),
100 );
101 },
102 );
103
87 it.effect("drives check, download, and install with no local confirmation", () => {104 it.effect("drives check, download, and install with no local confirmation", () => {
88 const harness = makeHarness();105 const harness = makeHarness();
89106
apps/desktop/src/updates/DesktopRemoteUpdates.ts+4
...@@ -228,6 +228,10 @@ export const listen: Effect.Effect<...@@ -228,6 +228,10 @@ export const listen: Effect.Effect<
228 );228 );
229 return false;229 return false;
230 case "install": {230 case "install": {
231 if (state.installKind === "ui") {
232 yield* publishReport(state, { outcome: "up-to-date" }, request.requestId);
233 return true;
234 }
231 // The download event fires before its action releases the235 // The download event fires before its action releases the
232 // updater reservation. Wait until the prepared install can236 // updater reservation. Wait until the prepared install can
233 // be committed by the client in a separate RPC.237 // be committed by the client in a separate RPC.
apps/desktop/src/updates/DesktopUpdates.test.ts+157-1
...@@ -18,9 +18,165 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";...@@ -18,9 +18,165 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
19import * as DesktopState from "../app/DesktopState.ts";19import * as DesktopState from "../app/DesktopState.ts";
20import * as DesktopUpdates from "./DesktopUpdates.ts";20import * as DesktopUpdates from "./DesktopUpdates.ts";
21import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts";21import { flushCallbacks, makeHarness, forkReleaseFixture } from "./updatesTestHarness.ts";
2222
23describe("DesktopUpdates", () => {23describe("DesktopUpdates", () => {
24 it.effect(
25 "discovers new private releases and uses them for either stored channel preference",
26 () => {
27 let latest = "0.0.2000000000";
28 const harness = makeHarness({
29 env: { T3CODE_DESKTOP_MOCK_UPDATES: "false" },
30 appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n",
31 releaseManifest: () => forkReleaseFixture(latest),
32 });
33 return Effect.scoped(
34 Effect.gen(function* () {
35 const updates = yield* DesktopUpdates.DesktopUpdates;
36 yield* updates.configure;
37 yield* updates.check("test");
38 assert.equal(harness.checkCount(), 0);
39 assert.equal(harness.downloadCount(), 0);
40 assert.equal((yield* updates.getState).status, "downloaded");
41 latest = "0.0.2000000001";
42 yield* updates.setChannel("nightly");
43 yield* updates.check("test");
44 const state = yield* updates.getState;
45 assert.equal(state.channel, "nightly");
46 assert.equal(state.status, "downloaded");
47 assert.equal(state.availableVersion, latest);
48 }),
49 ).pipe(Effect.provide(harness.layer));
50 },
51 );
52
53 it.effect("applies a compatible downloaded UI without stopping a backend or quitting", () => {
54 const harness = makeHarness({ uiOnly: true });
55 return Effect.scoped(
56 Effect.gen(function* () {
57 const updates = yield* DesktopUpdates.DesktopUpdates;
58 yield* updates.configure;
59 yield* updates.check("test");
60 const downloaded = yield* updates.download;
61 assert.equal(downloaded.state.installKind, "ui");
62 assert.equal(downloaded.state.status, "downloaded");
63 const result = yield* updates.install;
64 assert.equal(result.completed, true);
65 assert.equal(result.state.currentVersion, "1.2.3");
66 assert.equal(result.state.uiVersion, "1.2.4");
67 assert.equal(result.state.status, "up-to-date");
68 assert.deepEqual(harness.uiInstalls, ["1.2.4"]);
69 assert.equal(harness.quitAndInstalls(), 0);
70 assert.isEmpty(harness.installSteps);
71 assert.isEmpty([...harness.updateRestartMarkers]);
72 harness.emit("update-available", { version: "1.2.4" });
73 yield* flushCallbacks;
74 assert.equal((yield* updates.getState).status, "up-to-date");
75 }),
76 ).pipe(Effect.provide(harness.layer));
77 });
78
79 it.effect("downloads code automatically but activates it only after a confirmed install", () => {
80 let stopped = 0;
81 const harness = makeHarness({
82 appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n",
83 releaseManifest: () => forkReleaseFixture("0.0.2000000000"),
84 stopBackend: Effect.sync(() => {
85 stopped += 1;
86 }),
87 });
88 return Effect.scoped(
89 Effect.gen(function* () {
90 const updates = yield* DesktopUpdates.DesktopUpdates;
91 yield* updates.configure;
92 const checked = yield* updates.check("test");
93 assert.equal(checked.state.status, "downloaded");
94 assert.equal(checked.state.installKind, "app");
95 assert.deepEqual(harness.installSteps, []);
96 assert.equal(harness.downloadCount(), 0);
97 const installed = yield* updates.install;
98 assert.equal(installed.accepted, true);
99 assert.deepEqual(harness.installSteps, ["activateCode", "relaunch", "quit"]);
100 assert.equal(stopped, 1);
101 assert.equal(harness.quitAndInstalls(), 0);
102 }),
103 ).pipe(Effect.provide(harness.layer));
104 });
105
106 it.effect("retains a confirmed full restart for incompatible downloaded releases", () => {
107 const harness = makeHarness({ uiOnly: false });
108 return Effect.scoped(
109 Effect.gen(function* () {
110 const updates = yield* DesktopUpdates.DesktopUpdates;
111 yield* updates.configure;
112 harness.emit("update-available", { version: "1.2.4" });
113 yield* flushCallbacks;
114 assert.equal((yield* updates.download).state.installKind, "app");
115 const result = yield* updates.install;
116 assert.equal(result.accepted, true);
117 assert.equal(harness.quitAndInstalls(), 1);
118 assert.isEmpty(harness.uiInstalls);
119 }),
120 ).pipe(Effect.provide(harness.layer));
121 });
122
123 it.effect("does not offer an already-applied UI release after relaunch", () => {
124 const harness = makeHarness({ uiVersion: "1.2.4" });
125 return Effect.scoped(
126 Effect.gen(function* () {
127 const updates = yield* DesktopUpdates.DesktopUpdates;
128 yield* updates.configure;
129 harness.emit("update-available", { version: "1.2.4" });
130 yield* flushCallbacks;
131 assert.equal((yield* updates.getState).status, "up-to-date");
132 assert.equal((yield* updates.getState).currentVersion, "1.2.3");
133 }),
134 ).pipe(Effect.provide(harness.layer));
135 });
136
137 it.effect("does not offer an older private release", () => {
138 const harness = makeHarness({
139 env: { T3CODE_DESKTOP_MOCK_UPDATES: "false" },
140 appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n",
141 releaseManifest: () => forkReleaseFixture("0.0.2000000000"),
142 uiVersion: "0.0.2000000001",
143 });
144 return Effect.scoped(
145 Effect.gen(function* () {
146 const updates = yield* DesktopUpdates.DesktopUpdates;
147 yield* updates.configure;
148 yield* updates.check("test");
149 assert.equal(harness.checkCount(), 0);
150 assert.equal((yield* updates.getState).status, "up-to-date");
151 }),
152 ).pipe(Effect.provide(harness.layer));
153 });
154
155 it.effect("keeps a platform up to date when only another platform has a newer release", () => {
156 const release = forkReleaseFixture("0.0.2000000001");
157 const target = release.desktop["darwin-x64"]!;
158 const harness = makeHarness({
159 env: { T3CODE_DESKTOP_MOCK_UPDATES: "false" },
160 appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n",
161 releaseManifest: () => ({
162 ...release,
163 desktop: { "darwin-x64": { ...target, version: "0.0.2000000000" } },
164 }),
165 uiVersion: "0.0.2000000000",
166 });
167 return Effect.scoped(
168 Effect.gen(function* () {
169 const updates = yield* DesktopUpdates.DesktopUpdates;
170 yield* updates.configure;
171 yield* updates.check("test");
172 const state = yield* updates.getState;
173 assert.equal(state.status, "up-to-date");
174 assert.equal(state.availableVersion, null);
175 assert.equal(harness.downloadCount(), 0);
176 }),
177 ).pipe(Effect.provide(harness.layer));
178 });
179
24 it("preserves complete causes for update poller and event failures", () => {180 it("preserves complete causes for update poller and event failures", () => {
25 const cause = Cause.combine(181 const cause = Cause.combine(
26 Cause.fail(new Error("updater failed")),182 Cause.fail(new Error("updater failed")),
apps/desktop/src/updates/DesktopUpdates.ts+167-12
...@@ -7,7 +7,9 @@ import {...@@ -7,7 +7,9 @@ import {
7 type DesktopUpdateCheckResult,7 type DesktopUpdateCheckResult,
8 type DesktopUpdateState,8 type DesktopUpdateState,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { compareSemverVersions } from "@t3tools/shared/semver";
10import { errorTag } from "@t3tools/shared/observability";11import { errorTag } from "@t3tools/shared/observability";
12import { ForkReleaseManifest } from "@t3tools/shared/forkUpdateDirectory";
11import * as Cause from "effect/Cause";13import * as Cause from "effect/Cause";
12import * as Context from "effect/Context";14import * as Context from "effect/Context";
13import * as DateTime from "effect/DateTime";15import * as DateTime from "effect/DateTime";
...@@ -30,6 +32,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";...@@ -30,6 +32,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
30import * as DesktopObservability from "../app/DesktopObservability.ts";32import * as DesktopObservability from "../app/DesktopObservability.ts";
31import * as DesktopState from "../app/DesktopState.ts";33import * as DesktopState from "../app/DesktopState.ts";
32import * as ElectronUpdater from "../electron/ElectronUpdater.ts";34import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
35import * as DesktopForkUpdates from "./DesktopForkUpdates.ts";
36import * as ElectronApp from "../electron/ElectronApp.ts";
37import * as DesktopLocalUi from "./DesktopLocalUi.ts";
33import * as ElectronWindow from "../electron/ElectronWindow.ts";38import * as ElectronWindow from "../electron/ElectronWindow.ts";
34import * as IpcChannels from "../ipc/channels.ts";39import * as IpcChannels from "../ipc/channels.ts";
35import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";40import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
...@@ -350,20 +355,36 @@ export const make = Effect.gen(function* () {...@@ -350,20 +355,36 @@ export const make = Effect.gen(function* () {
350 const environment = yield* DesktopEnvironment.DesktopEnvironment;355 const environment = yield* DesktopEnvironment.DesktopEnvironment;
351 const fileSystem = yield* FileSystem.FileSystem;356 const fileSystem = yield* FileSystem.FileSystem;
352 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;357 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
358 const fork = yield* DesktopForkUpdates.DesktopForkUpdates;
359 const electronApp = yield* ElectronApp.ElectronApp;
360 const forkRelease = yield* Ref.make<{
361 base: URL;
362 release: typeof ForkReleaseManifest.Type;
363 kind: "ui" | "code" | "shell";
364 } | null>(null);
353365
354 const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none());366 const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none());
367 const forkUpdateDirectory = Ref.get(appUpdateYmlConfigRef).pipe(
368 Effect.map((config) => {
369 if (Option.isNone(config) || config.value.provider !== "generic") return;
370 const url = URL.parse(config.value.url ?? "");
371 return url?.searchParams.has("ls") ? url : undefined;
372 }),
373 );
374 const localUi = yield* DesktopLocalUi.DesktopLocalUi;
355 const activeUpdateActionRef = yield* Ref.make<Option.Option<UpdateAction>>(Option.none());375 const activeUpdateActionRef = yield* Ref.make<Option.Option<UpdateAction>>(Option.none());
356 const finishedUpdateActions = yield* PubSub.unbounded<UpdateAction>();376 const finishedUpdateActions = yield* PubSub.unbounded<UpdateAction>();
357 const updaterConfiguredRef = yield* Ref.make(false);377 const updaterConfiguredRef = yield* Ref.make(false);
358 const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);378 const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);
359 const notAppliedVersionRef = yield* Ref.make(Option.none<string>());379 const notAppliedVersionRef = yield* Ref.make(Option.none<string>());
360 const updateStateRef = yield* Ref.make<DesktopUpdateState>(380 const updateStateRef = yield* Ref.make<DesktopUpdateState>({
361 createInitialDesktopUpdateState(381 ...createInitialDesktopUpdateState(
362 environment.appVersion,382 environment.appVersion,
363 environment.runtimeInfo,383 environment.runtimeInfo,
364 environment.defaultDesktopSettings.updateChannel,384 environment.defaultDesktopSettings.updateChannel,
365 ),385 ),
366 );386 uiVersion: yield* localUi.currentVersion,
387 });
367388
368 const stateChanges = yield* PubSub.sliding<DesktopUpdateState>(16);389 const stateChanges = yield* PubSub.sliding<DesktopUpdateState>(16);
369 // Makes ref writes + publishes atomic against subscribe, so a snapshot390 // Makes ref writes + publishes atomic against subscribe, so a snapshot
...@@ -377,7 +398,11 @@ export const make = Effect.gen(function* () {...@@ -377,7 +398,11 @@ export const make = Effect.gen(function* () {
377 const setState = (state: DesktopUpdateState): Effect.Effect<void> =>398 const setState = (state: DesktopUpdateState): Effect.Effect<void> =>
378 stateMutex399 stateMutex
379 .withPermits(1)(400 .withPermits(1)(
380 Ref.set(updateStateRef, state).pipe(Effect.andThen(PubSub.publish(stateChanges, state))),401 Effect.gen(function* () {
402 const next = { ...state, uiVersion: yield* localUi.currentVersion };
403 yield* Ref.set(updateStateRef, next);
404 yield* PubSub.publish(stateChanges, next);
405 }),
381 )406 )
382 .pipe(Effect.andThen(emitState));407 .pipe(Effect.andThen(emitState));
383408
...@@ -498,9 +523,50 @@ export const make = Effect.gen(function* () {...@@ -498,9 +523,50 @@ export const make = Effect.gen(function* () {
498 yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt));523 yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt));
499 yield* logUpdaterInfo("checking for updates", { reason });524 yield* logUpdaterInfo("checking for updates", { reason });
500525
501 return yield* electronUpdater.checkForUpdates.pipe(526 return yield* Effect.gen(function* () {
502 Effect.as(true),527 const directory = yield* forkUpdateDirectory;
528 if (directory) {
529 directory.searchParams.delete("ls");
530 const release = yield* fork.check(directory);
531 const kind = yield* fork.kind(release);
532 const target = release.desktop[`${environment.platform}-${environment.processArch}`]!;
533 const effectiveVersion = state.uiVersion ?? state.currentVersion;
534 if (
535 /^0\.0\.[0-9]+$/.test(effectiveVersion) &&
536 compareSemverVersions(target.version, effectiveVersion) <= 0
537 ) {
538 yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));
539 return true;
540 }
541 yield* Ref.set(forkRelease, { base: directory, release, kind });
542 if (kind !== "shell") {
543 yield* setState({
544 ...reduceDesktopUpdateStateOnUpdateAvailable(state, target.version, checkedAt),
545 installKind: kind === "ui" ? "ui" : "app",
546 });
547 return true;
548 }
549 yield* electronUpdater.setFeedURL({ provider: "generic", url: directory.href });
550 yield* electronUpdater.setChannel(target.app.version);
551 yield* electronUpdater.setAllowDowngrade(false);
552 }
553 yield* electronUpdater.checkForUpdates;
554 return true;
555 }).pipe(
556 Effect.mapError((error) =>
557 error._tag === "ElectronUpdaterCheckForUpdatesError" ||
558 error._tag === "DesktopForkUpdateError"
559 ? error
560 : new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({
561 channel: state.channel,
562 cause: error,
563 }),
564 ),
503 Effect.catchTags({565 Effect.catchTags({
566 DesktopForkUpdateError: (error) =>
567 updateState((current) =>
568 reduceDesktopUpdateStateOnCheckFailure(current, error.message, checkedAt),
569 ).pipe(Effect.as(true)),
504 ElectronUpdaterCheckForUpdatesError: Effect.fn(570 ElectronUpdaterCheckForUpdatesError: Effect.fn(
505 "desktop.updates.handleCheckForUpdatesFailure",571 "desktop.updates.handleCheckForUpdatesFailure",
506 )(function* (error) {572 )(function* (error) {
...@@ -543,10 +609,27 @@ export const make = Effect.gen(function* () {...@@ -543,10 +609,27 @@ export const make = Effect.gen(function* () {
543 isArm64HostRunningIntelBuild(environment.runtimeInfo),609 isArm64HostRunningIntelBuild(environment.runtimeInfo),
544 );610 );
545 yield* logUpdaterInfo("downloading update");611 yield* logUpdaterInfo("downloading update");
546 yield* electronUpdater.downloadUpdate;612 const candidate = yield* Ref.get(forkRelease);
613 const version = state.availableVersion;
614 let uiOnly = false;
615 if (candidate !== null && candidate.kind !== "shell") {
616 uiOnly = (yield* fork.prepare(candidate.base, candidate.release)) === "ui";
617 } else {
618 yield* electronUpdater.downloadUpdate;
619 }
620 yield* updateState((current) => ({
621 ...(version === null
622 ? current
623 : reduceDesktopUpdateStateOnDownloadComplete(current, version)),
624 installKind: uiOnly ? "ui" : "app",
625 }));
547 return { accepted: true, completed: true };626 return { accepted: true, completed: true };
548 }).pipe(627 }).pipe(
549 Effect.catchTags({628 Effect.catchTags({
629 DesktopForkUpdateError: (error) =>
630 updateState((current) =>
631 reduceDesktopUpdateStateOnDownloadFailure(current, error.message),
632 ).pipe(Effect.as({ accepted: true, completed: false })),
550 ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")(633 ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")(
551 function* (error) {634 function* (error) {
552 yield* updateState((current) =>635 yield* updateState((current) =>
...@@ -744,6 +827,37 @@ export const make = Effect.gen(function* () {...@@ -744,6 +827,37 @@ export const make = Effect.gen(function* () {
744 return { accepted: false, completed: false, failed: false };827 return { accepted: false, completed: false, failed: false };
745 }828 }
746829
830 const installing = yield* Ref.get(updateStateRef);
831 if (
832 expectedVersion === undefined &&
833 installing.installKind === "ui" &&
834 installing.downloadedVersion !== null
835 ) {
836 return yield* fork.installUi(installing.downloadedVersion).pipe(
837 Effect.flatMap((applied) =>
838 applied
839 ? updateState((current) => ({
840 ...current,
841 status: "up-to-date",
842 availableVersion: null,
843 downloadedVersion: null,
844 installKind: "app",
845 downloadPercent: null,
846 message: null,
847 errorContext: null,
848 canRetry: false,
849 })).pipe(Effect.as({ accepted: true, completed: true, failed: false }))
850 : Effect.succeed({ accepted: false, completed: false, failed: false }),
851 ),
852 Effect.catch((error) =>
853 updateState((current) =>
854 reduceDesktopUpdateStateOnInstallFailure(current, error.message),
855 ).pipe(Effect.as({ accepted: true, completed: false, failed: true })),
856 ),
857 Effect.ensuring(finishUpdateAction("install")),
858 );
859 }
860
747 yield* Ref.set(desktopState.quitting, true);861 yield* Ref.set(desktopState.quitting, true);
748862
749 return yield* Effect.gen(function* () {863 return yield* Effect.gen(function* () {
...@@ -763,13 +877,26 @@ export const make = Effect.gen(function* () {...@@ -763,13 +877,26 @@ export const make = Effect.gen(function* () {
763 (instance) => instance.stop({ timeout: Duration.seconds(5) }),877 (instance) => instance.stop({ timeout: Duration.seconds(5) }),
764 { concurrency: "unbounded" },878 { concurrency: "unbounded" },
765 );879 );
766 yield* electronUpdater.quitAndInstall({880 const candidate = yield* Ref.get(forkRelease);
767 isSilent: true,881 if (candidate?.kind === "code" && downloadedVersion !== null) {
768 isForceRunAfter: true,882 if (!(yield* fork.activateCode(downloadedVersion))) {
769 });883 return yield* new DesktopForkUpdates.DesktopForkUpdateError({
884 message: "The update is no longer prepared. Download it again.",
885 cause: downloadedVersion,
886 });
887 }
888 yield* electronApp.relaunch({});
889 yield* electronApp.quit;
890 } else {
891 yield* electronUpdater.quitAndInstall({ isSilent: true, isForceRunAfter: true });
892 }
770 return { accepted: true, completed: false, failed: false };893 return { accepted: true, completed: false, failed: false };
771 }).pipe(894 }).pipe(
772 Effect.catchTags({895 Effect.catchTags({
896 DesktopForkUpdateError: (error) =>
897 recoverFailedInstall(error.message).pipe(
898 Effect.as({ accepted: true, completed: false, failed: true }),
899 ),
773 ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")(900 ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")(
774 function* (error) {901 function* (error) {
775 yield* recoverFailedInstall(error.message);902 yield* recoverFailedInstall(error.message);
...@@ -827,6 +954,11 @@ export const make = Effect.gen(function* () {...@@ -827,6 +954,11 @@ export const make = Effect.gen(function* () {
827 const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {954 const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {
828 yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe(955 yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe(
829 Effect.andThen(checkForUpdates("startup")),956 Effect.andThen(checkForUpdates("startup")),
957 Effect.andThen(
958 Effect.gen(function* () {
959 if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate;
960 }),
961 ),
830 Effect.catchCause((cause) => {962 Effect.catchCause((cause) => {
831 if (Cause.hasInterruptsOnly(cause)) {963 if (Cause.hasInterruptsOnly(cause)) {
832 return Effect.void;964 return Effect.void;
...@@ -842,6 +974,11 @@ export const make = Effect.gen(function* () {...@@ -842,6 +974,11 @@ export const make = Effect.gen(function* () {
842 );974 );
843 yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe(975 yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe(
844 Effect.andThen(checkForUpdates("poll")),976 Effect.andThen(checkForUpdates("poll")),
977 Effect.andThen(
978 Effect.gen(function* () {
979 if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate;
980 }),
981 ),
845 Effect.forever,982 Effect.forever,
846 Effect.catchCause((cause) => {983 Effect.catchCause((cause) => {
847 if (Cause.hasInterruptsOnly(cause)) {984 if (Cause.hasInterruptsOnly(cause)) {
...@@ -865,7 +1002,10 @@ export const make = Effect.gen(function* () {...@@ -865,7 +1002,10 @@ export const make = Effect.gen(function* () {
865 Effect.flatMap(1002 Effect.flatMap(
866 Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) {1003 Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) {
867 const state = yield* Ref.get(updateStateRef);1004 const state = yield* Ref.get(updateStateRef);
868 if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) {1005 if (
1006 !(yield* forkUpdateDirectory) &&
1007 resolveDefaultDesktopUpdateChannel(info.version) !== state.channel
1008 ) {
869 yield* logUpdaterInfo("ignoring update that does not match selected channel", {1009 yield* logUpdaterInfo("ignoring update that does not match selected channel", {
870 version: info.version,1010 version: info.version,
871 channel: state.channel,1011 channel: state.channel,
...@@ -877,6 +1017,14 @@ export const make = Effect.gen(function* () {...@@ -877,6 +1017,14 @@ export const make = Effect.gen(function* () {
877 }1017 }
8781018
879 const checkedAt = yield* currentIsoTimestamp;1019 const checkedAt = yield* currentIsoTimestamp;
1020 if (
1021 state.uiVersion &&
1022 state.uiVersion !== state.currentVersion &&
1023 compareSemverVersions(info.version, state.uiVersion) <= 0
1024 ) {
1025 yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));
1026 return;
1027 }
880 const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes(1028 const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes(
881 info.releaseNotes,1029 info.releaseNotes,
882 info.version,1030 info.version,
...@@ -1002,6 +1150,12 @@ export const make = Effect.gen(function* () {...@@ -1002,6 +1150,12 @@ export const make = Effect.gen(function* () {
1002 Effect.flatMap(1150 Effect.flatMap(
1003 Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) {1151 Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) {
1004 const state = yield* Ref.get(updateStateRef);1152 const state = yield* Ref.get(updateStateRef);
1153 if (
1154 state.uiVersion &&
1155 state.uiVersion !== state.currentVersion &&
1156 compareSemverVersions(info.version, state.uiVersion) <= 0
1157 )
1158 return;
1005 yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version));1159 yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version));
1006 yield* logUpdaterInfo("update downloaded", { version: info.version });1160 yield* logUpdaterInfo("update downloaded", { version: info.version });
1007 }),1161 }),
...@@ -1157,6 +1311,7 @@ export const make = Effect.gen(function* () {...@@ -1157,6 +1311,7 @@ export const make = Effect.gen(function* () {
1157 };1311 };
1158 }1312 }
1159 const checked = yield* checkForUpdates(reason);1313 const checked = yield* checkForUpdates(reason);
1314 if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate;
1160 return {1315 return {
1161 checked,1316 checked,
1162 state: yield* Ref.get(updateStateRef),1317 state: yield* Ref.get(updateStateRef),
apps/desktop/src/updates/forkRuntime.test.ts created+86
...@@ -0,0 +1,86 @@
1// @effect-diagnostics nodeBuiltinImport:off - The fixture verifies synchronous bootstrap selection on disk.
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import { afterEach, beforeEach, expect, it } from "vite-plus/test";
6import { readActiveForkRuntime } from "./forkRuntime.ts";
7
8let home: string;
9let bundled: string;
10let releases: string;
11const shell = "a".repeat(64);
12const pointer = {
13 directory: "release-abc123",
14 version: "0.0.2000000000",
15 shell,
16 runtime: "b".repeat(64),
17};
18
19beforeEach(() => {
20 home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "knife-bootstrap-"));
21 bundled = NodePath.join(home, "bundle");
22 releases = NodePath.join(home, "fork-releases");
23 NodeFS.mkdirSync(bundled);
24 NodeFS.mkdirSync(releases);
25 NodeFS.writeFileSync(
26 NodePath.join(bundled, "knife-runtime.json"),
27 JSON.stringify({ shell, version: "0.0.1999999999" }),
28 );
29 for (const file of [
30 "apps/desktop/dist-electron/main.cjs",
31 "apps/desktop/dist-electron/compileCache.cjs",
32 "apps/desktop/dist-electron/preload.cjs",
33 "apps/server/dist/bin.mjs",
34 "apps/server/dist/client/index.html",
35 ]) {
36 const target = NodePath.join(releases, pointer.directory, file);
37 NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true });
38 NodeFS.writeFileSync(target, "fixture");
39 }
40 NodeFS.writeFileSync(NodePath.join(releases, "current.json"), JSON.stringify(pointer));
41});
42afterEach(() => NodeFS.rmSync(home, { recursive: true, force: true }));
43
44it("selects the fully staged runtime matching the signed shell", () => {
45 expect(readActiveForkRuntime(home, bundled)).toEqual({
46 ...pointer,
47 directory: NodePath.join(releases, pointer.directory),
48 });
49});
50
51it.each([
52 { ...pointer, shell: "c".repeat(64) },
53 { ...pointer, directory: "../outside" },
54 { ...pointer, runtime: "malformed" },
55])("ignores an incompatible or malformed active pointer", (invalid) => {
56 NodeFS.writeFileSync(NodePath.join(releases, "current.json"), JSON.stringify(invalid));
57 expect(readActiveForkRuntime(home, bundled)).toBeUndefined();
58});
59
60it.each([
61 "apps/server/dist/client/index.html",
62 "apps/desktop/dist-electron/compileCache.cjs",
63 "apps/desktop/dist-electron/preload.cjs",
64])("keeps the bundled app when the staged runtime is missing %s", (file) => {
65 NodeFS.unlinkSync(NodePath.join(releases, pointer.directory, file));
66 expect(readActiveForkRuntime(home, bundled)).toBeUndefined();
67});
68
69it("prefers a newer bundled release even when an older pointer uses the same shell", () => {
70 NodeFS.writeFileSync(
71 NodePath.join(bundled, "knife-runtime.json"),
72 JSON.stringify({ shell, version: "0.0.2000000001" }),
73 );
74 expect(readActiveForkRuntime(home, bundled)).toBeUndefined();
75});
76
77it("requires the separate Windows server tree before selecting its runtime", () => {
78 expect(readActiveForkRuntime(home, bundled, "win32")).toBeUndefined();
79 const directory = NodePath.join(releases, pointer.directory);
80 NodeFS.mkdirSync(NodePath.join(directory, "server/apps"), { recursive: true });
81 NodeFS.renameSync(
82 NodePath.join(directory, "apps/server"),
83 NodePath.join(directory, "server/apps/server"),
84 );
85 expect(readActiveForkRuntime(home, bundled, "win32")?.version).toBe(pointer.version);
86});
apps/desktop/src/updates/forkRuntime.ts created+75
...@@ -0,0 +1,75 @@
1// @effect-diagnostics nodeBuiltinImport:off - Bootstrap metadata is read synchronously before the main bundle loads.
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4
5export interface ActiveForkRuntime {
6 readonly directory: string;
7 readonly version: string;
8 readonly shell: string;
9 readonly runtime: string;
10}
11
12export function readActiveForkRuntime(
13 stateDir: string,
14 bundledRoot: string,
15 // oxlint-disable-next-line t3code/no-global-process-runtime -- The synchronous bootstrap runs before Effect services exist.
16 platform: NodeJS.Platform = process.platform,
17): ActiveForkRuntime | undefined {
18 try {
19 const baseline: unknown = JSON.parse(
20 NodeFS.readFileSync(NodePath.join(bundledRoot, "knife-runtime.json"), "utf8"),
21 );
22 const active: unknown = JSON.parse(
23 NodeFS.readFileSync(NodePath.join(stateDir, "fork-releases", "current.json"), "utf8"),
24 );
25 if (
26 typeof baseline !== "object" ||
27 baseline === null ||
28 !("shell" in baseline) ||
29 typeof active !== "object" ||
30 active === null ||
31 !("directory" in active) ||
32 typeof active.directory !== "string" ||
33 !("version" in active) ||
34 typeof active.version !== "string" ||
35 !("shell" in active) ||
36 active.shell !== baseline.shell ||
37 typeof active.shell !== "string" ||
38 !("runtime" in active) ||
39 typeof active.runtime !== "string" ||
40 !/^[a-f0-9]{64}$/.test(active.runtime) ||
41 !/^release-[A-Za-z0-9-]+$/.test(active.directory)
42 )
43 return;
44
45 const bundledVersion =
46 "version" in baseline && typeof baseline.version === "string" ? baseline.version : undefined;
47 if (
48 bundledVersion !== undefined &&
49 /^0\.0\.[0-9]+$/.test(bundledVersion) &&
50 /^0\.0\.[0-9]+$/.test(active.version) &&
51 BigInt(active.version.slice(4)) < BigInt(bundledVersion.slice(4))
52 )
53 return;
54
55 const directory = NodePath.join(stateDir, "fork-releases", active.directory);
56 const serverRoot = platform === "win32" ? "server" : ".";
57 for (const file of [
58 "apps/desktop/dist-electron/main.cjs",
59 "apps/desktop/dist-electron/compileCache.cjs",
60 "apps/desktop/dist-electron/preload.cjs",
61 NodePath.join(serverRoot, "apps/server/dist/bin.mjs"),
62 NodePath.join(serverRoot, "apps/server/dist/client/index.html"),
63 ]) {
64 if (!NodeFS.statSync(NodePath.join(directory, file)).isFile()) return;
65 }
66 return { directory, version: active.version, shell: active.shell, runtime: active.runtime };
67 } catch {
68 return;
69 }
70}
71
72export function activeForkRuntime(): ActiveForkRuntime | undefined {
73 const raw = process.env.T3CODE_DESKTOP_PAYLOAD;
74 return raw === undefined ? undefined : JSON.parse(raw);
75}
apps/desktop/src/updates/updateMachine.ts+1
...@@ -99,6 +99,7 @@ export function reduceDesktopUpdateStateOnUpdateAvailable(...@@ -99,6 +99,7 @@ export function reduceDesktopUpdateStateOnUpdateAvailable(
99 ...state,99 ...state,
100 status: isDownloadedVersion ? "downloaded" : "available",100 status: isDownloadedVersion ? "downloaded" : "available",
101 availableVersion: version,101 availableVersion: version,
102 installKind: isDownloadedVersion ? (state.installKind ?? "app") : "app",
102 downloadedVersion: isDownloadedVersion ? version : null,103 downloadedVersion: isDownloadedVersion ? version : null,
103 releaseNotes: preserveReleaseNotes ? state.releaseNotes : releaseNotes,104 releaseNotes: preserveReleaseNotes ? state.releaseNotes : releaseNotes,
104 omittedReleaseCount: preserveReleaseNotes ? state.omittedReleaseCount : omittedReleaseCount,105 omittedReleaseCount: preserveReleaseNotes ? state.omittedReleaseCount : omittedReleaseCount,
apps/desktop/src/updates/updatesTestHarness.ts+105-2
...@@ -5,15 +5,20 @@ import * as FileSystem from "effect/FileSystem";...@@ -5,15 +5,20 @@ import * as FileSystem from "effect/FileSystem";
5import * as PlatformError from "effect/PlatformError";5import * as PlatformError from "effect/PlatformError";
6import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import { HttpClient, HttpClientResponse } from "effect/http";
89
9import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";10import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
10import * as DesktopConfig from "../app/DesktopConfig.ts";11import * as DesktopConfig from "../app/DesktopConfig.ts";
11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";12import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
13import * as ElectronApp from "../electron/ElectronApp.ts";
14import * as DesktopForkUpdates from "./DesktopForkUpdates.ts";
15import { ForkReleaseManifest } from "@t3tools/shared/forkUpdateDirectory";
12import * as ElectronUpdater from "../electron/ElectronUpdater.ts";16import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
13import * as ElectronWindow from "../electron/ElectronWindow.ts";17import * as ElectronWindow from "../electron/ElectronWindow.ts";
14import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";18import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
15import * as DesktopState from "../app/DesktopState.ts";19import * as DesktopState from "../app/DesktopState.ts";
16import * as DesktopUpdates from "./DesktopUpdates.ts";20import * as DesktopUpdates from "./DesktopUpdates.ts";
21import * as DesktopLocalUi from "./DesktopLocalUi.ts";
1722
18/** Shared DesktopUpdates test harness: a fully stubbed updater layer whose23/** Shared DesktopUpdates test harness: a fully stubbed updater layer whose
19 electron-updater events are driven by hand via `emit`. Used by24 electron-updater events are driven by hand via `emit`. Used by
...@@ -30,6 +35,8 @@ export interface UpdatesHarnessOptions {...@@ -30,6 +35,8 @@ export interface UpdatesHarnessOptions {
30 readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError;35 readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError;
31 readonly setDisableDifferentialDownload?: Effect.Effect<void>;36 readonly setDisableDifferentialDownload?: Effect.Effect<void>;
32 readonly downloadUpdate?: Effect.Effect<void>;37 readonly downloadUpdate?: Effect.Effect<void>;
38 readonly uiOnly?: boolean;
39 readonly uiVersion?: string;
33 readonly quitAndInstall?: Effect.Effect<void, ElectronUpdater.ElectronUpdaterQuitAndInstallError>;40 readonly quitAndInstall?: Effect.Effect<void, ElectronUpdater.ElectronUpdaterQuitAndInstallError>;
34 readonly stopBackend?: Effect.Effect<void>;41 readonly stopBackend?: Effect.Effect<void>;
35 readonly startBackend?: Effect.Effect<void>;42 readonly startBackend?: Effect.Effect<void>;
...@@ -39,6 +46,9 @@ export interface UpdatesHarnessOptions {...@@ -39,6 +46,9 @@ export interface UpdatesHarnessOptions {
39 readonly packageType?: string | undefined;46 readonly packageType?: string | undefined;
40 /** Contents of the install record a previous run left behind. */47 /** Contents of the install record a previous run left behind. */
41 readonly installRecord?: string | undefined;48 readonly installRecord?: string | undefined;
49 readonly appUpdateYml?: string;
50 readonly releaseManifest?: () => typeof ForkReleaseManifest.Type;
51 readonly forkKind?: "ui" | "code" | "shell";
42}52}
4353
44export function makeHarness(options: UpdatesHarnessOptions = {}) {54export function makeHarness(options: UpdatesHarnessOptions = {}) {
...@@ -48,6 +58,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -48,6 +58,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
48 let allowDowngrade = false;58 let allowDowngrade = false;
49 let fullChangelog = false;59 let fullChangelog = false;
50 const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = [];60 const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = [];
61 const channels: string[] = [];
51 const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>();62 const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>();
52 const sentStates: DesktopUpdateState[] = [];63 const sentStates: DesktopUpdateState[] = [];
53 const installSteps: string[] = [];64 const installSteps: string[] = [];
...@@ -76,7 +87,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -76,7 +87,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
76 }),87 }),
77 setAutoDownload: () => Effect.void,88 setAutoDownload: () => Effect.void,
78 setAutoInstallOnAppQuit: () => Effect.void,89 setAutoInstallOnAppQuit: () => Effect.void,
79 setChannel: () => Effect.void,90 setChannel: (channel) =>
91 Effect.sync(() => {
92 channels.push(channel);
93 }),
80 setAllowPrerelease: () => Effect.void,94 setAllowPrerelease: () => Effect.void,
81 allowDowngrade: Effect.sync(() => allowDowngrade),95 allowDowngrade: Effect.sync(() => allowDowngrade),
82 setAllowDowngrade: (value) =>96 setAllowDowngrade: (value) =>
...@@ -93,7 +107,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -93,7 +107,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
93 }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)),107 }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)),
94 downloadUpdate: Effect.sync(() => {108 downloadUpdate: Effect.sync(() => {
95 downloadCount += 1;109 downloadCount += 1;
96 }).pipe(Effect.andThen(options.downloadUpdate ?? Effect.void)),110 }).pipe(
111 Effect.andThen(options.downloadUpdate ?? Effect.void),
112 Effect.as(["/download/update.zip"]),
113 ),
97 quitAndInstall: () =>114 quitAndInstall: () =>
98 Effect.sync(() => {115 Effect.sync(() => {
99 quitAndInstallCount += 1;116 quitAndInstallCount += 1;
...@@ -224,6 +241,14 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -224,6 +241,14 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
224 ? (installRecords.get(path) ?? previousInstallRecord)241 ? (installRecords.get(path) ?? previousInstallRecord)
225 : undefined;242 : undefined;
226 if (installRecord !== undefined) return Effect.succeed(installRecord);243 if (installRecord !== undefined) return Effect.succeed(installRecord);
244 if (
245 path === "/missing/resources/app-update.yml" &&
246 (options.appUpdateYml !== undefined || options.uiOnly)
247 )
248 return Effect.succeed(
249 options.appUpdateYml ??
250 "provider: generic\nurl: https://updates.example.test/fork/?ls=\n",
251 );
227 return path === "/missing/resources/package-type" && options.packageType !== undefined252 return path === "/missing/resources/package-type" && options.packageType !== undefined
228 ? Effect.succeed(options.packageType)253 ? Effect.succeed(options.packageType)
229 : Effect.fail(254 : Effect.fail(
...@@ -249,7 +274,62 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -249,7 +274,62 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
249 }),274 }),
250 });275 });
251276
277 const uiInstalls: string[] = [];
278 let uiVersion = options.uiVersion ?? "1.2.3";
252 const layer = DesktopUpdates.layer.pipe(279 const layer = DesktopUpdates.layer.pipe(
280 Layer.provide(
281 Layer.succeed(DesktopLocalUi.DesktopLocalUi, {
282 currentVersion: Effect.sync(() => uiVersion),
283 assetDirectories: Effect.succeed(["/client"] as const),
284 apply: () => Effect.void,
285 reset: Effect.void,
286 }),
287 ),
288 Layer.provide(
289 Layer.succeed(DesktopForkUpdates.DesktopForkUpdates, {
290 check: () => Effect.sync(() => options.releaseManifest?.() ?? forkReleaseFixture("1.2.4")),
291 kind: () => Effect.succeed(options.forkKind ?? (options.uiOnly ? "ui" : "code")),
292 prepare: () => Effect.succeed(options.forkKind ?? (options.uiOnly ? "ui" : "code")),
293 installUi: (version) =>
294 Effect.sync(() => {
295 uiInstalls.push(version);
296 uiVersion = version;
297 return true;
298 }),
299 activateCode: () =>
300 Effect.sync(() => {
301 installSteps.push("activateCode");
302 return true;
303 }),
304 }),
305 ),
306 Layer.provide(
307 Layer.succeed(ElectronApp.ElectronApp, {
308 ...ElectronApp.make,
309 relaunch: () =>
310 Effect.sync(() => {
311 installSteps.push("relaunch");
312 }),
313 quit: Effect.sync(() => {
314 installSteps.push("quit");
315 }),
316 }),
317 ),
318 Layer.provide(
319 Layer.succeed(
320 HttpClient.HttpClient,
321 HttpClient.make((request) =>
322 Effect.succeed(
323 HttpClientResponse.fromWeb(
324 request,
325 new Response(
326 JSON.stringify(options.releaseManifest?.() ?? forkReleaseFixture("1.2.4")),
327 ),
328 ),
329 ),
330 ),
331 ),
332 ),
253 Layer.provide(layerFileSystem),333 Layer.provide(layerFileSystem),
254 Layer.provideMerge(layerUpdater),334 Layer.provideMerge(layerUpdater),
255 Layer.provideMerge(layerWindow),335 Layer.provideMerge(layerWindow),
...@@ -273,12 +353,14 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -273,12 +353,14 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
273 checkCount: () => checkCount,353 checkCount: () => checkCount,
274 quitAndInstalls: () => quitAndInstallCount,354 quitAndInstalls: () => quitAndInstallCount,
275 installSteps,355 installSteps,
356 uiInstalls,
276 updateRestartMarkers,357 updateRestartMarkers,
277 installRecords,358 installRecords,
278 /** The record the test started with, until the app removes it. */359 /** The record the test started with, until the app removes it. */
279 previousInstallRecord: () => previousInstallRecord,360 previousInstallRecord: () => previousInstallRecord,
280 downloadCount: () => downloadCount,361 downloadCount: () => downloadCount,
281 feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls,362 feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls,
363 channels,
282 fullChangelog: () => fullChangelog,364 fullChangelog: () => fullChangelog,
283 listenerCount: () =>365 listenerCount: () =>
284 Array.from(listeners.values()).reduce(366 Array.from(listeners.values()).reduce(
...@@ -293,3 +375,24 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -293,3 +375,24 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
293 },375 },
294 };376 };
295}377}
378
379export function forkReleaseFixture(version: string): typeof ForkReleaseManifest.Type {
380 const artifact = { file: "fixture.tar.gz", size: 1, sha256: "a".repeat(64) };
381 return {
382 version,
383 ui: artifact,
384 assets: artifact,
385 server: { ...artifact, version, runtime: "b".repeat(64) },
386 desktop: {
387 "darwin-x64": {
388 version,
389 ui: artifact,
390 assets: artifact,
391 shell: "c".repeat(64),
392 runtime: "d".repeat(64),
393 code: artifact,
394 app: { ...artifact, version },
395 },
396 },
397 };
398}
apps/desktop/src/window/DesktopApplicationMenu.test.ts+39
...@@ -13,6 +13,7 @@ import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts";...@@ -13,6 +13,7 @@ import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts";
13import * as DesktopConfig from "../app/DesktopConfig.ts";13import * as DesktopConfig from "../app/DesktopConfig.ts";
14import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";14import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
15import * as DesktopUpdates from "../updates/DesktopUpdates.ts";15import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
16import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts";
16import * as DesktopWindow from "./DesktopWindow.ts";17import * as DesktopWindow from "./DesktopWindow.ts";
1718
18const environmentInput = {19const environmentInput = {
...@@ -106,6 +107,12 @@ const configureMenu = (...@@ -106,6 +107,12 @@ const configureMenu = (
106 selectedAction: Deferred.Deferred<string>,107 selectedAction: Deferred.Deferred<string>,
107 applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>,108 applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>,
108 environment: Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> = {},109 environment: Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> = {},
110 localUi: DesktopLocalUi.DesktopLocalUi["Service"] = {
111 assetDirectories: Effect.die("unexpected asset read"),
112 currentVersion: Effect.succeed("1.0.0"),
113 apply: () => Effect.die("unexpected local UI update"),
114 reset: Effect.die("unexpected local UI reset"),
115 },
109) =>116) =>
110 Effect.gen(function* () {117 Effect.gen(function* () {
111 const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu;118 const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
...@@ -116,6 +123,7 @@ const configureMenu = (...@@ -116,6 +123,7 @@ const configureMenu = (
116 Layer.provideMerge(layerElectronMenu(applicationMenuTemplate)),123 Layer.provideMerge(layerElectronMenu(applicationMenuTemplate)),
117 Layer.provideMerge(layerDesktopWindow(selectedAction)),124 Layer.provideMerge(layerDesktopWindow(selectedAction)),
118 Layer.provideMerge(layerDesktopUpdates),125 Layer.provideMerge(layerDesktopUpdates),
126 Layer.provideMerge(Layer.succeed(DesktopLocalUi.DesktopLocalUi, localUi)),
119 Layer.provideMerge(layerElectronDialog),127 Layer.provideMerge(layerElectronDialog),
120 Layer.provideMerge(layerElectronApp),128 Layer.provideMerge(layerElectronApp),
121 Layer.provideMerge(129 Layer.provideMerge(
...@@ -128,6 +136,37 @@ const configureMenu = (...@@ -128,6 +136,37 @@ const configureMenu = (
128 );136 );
129137
130describe("DesktopApplicationMenu", () => {138describe("DesktopApplicationMenu", () => {
139 it.effect.each([["Use Bundled UI", "reset"]] as const)(
140 "runs %s without needing a working renderer",
141 ([label, operation]) =>
142 Effect.gen(function* () {
143 const selectedAction = yield* Deferred.make<string>();
144 const templateReady =
145 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
146 const steps: string[] = [];
147 yield* configureMenu(
148 selectedAction,
149 templateReady,
150 {},
151 {
152 assetDirectories: Effect.die("unexpected asset read"),
153 currentVersion: Effect.succeed("1.0.0"),
154 apply: () => Effect.die("unexpected local UI update"),
155 reset: Effect.sync(() => {
156 steps.push("reset");
157 }),
158 },
159 );
160 const template = yield* Deferred.await(templateReady);
161 const view = template.find((item) => item.label === "View");
162 if (!Array.isArray(view?.submenu)) throw new Error("Missing View menu");
163 const item = view.submenu.find((entry) => entry.label === label);
164 if (!item?.click) throw new Error(`Missing ${label} menu action`);
165 item.click({} as Electron.MenuItem, undefined, {} as KeyboardEvent);
166 assert.equal(yield* Deferred.await(selectedAction), "main-forceReload");
167 assert.deepEqual(steps, [operation]);
168 }),
169 );
131 it.effect("keeps display branding in the macOS application menu", () =>170 it.effect("keeps display branding in the macOS application menu", () =>
132 Effect.gen(function* () {171 Effect.gen(function* () {
133 const selectedAction = yield* Deferred.make<string>();172 const selectedAction = yield* Deferred.make<string>();
apps/desktop/src/window/DesktopApplicationMenu.ts+31-1
...@@ -11,6 +11,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts";...@@ -11,6 +11,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts";
11import * as ElectronMenu from "../electron/ElectronMenu.ts";11import * as ElectronMenu from "../electron/ElectronMenu.ts";
12import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";12import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
13import * as DesktopUpdates from "../updates/DesktopUpdates.ts";13import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
14import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts";
14import * as DesktopWindow from "./DesktopWindow.ts";15import * as DesktopWindow from "./DesktopWindow.ts";
1516
16export class DesktopApplicationMenuActionError extends Schema.TaggedError<DesktopApplicationMenuActionError>()(17export class DesktopApplicationMenuActionError extends Schema.TaggedError<DesktopApplicationMenuActionError>()(
...@@ -34,6 +35,7 @@ export class DesktopApplicationMenu extends Context.Service<...@@ -34,6 +35,7 @@ export class DesktopApplicationMenu extends Context.Service<
3435
35type DesktopApplicationMenuRuntimeServices =36type DesktopApplicationMenuRuntimeServices =
36 | DesktopUpdates.DesktopUpdates37 | DesktopUpdates.DesktopUpdates
38 | DesktopLocalUi.DesktopLocalUi
37 | DesktopWindow.DesktopWindow39 | DesktopWindow.DesktopWindow
38 | ElectronDialog.ElectronDialog;40 | ElectronDialog.ElectronDialog;
3941
...@@ -74,7 +76,7 @@ const checkForUpdatesFromMenu = Effect.gen(function* () {...@@ -74,7 +76,7 @@ const checkForUpdatesFromMenu = Effect.gen(function* () {
74 yield* electronDialog.showMessageBox({76 yield* electronDialog.showMessageBox({
75 type: "info",77 type: "info",
76 title: "You're up to date!",78 title: "You're up to date!",
77 message: `The Knife ${updateState.currentVersion} is currently the newest version available.`,79 message: `The Knife ${updateState.uiVersion ?? updateState.currentVersion} is currently the newest version available.`,
78 buttons: ["OK"],80 buttons: ["OK"],
79 });81 });
80 } else if (updateState.status === "error") {82 } else if (updateState.status === "error") {
...@@ -160,6 +162,29 @@ export const make = Effect.gen(function* () {...@@ -160,6 +162,29 @@ export const make = Effect.gen(function* () {
160 const mainContentsClick = (command: DesktopWindow.MainWindowContentsCommand) => () => {162 const mainContentsClick = (command: DesktopWindow.MainWindowContentsCommand) => () => {
161 runMenuEffect(command, runMainContentsCommand(command));163 runMenuEffect(command, runMainContentsCommand(command));
162 };164 };
165 const localUiClick = () => () => {
166 runMenuEffect(
167 "reset-ui",
168 Effect.gen(function* () {
169 const localUi = yield* DesktopLocalUi.DesktopLocalUi;
170 yield* localUi.reset;
171 yield* runMainContentsCommand("forceReload");
172 }).pipe(
173 Effect.catchTags({
174 DesktopLocalUiError: (error) =>
175 Effect.gen(function* () {
176 const dialog = yield* ElectronDialog.ElectronDialog;
177 yield* dialog.showMessageBox({
178 type: "error",
179 title: "Could not update UI",
180 message: error.message,
181 buttons: ["OK"],
182 });
183 }),
184 }),
185 ),
186 );
187 };
163 const template: Electron.MenuItemConstructorOptions[] = [];188 const template: Electron.MenuItemConstructorOptions[] = [];
164189
165 if (environment.platform === "darwin") {190 if (environment.platform === "darwin") {
...@@ -244,6 +269,11 @@ export const make = Effect.gen(function* () {...@@ -244,6 +269,11 @@ export const make = Effect.gen(function* () {
244 the main window (see DesktopWindow.zoomMain).269 the main window (see DesktopWindow.zoomMain).
245 */270 */
246 { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") },271 { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") },
272 {
273 label: "Use Bundled UI",
274 enabled: !environment.isDevelopment,
275 click: localUiClick(),
276 },
247 {277 {
248 label: "Force Reload",278 label: "Force Reload",
249 accelerator: "Shift+CmdOrCtrl+R",279 accelerator: "Shift+CmdOrCtrl+R",
apps/desktop/vite.config.ts+3-1
...@@ -95,7 +95,9 @@ export default defineConfig({...@@ -95,7 +95,9 @@ export default defineConfig({
95 entry: ["src/boot.ts", "src/compileCache.ts"],95 entry: ["src/boot.ts", "src/compileCache.ts"],
96 clean: false,96 clean: false,
97 deps: {97 deps: {
98 neverBundle: (id) => id === "./main.cjs" || id === "./compileCache.cjs",98 alwaysBundle: (id) => !id.startsWith("node:") && id !== "electron",
99 neverBundle: (id) =>
100 id === "electron" || id === "./main.cjs" || id === "./compileCache.cjs",
99 },101 },
100 },102 },
101 {103 {
apps/server/src/binCli.ts+2-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "./version.ts";
1import * as NodeRuntime from "@effect/platform-node/NodeRuntime";2import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -6,7 +7,6 @@ import { Argument, Command } from "effect/cli";...@@ -6,7 +7,6 @@ import { Argument, Command } from "effect/cli";
6import * as CliError from "effect/cli/CliError";7import * as CliError from "effect/cli/CliError";
78
8import * as NetService from "@t3tools/shared/Net";9import * as NetService from "@t3tools/shared/Net";
9import packageJson from "../package.json" with { type: "json" };
10import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts";10import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts";
11import { authCommand } from "./cli/auth.ts";11import { authCommand } from "./cli/auth.ts";
12import { appCommand } from "./cli/app.ts";12import { appCommand } from "./cli/app.ts";
...@@ -92,7 +92,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>...@@ -92,7 +92,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
92export const cli = makeCli();92export const cli = makeCli();
9393
94export function runCli() {94export function runCli() {
95 Command.run(cli, { version: packageJson.version }).pipe(95 Command.run(cli, { version: SERVER_VERSION }).pipe(
96 Effect.scoped,96 Effect.scoped,
97 Effect.provide(layerCliRuntime),97 Effect.provide(layerCliRuntime),
98 NodeRuntime.runMain,98 NodeRuntime.runMain,
apps/server/src/cli/invocation.ts+2-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";3import * as FileSystem from "effect/FileSystem";
3import * as Option from "effect/Option";4import * as Option from "effect/Option";
...@@ -8,8 +9,6 @@ import type { ServerInstallation } from "@t3tools/contracts";...@@ -8,8 +9,6 @@ import type { ServerInstallation } from "@t3tools/contracts";
8import * as HostProcess from "@t3tools/shared/HostProcess";9import * as HostProcess from "@t3tools/shared/HostProcess";
9import { isCommandAvailable } from "@t3tools/shared/shell";10import { isCommandAvailable } from "@t3tools/shared/shell";
1011
11import packageJson from "../../package.json" with { type: "json" };
12
13export type CliRunner = "npx" | "pnpm dlx" | "bunx";12export type CliRunner = "npx" | "pnpm dlx" | "bunx";
1413
15/**14/**
...@@ -139,7 +138,7 @@ export const resolveCliCommand = (subcommand: string) =>...@@ -139,7 +138,7 @@ export const resolveCliCommand = (subcommand: string) =>
139 formatCliCommand({138 formatCliCommand({
140 subcommand,139 subcommand,
141 entryPath: processArguments[1] ?? "",140 entryPath: processArguments[1] ?? "",
142 version: packageJson.version,141 version: SERVER_VERSION,
143 }),142 }),
144 );143 );
145144
apps/server/src/cli/service.ts+10-12
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
1import * as HostProcess from "@t3tools/shared/HostProcess";2import * as HostProcess from "@t3tools/shared/HostProcess";
2import * as Console from "effect/Console";3import * as Console from "effect/Console";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -6,7 +7,6 @@ import * as Terminal from "effect/Terminal";...@@ -6,7 +7,6 @@ import * as Terminal from "effect/Terminal";
6import { Command, Flag, GlobalFlag, Prompt } from "effect/cli";7import { Command, Flag, GlobalFlag, Prompt } from "effect/cli";
7import { FetchHttpClient } from "effect/http";8import { FetchHttpClient } from "effect/http";
89
9import packageJson from "../../package.json" with { type: "json" };
10import * as BootService from "../cloud/bootService.ts";10import * as BootService from "../cloud/bootService.ts";
11import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts";11import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts";
12import type * as ServerConfig from "../config.ts";12import type * as ServerConfig from "../config.ts";
...@@ -17,7 +17,7 @@ export const layer = (config: ServerConfig.ServerConfig["Service"]) =>...@@ -17,7 +17,7 @@ export const layer = (config: ServerConfig.ServerConfig["Service"]) =>
17 BootService.layer({17 BootService.layer({
18 baseDir: config.baseDir,18 baseDir: config.baseDir,
19 logsDir: config.logsDir,19 logsDir: config.logsDir,
20 cliVersion: packageJson.version,20 cliVersion: SERVER_VERSION,
21 }).pipe(21 }).pipe(
22 Layer.provide(ProcessRunner.layer),22 Layer.provide(ProcessRunner.layer),
23 // Archive-distributed versions download the release archive here.23 // Archive-distributed versions download the release archive here.
...@@ -48,11 +48,11 @@ export const reconcileService = Effect.fn("cli.service.reconcile")(function* (op...@@ -48,11 +48,11 @@ export const reconcileService = Effect.fn("cli.service.reconcile")(function* (op
48 if (48 if (
49 status.installedVersion !== undefined &&49 status.installedVersion !== undefined &&
50 options?.allowDowngrade !== true &&50 options?.allowDowngrade !== true &&
51 compareExactServiceVersions(packageJson.version, status.installedVersion) < 051 compareExactServiceVersions(SERVER_VERSION, status.installedVersion) < 0
52 ) {52 ) {
53 return yield* new BootService.BootServiceDowngradeRefusedError({53 return yield* new BootService.BootServiceDowngradeRefusedError({
54 installedVersion: status.installedVersion,54 installedVersion: status.installedVersion,
55 targetVersion: packageJson.version,55 targetVersion: SERVER_VERSION,
56 });56 });
57 }57 }
58 const plan = yield* service.install(options);58 const plan = yield* service.install(options);
...@@ -126,13 +126,11 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip...@@ -126,13 +126,11 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip
126 Effect.gen(function* () {126 Effect.gen(function* () {
127 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });127 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
128 if (!result.changed) {128 if (!result.changed) {
129 yield* Console.log(129 yield* Console.log(`T3 Code service is already installed with t3@${SERVER_VERSION}.`);
130 `T3 Code service is already installed with t3@${packageJson.version}.`,
131 );
132 return;130 return;
133 }131 }
134 yield* Console.log(132 yield* Console.log(
135 `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`,133 `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${SERVER_VERSION}.\nLogs: ${result.plan.logPath}`,
136 );134 );
137 }),135 }),
138 ),136 ),
...@@ -153,11 +151,11 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(...@@ -153,11 +151,11 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(
153 );151 );
154 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });152 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
155 if (!result.changed) {153 if (!result.changed) {
156 yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`);154 yield* Console.log(`T3 Code service is already using t3@${SERVER_VERSION}.`);
157 return;155 return;
158 }156 }
159 yield* Console.log(157 yield* Console.log(
160 `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`,158 `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${SERVER_VERSION}.\nLogs: ${result.plan.logPath}`,
161 );159 );
162 }),160 }),
163 ),161 ),
...@@ -208,7 +206,7 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe(...@@ -208,7 +206,7 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe(
208 flags,206 flags,
209 Effect.gen(function* () {207 Effect.gen(function* () {
210 const service = yield* BootService.BootService;208 const service = yield* BootService.BootService;
211 yield* Console.log(formatServiceStatus(yield* service.status, packageJson.version));209 yield* Console.log(formatServiceStatus(yield* service.status, SERVER_VERSION));
212 }),210 }),
213 ),211 ),
214 ),212 ),
...@@ -231,7 +229,7 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () {...@@ -231,7 +229,7 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () {
231 if (229 if (
232 installed &&230 installed &&
233 status.installedVersion !== undefined &&231 status.installedVersion !== undefined &&
234 compareExactServiceVersions(status.installedVersion, packageJson.version) > 0232 compareExactServiceVersions(status.installedVersion, SERVER_VERSION) > 0
235 ) {233 ) {
236 yield* Console.log(234 yield* Console.log(
237 `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`,235 `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`,
apps/server/src/cli/triage.ts+2-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
1/**2/**
2 * `t3 triage` - hand a misbehaving install to the user's own coding agent.3 * `t3 triage` - hand a misbehaving install to the user's own coding agent.
3 *4 *
...@@ -26,7 +27,6 @@ import * as Path from "effect/Path";...@@ -26,7 +27,6 @@ import * as Path from "effect/Path";
26import * as Schema from "effect/Schema";27import * as Schema from "effect/Schema";
27import { Command, Flag } from "effect/cli";28import { Command, Flag } from "effect/cli";
2829
29import packageJson from "../../package.json" with { type: "json" };
30import * as BootService from "../cloud/bootService.ts";30import * as BootService from "../cloud/bootService.ts";
31import * as ServerConfig from "../config.ts";31import * as ServerConfig from "../config.ts";
32import { resolveBaseDir } from "../os-jank.ts";32import { resolveBaseDir } from "../os-jank.ts";
...@@ -195,7 +195,7 @@ export const triageCommand = Command.make("triage", {...@@ -195,7 +195,7 @@ export const triageCommand = Command.make("triage", {
195 contextFilePath,195 contextFilePath,
196 buildTriageContext({196 buildTriageContext({
197 generatedAt: DateTime.formatIso(now),197 generatedAt: DateTime.formatIso(now),
198 cliVersion: packageJson.version,198 cliVersion: SERVER_VERSION,
199 os: `${yield* HostProcess.Platform} ${yield* HostProcess.Architecture} (${NodeOS.release()})`,199 os: `${yield* HostProcess.Platform} ${yield* HostProcess.Architecture} (${NodeOS.release()})`,
200 nodeVersion: process.version,200 nodeVersion: process.version,
201 launchedAs: yield* resolveCliCommand("triage"),201 launchedAs: yield* resolveCliCommand("triage"),
apps/server/src/cli/update.ts+2-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
1import * as HostProcess from "@t3tools/shared/HostProcess";2import * as HostProcess from "@t3tools/shared/HostProcess";
2import {3import {
3 CLI_RELEASE_BASE_URL_ENV,4 CLI_RELEASE_BASE_URL_ENV,
...@@ -18,7 +19,6 @@ import * as Schema from "effect/Schema";...@@ -18,7 +19,6 @@ import * as Schema from "effect/Schema";
18import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/cli";19import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/cli";
19import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";20import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
2021
21import packageJson from "../../package.json" with { type: "json" };
22import * as BootService from "../cloud/bootService.ts";22import * as BootService from "../cloud/bootService.ts";
23import {23import {
24 ensurePinnedRuntimeInstalled,24 ensurePinnedRuntimeInstalled,
...@@ -339,7 +339,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {...@@ -339,7 +339,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
339 const httpClient = yield* HttpClient.HttpClient;339 const httpClient = yield* HttpClient.HttpClient;
340 const service = yield* BootService.BootService;340 const service = yield* BootService.BootService;
341341
342 const currentVersion = packageJson.version;342 const currentVersion = SERVER_VERSION;
343 const channel = input.channel ?? cliReleaseChannelOf(currentVersion);343 const channel = input.channel ?? cliReleaseChannelOf(currentVersion);
344 if (input.requestedVersion !== undefined && !isExactServiceVersion(input.requestedVersion)) {344 if (input.requestedVersion !== undefined && !isExactServiceVersion(input.requestedVersion)) {
345 return yield* new CliUpdateError({345 return yield* new CliUpdateError({
apps/server/src/cloud/serviceLauncherClient.ts+2-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
1import type { ServerSelfUpdateOutcome } from "@t3tools/contracts";2import type { ServerSelfUpdateOutcome } from "@t3tools/contracts";
2import * as HostProcess from "@t3tools/shared/HostProcess";3import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Context from "effect/Context";4import * as Context from "effect/Context";
...@@ -5,7 +6,6 @@ import * as Effect from "effect/Effect";...@@ -5,7 +6,6 @@ import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
6import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
78
8import packageJson from "../../package.json" with { type: "json" };
9import {9import {
10 decodeServiceLauncherContext,10 decodeServiceLauncherContext,
11 decodeServiceLauncherParentMessage,11 decodeServiceLauncherParentMessage,
...@@ -115,7 +115,7 @@ const resolveStartup = Effect.fn("cloud.service_launcher_client.resolve_startup"...@@ -115,7 +115,7 @@ const resolveStartup = Effect.fn("cloud.service_launcher_client.resolve_startup"
115 function* (options?: { readonly currentVersion?: string }) {115 function* (options?: { readonly currentVersion?: string }) {
116 const host = yield* ServiceLauncherHostProcess;116 const host = yield* ServiceLauncherHostProcess;
117 const environment = yield* HostProcess.Environment;117 const environment = yield* HostProcess.Environment;
118 const currentVersion = options?.currentVersion ?? packageJson.version;118 const currentVersion = options?.currentVersion ?? SERVER_VERSION;
119 const rawContext = environment[SERVICE_LAUNCHER_CONTEXT_ENV];119 const rawContext = environment[SERVICE_LAUNCHER_CONTEXT_ENV];
120 const context = rawContext === undefined ? undefined : decodeServiceLauncherContext(rawContext);120 const context = rawContext === undefined ? undefined : decodeServiceLauncherContext(rawContext);
121121
apps/server/src/cloud/servicePreflight.ts+2-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import packageJson from "../../package.json" with { type: "json" };1import { SERVER_VERSION } from "../version.ts";
2import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";2import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
33
4export type ServicePreflightResult =4export type ServicePreflightResult =
...@@ -19,7 +19,7 @@ export function runServicePreflight(input: {...@@ -19,7 +19,7 @@ export function runServicePreflight(input: {
19 readonly launcherProtocol: number;19 readonly launcherProtocol: number;
20 readonly version?: string;20 readonly version?: string;
21}): ServicePreflightResult {21}): ServicePreflightResult {
22 const version = input.version ?? packageJson.version;22 const version = input.version ?? SERVER_VERSION;
23 if (input.launcherProtocol !== SERVICE_LAUNCHER_PROTOCOL) {23 if (input.launcherProtocol !== SERVICE_LAUNCHER_PROTOCOL) {
24 return {24 return {
25 status: "blocked",25 status: "blocked",
apps/server/src/environment/ServerEnvironment.ts+2-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
1import {2import {
2 EnvironmentId,3 EnvironmentId,
3 ORCHESTRATION_PROTOCOL_VERSION,4 ORCHESTRATION_PROTOCOL_VERSION,
...@@ -13,7 +14,6 @@ import * as Layer from "effect/Layer";...@@ -13,7 +14,6 @@ import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";14import * as Path from "effect/Path";
14import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
1516
16import packageJson from "../../package.json" with { type: "json" };
17import * as ServerSecretStore from "../auth/ServerSecretStore.ts";17import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
18import { resolveServerInstallation } from "../cli/invocation.ts";18import { resolveServerInstallation } from "../cli/invocation.ts";
19import { readAgentActivityPublishingActive } from "../cloud/config.ts";19import { readAgentActivityPublishingActive } from "../cloud/config.ts";
...@@ -213,7 +213,7 @@ export const make = Effect.gen(function* () {...@@ -213,7 +213,7 @@ export const make = Effect.gen(function* () {
213 arch: platformArch(hostArchitecture),213 arch: platformArch(hostArchitecture),
214 ...(machine === null ? {} : { machine }),214 ...(machine === null ? {} : { machine }),
215 },215 },
216 serverVersion: packageJson.version,216 serverVersion: SERVER_VERSION,
217 orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION,217 orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION,
218 capabilities: {218 capabilities: {
219 repositoryIdentity: true,219 repositoryIdentity: true,
apps/server/src/mcp/McpHttpServer.ts+2-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
1import * as Cause from "effect/Cause";2import * as Cause from "effect/Cause";
2import * as Clock from "effect/Clock";3import * as Clock from "effect/Clock";
3import * as Context from "effect/Context";4import * as Context from "effect/Context";
...@@ -15,7 +16,6 @@ import { AiError, McpProtocol, McpSchema, McpServer, Tool, type Toolkit } from "...@@ -15,7 +16,6 @@ import { AiError, McpProtocol, McpSchema, McpServer, Tool, type Toolkit } from "
15import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";16import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
16import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts";17import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts";
1718
18import packageJson from "../../package.json" with { type: "json" };
19import * as ServerConfig from "../config.ts";19import * as ServerConfig from "../config.ts";
20import * as DeviceService from "../device/DeviceService.ts";20import * as DeviceService from "../device/DeviceService.ts";
21import * as HtmlRender from "../htmlRender/HtmlRender.ts";21import * as HtmlRender from "../htmlRender/HtmlRender.ts";
...@@ -860,7 +860,7 @@ export const layerDeviceToolkit = Layer.mergeAll(...@@ -860,7 +860,7 @@ export const layerDeviceToolkit = Layer.mergeAll(
860860
861export const layerMcpTransport = McpServer.layerHttp({861export const layerMcpTransport = McpServer.layerHttp({
862 name: "T3 Code",862 name: "T3 Code",
863 version: packageJson.version,863 version: SERVER_VERSION,
864 path: "/mcp",864 path: "/mcp",
865 protocols: [McpProtocol.v2025_06_18],865 protocols: [McpProtocol.v2025_06_18],
866 allowSessionTermination: true,866 allowSessionTermination: true,
apps/server/src/provider/CodexProvider.ts+2-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
1import * as DateTime from "effect/DateTime";2import * as DateTime from "effect/DateTime";
2import * as Duration from "effect/Duration";3import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -51,7 +52,6 @@ import {...@@ -51,7 +52,6 @@ import {
51 type CodexResetCreditsSummary,52 type CodexResetCreditsSummary,
52} from "./codexUsageLimits.ts";53} from "./codexUsageLimits.ts";
53import * as HostProcess from "@t3tools/shared/HostProcess";54import * as HostProcess from "@t3tools/shared/HostProcess";
54import packageJson from "../../package.json" with { type: "json" };
55const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);55const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);
56const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000;56const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000;
5757
...@@ -410,7 +410,7 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {...@@ -410,7 +410,7 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
410 clientInfo: {410 clientInfo: {
411 name: CODEX_CLIENT_NAME,411 name: CODEX_CLIENT_NAME,
412 title: CODEX_CLIENT_NAME,412 title: CODEX_CLIENT_NAME,
413 version: packageJson.version,413 version: SERVER_VERSION,
414 },414 },
415 capabilities: {415 capabilities: {
416 experimentalApi: true,416 experimentalApi: true,
apps/server/src/provider/providerCompatibility.ts+2-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
1import {2import {
2 TrimmedNonEmptyString,3 TrimmedNonEmptyString,
3 ServerProviderCompatibilityStatus,4 ServerProviderCompatibilityStatus,
...@@ -7,7 +8,6 @@ import {...@@ -7,7 +8,6 @@ import {
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import { satisfiesSemverRange } from "@t3tools/shared/semver";9import { satisfiesSemverRange } from "@t3tools/shared/semver";
9import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
10import packageJson from "../../package.json" with { type: "json" };
1111
12// Deliberately uses the shared CLI gate syntax: comparator groups joined by ||.12// Deliberately uses the shared CLI gate syntax: comparator groups joined by ||.
13// Prereleases and unrecognized release tags remain unknown.13// Prereleases and unrecognized release tags remain unknown.
...@@ -60,7 +60,7 @@ export function resolveProviderCompatibility(...@@ -60,7 +60,7 @@ export function resolveProviderCompatibility(
60 policies: ReadonlyArray<ProviderCompatibilityPolicy> | undefined,60 policies: ReadonlyArray<ProviderCompatibilityPolicy> | undefined,
61 driver: ProviderDriverKind,61 driver: ProviderDriverKind,
62 version: string | null,62 version: string | null,
63 t3CodeVersion = packageJson.version,63 t3CodeVersion = SERVER_VERSION,
64): ServerProviderCompatibilityAdvisory | undefined {64): ServerProviderCompatibilityAdvisory | undefined {
65 const policy = policies?.find(65 const policy = policies?.find(
66 (entry) => entry.driver === driver && satisfiesSemverRange(t3CodeVersion, entry.t3CodeRange),66 (entry) => entry.driver === driver && satisfiesSemverRange(t3CodeVersion, entry.t3CodeRange),
apps/server/src/serverRuntimeState.ts+2-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "./version.ts";
1import * as DateTime from "effect/DateTime";2import * as DateTime from "effect/DateTime";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
...@@ -5,7 +6,6 @@ import * as Option from "effect/Option";...@@ -5,7 +6,6 @@ import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
67
7import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";8import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
8import packageJson from "../package.json" with { type: "json" };
9import type * as ServerConfig from "./config.ts";9import type * as ServerConfig from "./config.ts";
10import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts";10import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts";
1111
...@@ -76,7 +76,7 @@ export const makePersistedServerRuntimeState = (input: {...@@ -76,7 +76,7 @@ export const makePersistedServerRuntimeState = (input: {
76 ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}),76 ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}),
77 startedAt: DateTime.formatIso(now),77 startedAt: DateTime.formatIso(now),
78 ...(input.serviceManaged ? { serviceManaged: true } : {}),78 ...(input.serviceManaged ? { serviceManaged: true } : {}),
79 serverVersion: packageJson.version,79 serverVersion: SERVER_VERSION,
80 }));80 }));
8181
82export const persistServerRuntimeState = (input: {82export const persistServerRuntimeState = (input: {
apps/server/src/telemetry/AnalyticsService.ts+3-5
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
1/**2/**
2 * Anonymous PostHog telemetry service.3 * Anonymous PostHog telemetry service.
3 *4 *
...@@ -27,7 +28,6 @@ import * as HttpClient from "effect/http/HttpClient";...@@ -27,7 +28,6 @@ import * as HttpClient from "effect/http/HttpClient";
27import * as HttpClientRequest from "effect/http/HttpClientRequest";28import * as HttpClientRequest from "effect/http/HttpClientRequest";
28import * as HttpClientResponse from "effect/http/HttpClientResponse";29import * as HttpClientResponse from "effect/http/HttpClientResponse";
2930
30import packageJson from "../../package.json" with { type: "json" };
31import * as ServerConfig from "../config.ts";31import * as ServerConfig from "../config.ts";
32import { getTelemetryIdentifier } from "./Identify.ts";32import { getTelemetryIdentifier } from "./Identify.ts";
3333
...@@ -188,12 +188,12 @@ export const make = Effect.gen(function* () {...@@ -188,12 +188,12 @@ export const make = Effect.gen(function* () {
188 platform: hostPlatform,188 platform: hostPlatform,
189 wsl: Option.getOrUndefined(telemetryConfig.wslDistroName),189 wsl: Option.getOrUndefined(telemetryConfig.wslDistroName),
190 arch: hostArchitecture,190 arch: hostArchitecture,
191 t3CodeVersion: packageJson.version,191 t3CodeVersion: SERVER_VERSION,
192 clientType,192 clientType,
193 serverOs: serverOsFromNodePlatform(hostPlatform),193 serverOs: serverOsFromNodePlatform(hostPlatform),
194 serverArch: hostArchitecture,194 serverArch: hostArchitecture,
195 serverWslDistro: Option.getOrUndefined(telemetryConfig.wslDistroName),195 serverWslDistro: Option.getOrUndefined(telemetryConfig.wslDistroName),
196 serverAppVersion: packageJson.version,196 serverAppVersion: SERVER_VERSION,
197 serverMode: serverConfig.mode,197 serverMode: serverConfig.mode,
198 },198 },
199 timestamp: event.capturedAt,199 timestamp: event.capturedAt,
...@@ -286,5 +286,3 @@ export const make = Effect.gen(function* () {...@@ -286,5 +286,3 @@ export const make = Effect.gen(function* () {
286});286});
287287
288export const layer = Layer.effect(AnalyticsService, make);288export const layer = Layer.effect(AnalyticsService, make);
289
290const layerTest = AnalyticsService.layerTest;
apps/server/src/version.test.ts created+37
...@@ -0,0 +1,37 @@
1// @effect-diagnostics nodeBuiltinImport:off - Exercises the actual CLI in isolated child processes.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFSP from "node:fs/promises";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6import * as NodeURL from "node:url";
7import * as NodeUtil from "node:util";
8import { afterAll, beforeAll, expect, it } from "vite-plus/test";
9
10import packageJson from "../package.json" with { type: "json" };
11
12const run = NodeUtil.promisify(NodeChildProcess.execFile);
13let home: string;
14
15beforeAll(async () => {
16 home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "knife-version-"));
17});
18
19afterAll(async () => {
20 await NodeFSP.rm(home, { recursive: true, force: true });
21});
22
23it.each([
24 [undefined, packageJson.version],
25 ["0.0.1234567890", "0.0.1234567890"],
26 ["9.0.1", packageJson.version],
27])("reports the running server version for override %s", async (override, expected) => {
28 const env: NodeJS.ProcessEnv = { ...process.env, T3CODE_HOME: home };
29 delete env.T3CODE_VERSION_OVERRIDE;
30 if (override !== undefined) env.T3CODE_VERSION_OVERRIDE = override;
31 const { stdout } = await run(
32 process.execPath,
33 [NodeURL.fileURLToPath(new URL("./bin.ts", import.meta.url)), "--version"],
34 { env },
35 );
36 expect(stdout.trim()).toBe(`t3 v${expected}`);
37});
apps/server/src/version.ts created+6
...@@ -0,0 +1,6 @@
1import packageJson from "../package.json" with { type: "json" };
2
3const override = process.env.T3CODE_VERSION_OVERRIDE;
4
5export const SERVER_VERSION =
6 override && /^0\.0\.\d+$/.test(override) ? override : packageJson.version;
apps/web/src/components/AutomaticUiRefresh.tsx created+39
...@@ -0,0 +1,39 @@
1import { useEffect, useLayoutEffect, useRef } from "react";
2import { useDesktopUpdateState } from "~/state/desktopUpdate";
3import { prepareUiRefresh, startAutomaticUiRefresh } from "~/uiRefresh";
4import { APP_VERSION } from "~/branding";
5import { installLocalUpdate } from "./desktopUpdate.actions";
6
7export function AutomaticUiRefresh() {
8 const state = useDesktopUpdateState();
9 const latest = useRef(state);
10 const controller = useRef<ReturnType<typeof startAutomaticUiRefresh> | null>(null);
11
12 useEffect(() => {
13 const bridge = window.desktopBridge;
14 if (!bridge) return;
15 const refresh = startAutomaticUiRefresh({
16 pending: () =>
17 (latest.current?.status === "downloaded" && latest.current.installKind === "ui") ||
18 Boolean(latest.current?.uiVersion && latest.current.uiVersion !== APP_VERSION),
19 apply: async () => {
20 if (latest.current?.uiVersion && latest.current.uiVersion !== APP_VERSION) {
21 if (prepareUiRefresh()) window.location.reload();
22 return;
23 }
24 await installLocalUpdate(bridge, undefined, { automatic: true });
25 },
26 });
27 controller.current = refresh;
28 return () => {
29 controller.current = null;
30 refresh.dispose();
31 };
32 }, []);
33
34 useLayoutEffect(() => {
35 latest.current = state;
36 controller.current?.check();
37 }, [state]);
38 return null;
39}
apps/web/src/components/ComposerPromptEditor.test.tsx+56-4
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1// @vitest-environment jsdom1// @vitest-environment jsdom
22
3import { act, createRef, useLayoutEffect, useState } from "react";3import { act, createRef, StrictMode, useEffect, useLayoutEffect, useState } from "react";
4import { createRoot, type Root } from "react-dom/client";4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
66
...@@ -17,6 +17,8 @@ import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";...@@ -17,6 +17,8 @@ import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
17import { ComposerContextActionsContext } from "./composerContextPresentation";17import { ComposerContextActionsContext } from "./composerContextPresentation";
1818
19import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";19import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
20import { EditorView } from "@codemirror/view";
21import { PREPARE_UI_REFRESH_EVENT } from "~/uiRefresh";
2022
21let root: Root;23let root: Root;
22let container: HTMLDivElement;24let container: HTMLDivElement;
...@@ -27,16 +29,28 @@ const openMention = vi.fn();...@@ -27,16 +29,28 @@ const openMention = vi.fn();
27const commandKey = vi.fn((_key: string, _event: KeyboardEvent) => false);29const commandKey = vi.fn((_key: string, _event: KeyboardEvent) => false);
28const changes: Array<{ value: string; suppressed: boolean }> = [];30const changes: Array<{ value: string; suppressed: boolean }> = [];
2931
30function ComposerProbe() {32function ComposerProbe({
33 focusOnMount = false,
34 initiallyEmpty = false,
35}: {
36 focusOnMount?: boolean;
37 initiallyEmpty?: boolean;
38}) {
31 const [richText, setRichText] = useState(false);39 const [richText, setRichText] = useState(false);
32 const [draft, setDraft] = useState({40 const [draft, setDraft] = useState({
33 value: "before :dizzy after",41 value: initiallyEmpty ? "" : "before :dizzy after",
34 cursor: "before :dizzy".length,42 cursor: initiallyEmpty ? 0 : "before :dizzy".length,
35 });43 });
36 useLayoutEffect(() => {44 useLayoutEffect(() => {
37 replace = (value, cursor) => setDraft({ value, cursor });45 replace = (value, cursor) => setDraft({ value, cursor });
38 enableRichText = setRichText;46 enableRichText = setRichText;
39 });47 });
48 useEffect(() => {
49 if (!focusOnMount) return;
50 // oxlint-disable-next-line react/set-state-in-effect -- Reproduce the parent's mount-time cursor reset.
51 setDraft((current) => ({ ...current, cursor: current.value.length }));
52 handle.current?.focusAtEnd();
53 }, [focusOnMount]);
40 return (54 return (
41 <ComposerContextActionsContext55 <ComposerContextActionsContext
42 value={{56 value={{
...@@ -49,6 +63,7 @@ function ComposerProbe() {...@@ -49,6 +63,7 @@ function ComposerProbe() {
49 }}63 }}
50 >64 >
51 <ComposerPromptEditor65 <ComposerPromptEditor
66 persistenceKey="refresh-test"
52 {...draft}67 {...draft}
53 richTextEnabled={richText}68 richTextEnabled={richText}
54 editorRef={handle}69 editorRef={handle}
...@@ -90,9 +105,46 @@ beforeEach(async () => {...@@ -90,9 +105,46 @@ beforeEach(async () => {
90afterEach(async () => {105afterEach(async () => {
91 await act(() => root.unmount());106 await act(() => root.unmount());
92 container.remove();107 container.remove();
108 sessionStorage.clear();
109 vi.restoreAllMocks();
93 vi.unstubAllGlobals();110 vi.unstubAllGlobals();
94});111});
95112
113it("restores the real editor selection and viewport after a refresh remount", async () => {
114 const textbox = container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!;
115 const view = EditorView.findFromDOM(textbox)!;
116 await act(() => view.dispatch({ selection: { anchor: 2, head: 6 } }));
117 view.scrollDOM.scrollTop = 35;
118 expect(window.dispatchEvent(new Event(PREPARE_UI_REFRESH_EVENT, { cancelable: true }))).toBe(
119 true,
120 );
121 vi.spyOn(performance, "timeOrigin", "get").mockReturnValue(performance.timeOrigin + 1);
122 await act(() => root.unmount());
123 root = createRoot(container);
124 await act(() =>
125 root.render(
126 <StrictMode>
127 <ComposerProbe focusOnMount initiallyEmpty />
128 </StrictMode>,
129 ),
130 );
131 await act(() => replace("before :dizzy after", "before :dizzy after".length));
132 expect(handle.current!.readSelectionRange()).toEqual({ start: 2, end: 6 });
133 expect(document.getSelection()?.toString()).toBe("fore");
134 const restored = EditorView.findFromDOM(
135 container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!,
136 )!;
137 expect(restored.scrollDOM.scrollTop).toBe(35);
138 await act(() => {
139 restored.contentDOM.dispatchEvent(
140 new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }),
141 );
142 handle.current!.focusAtEnd();
143 });
144 const end = handle.current!.readSnapshot().value.length;
145 expect(handle.current!.readSelectionRange()).toEqual({ start: end, end });
146});
147
96describe("emoji completion in the real composer editor", () => {148describe("emoji completion in the real composer editor", () => {
97 it("keeps the caret and undo history when a controlled completion replaces the shortcode", async () => {149 it("keeps the caret and undo history when a controlled completion replaces the shortcode", async () => {
98 await act(() => handle.current!.focus());150 await act(() => handle.current!.focus());
apps/web/src/components/ComposerPromptEditor.tsx+109-6
...@@ -10,14 +10,16 @@ import {...@@ -10,14 +10,16 @@ import {
10import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";10import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
11import { isolateHistory } from "@codemirror/commands";11import { isolateHistory } from "@codemirror/commands";
12import { Transaction } from "@codemirror/state";12import { Transaction } from "@codemirror/state";
13import * as Schema from "effect/Schema";
13import { createPortal } from "react-dom";14import { createPortal } from "react-dom";
14import { use, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";15import { use, useCallback, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
1516
16import { expandCollapsedComposerCursor } from "~/composer-logic";17import { expandCollapsedComposerCursor } from "~/composer-logic";
17import { collectInlineContextIds } from "~/lib/composerContextReferences";18import { collectInlineContextIds } from "~/lib/composerContextReferences";
18import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";19import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
19import { basenameOfPath } from "~/pierre-icons";20import { basenameOfPath } from "~/pierre-icons";
20import { cn } from "~/lib/utils";21import { cn } from "~/lib/utils";
22import { PREPARE_UI_REFRESH_EVENT } from "~/uiRefresh";
21import {23import {
22 ComposerContextActionsContext,24 ComposerContextActionsContext,
23 ComposerContextRecordsContext,25 ComposerContextRecordsContext,
...@@ -66,6 +68,7 @@ export interface ComposerPromptEditorHandle {...@@ -66,6 +68,7 @@ export interface ComposerPromptEditorHandle {
66}68}
6769
68export interface ComposerPromptEditorProps {70export interface ComposerPromptEditorProps {
71 persistenceKey?: string | undefined;
69 value: string;72 value: string;
70 cursor: number;73 cursor: number;
71 /**74 /**
...@@ -129,20 +132,45 @@ type CitationComment = {...@@ -129,20 +132,45 @@ type CitationComment = {
129 insertedSpaces?: CitationInsertedSpaces | undefined;132 insertedSpaces?: CitationInsertedSpaces | undefined;
130};133};
131134
135const RefreshSelection = Schema.fromJsonString(
136 Schema.Struct({
137 pageOrigin: Schema.Finite,
138 value: Schema.String,
139 anchor: Schema.Int,
140 head: Schema.Int,
141 scrollTop: Schema.Finite,
142 scrollLeft: Schema.Finite,
143 }),
144);
145const decodeRefreshSelection = Schema.decodeSync(RefreshSelection);
146const REFRESH_SELECTION_STORAGE_PREFIX = "t3code:ui-refresh-composer:";
147
132export function ComposerPromptEditor(props: ComposerPromptEditorProps) {148export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
133 const host = useRef<HTMLDivElement>(null);149 const host = useRef<HTMLDivElement>(null);
134 const currentProps = useRef(props);150 const currentProps = useRef(props);
135 const editor = useRef<ReturnType<typeof createComposerMarkdownEditor> | null>(null);151 const editor = useRef<ReturnType<typeof createComposerMarkdownEditor> | null>(null);
152 const restoringRefreshSelection = useRef(false);
136 const pendingCitation = useRef<ComposerCitationCommentRequest | null>(null);153 const pendingCitation = useRef<ComposerCitationCommentRequest | null>(null);
137 const citationDraft = useRef<{ start: number; comment: string } | null>(null);154 const citationDraft = useRef<{ start: number; comment: string } | null>(null);
138 const [comment, setComment] = useState<CitationComment | null>(null);155 const [comment, setComment] = useState<CitationComment | null>(null);
139 const [chips, setChips] = useState<ComposerChip[]>([]);156 const [chips, setChips] = useState<ComposerChip[]>([]);
157 const discardRefreshSelection = useCallback(() => {
158 restoringRefreshSelection.current = false;
159 const key = currentProps.current.persistenceKey;
160 if (!key) return;
161 try {
162 window.sessionStorage.removeItem(`${REFRESH_SELECTION_STORAGE_PREFIX}${key}`);
163 } catch {
164 // Storage availability must not prevent editing.
165 }
166 }, []);
140167
141 useLayoutEffect(() => {168 useLayoutEffect(() => {
142 currentProps.current = props;169 currentProps.current = props;
143 });170 });
144 useLayoutEffect(() => {171 useLayoutEffect(() => {
145 let alive = true;172 let alive = true;
173 restoringRefreshSelection.current = false;
146 const instance = createComposerMarkdownEditor({174 const instance = createComposerMarkdownEditor({
147 props: () => currentProps.current,175 props: () => currentProps.current,
148 mountChip: (chip) => {176 mountChip: (chip) => {
...@@ -154,12 +182,18 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {...@@ -154,12 +182,18 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
154 });182 });
155 editor.current = instance;183 editor.current = instance;
156 host.current!.append(instance.dom);184 host.current!.append(instance.dom);
185 instance.dom.addEventListener("pointerdown", discardRefreshSelection, true);
186 instance.dom.addEventListener("keydown", discardRefreshSelection, true);
187 instance.dom.addEventListener("beforeinput", discardRefreshSelection, true);
157 return () => {188 return () => {
158 alive = false;189 alive = false;
159 editor.current = null;190 editor.current = null;
191 instance.dom.removeEventListener("pointerdown", discardRefreshSelection, true);
192 instance.dom.removeEventListener("keydown", discardRefreshSelection, true);
193 instance.dom.removeEventListener("beforeinput", discardRefreshSelection, true);
160 instance.destroy();194 instance.destroy();
161 };195 };
162 }, []);196 }, [discardRefreshSelection]);
163197
164 // oxlint-disable react/exhaustive-effect-dependencies -- Editor callbacks read the ref; DOM configuration follows these props.198 // oxlint-disable react/exhaustive-effect-dependencies -- Editor callbacks read the ref; DOM configuration follows these props.
165 useLayoutEffect(() => {199 useLayoutEffect(() => {
...@@ -180,6 +214,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {...@@ -180,6 +214,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
180 if (!instance) return;214 if (!instance) return;
181 const snapshot = instance.snapshot();215 const snapshot = instance.snapshot();
182 if (snapshot.value !== props.value) {216 if (snapshot.value !== props.value) {
217 if (restoringRefreshSelection.current) discardRefreshSelection();
183 const draft = citationDraft.current;218 const draft = citationDraft.current;
184 citationDraft.current = null;219 citationDraft.current = null;
185 const saveDraft = currentProps.current.onCitationDraftSave;220 const saveDraft = currentProps.current.onCitationDraftSave;
...@@ -229,7 +264,11 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {...@@ -229,7 +264,11 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
229 },264 },
230 annotations: [isolateHistory.of("full"), Transaction.userEvent.of("t3.controlled")],265 annotations: [isolateHistory.of("full"), Transaction.userEvent.of("t3.controlled")],
231 });266 });
232 } else if (snapshot.cursor !== props.cursor && !instance.view.compositionStarted) {267 } else if (
268 snapshot.cursor !== props.cursor &&
269 !instance.view.compositionStarted &&
270 !restoringRefreshSelection.current
271 ) {
233 instance.view.dispatch({272 instance.view.dispatch({
234 selection: {273 selection: {
235 anchor: expandCollapsedComposerCursor(props.value, props.cursor, props.literalText),274 anchor: expandCollapsedComposerCursor(props.value, props.cursor, props.literalText),
...@@ -251,7 +290,66 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {...@@ -251,7 +290,66 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
251 insertedSpaces: request.insertedSpaces,290 insertedSpaces: request.insertedSpaces,
252 });291 });
253 }292 }
254 }, [props.value, props.cursor, props.literalText]);293 }, [props.value, props.cursor, props.literalText, discardRefreshSelection]);
294
295 useLayoutEffect(() => {
296 const instance = editor.current;
297 if (!instance || !props.persistenceKey) return;
298 const storageKey = `${REFRESH_SELECTION_STORAGE_PREFIX}${props.persistenceKey}`;
299 const value = props.value;
300 try {
301 const saved = window.sessionStorage.getItem(storageKey);
302 if (saved) {
303 const selection = decodeRefreshSelection(saved);
304 if (
305 selection.pageOrigin !== performance.timeOrigin &&
306 selection.value === value &&
307 selection.anchor >= 0 &&
308 selection.anchor <= value.length &&
309 selection.head >= 0 &&
310 selection.head <= value.length
311 ) {
312 // Mount-time cursor resets and autofocus must not replace a restored selection.
313 restoringRefreshSelection.current = true;
314 instance.view.dispatch({ selection: { anchor: selection.anchor, head: selection.head } });
315 instance.view.scrollDOM.scrollTop = selection.scrollTop;
316 instance.view.scrollDOM.scrollLeft = selection.scrollLeft;
317 }
318 }
319 } catch {
320 // An unreadable snapshot leaves the editor at its ordinary initial caret.
321 }
322 }, [props.persistenceKey, props.value]);
323
324 useLayoutEffect(() => {
325 const instance = editor.current;
326 if (!instance || !props.persistenceKey) return;
327 const storageKey = `${REFRESH_SELECTION_STORAGE_PREFIX}${props.persistenceKey}`;
328 const save = (event: Event) => {
329 if (instance.view.compositionStarted || citationDraft.current || comment) {
330 event.preventDefault();
331 return;
332 }
333 try {
334 const selection = instance.view.state.selection.main;
335 window.sessionStorage.setItem(
336 storageKey,
337 JSON.stringify({
338 pageOrigin: performance.timeOrigin,
339 value: instance.view.state.doc.toString(),
340 anchor: selection.anchor,
341 head: selection.head,
342 scrollTop: instance.view.scrollDOM.scrollTop,
343 scrollLeft: instance.view.scrollDOM.scrollLeft,
344 }),
345 );
346 } catch {
347 event.preventDefault();
348 }
349 };
350 window.addEventListener(PREPARE_UI_REFRESH_EVENT, save);
351 return () => window.removeEventListener(PREPARE_UI_REFRESH_EVENT, save);
352 }, [props.persistenceKey, comment]);
255353
256 useLayoutEffect(() => {354 useLayoutEffect(() => {
257 editor.current?.view.requestMeasure();355 editor.current?.view.requestMeasure();
...@@ -264,6 +362,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {...@@ -264,6 +362,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
264 focusAt: (cursor) => {362 focusAt: (cursor) => {
265 const instance = editor.current;363 const instance = editor.current;
266 if (!instance) return;364 if (!instance) return;
365 discardRefreshSelection();
267 const snapshot = instance.snapshot();366 const snapshot = instance.snapshot();
268 instance.view.dispatch({367 instance.view.dispatch({
269 selection: {368 selection: {
...@@ -280,6 +379,10 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {...@@ -280,6 +379,10 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
280 focusAtEnd: () => {379 focusAtEnd: () => {
281 const instance = editor.current;380 const instance = editor.current;
282 if (!instance) return;381 if (!instance) return;
382 if (restoringRefreshSelection.current) {
383 instance.focus();
384 return;
385 }
283 instance.view.dispatch({386 instance.view.dispatch({
284 selection: { anchor: instance.view.state.doc.length },387 selection: { anchor: instance.view.state.doc.length },
285 scrollIntoView: true,388 scrollIntoView: true,
...@@ -337,7 +440,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {...@@ -337,7 +440,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
337 );440 );
338 },441 },
339 }),442 }),
340 [props.editorRef, props.value, props.cursor, props.literalText],443 [props.editorRef, props.value, props.cursor, props.literalText, discardRefreshSelection],
341 );444 );
342445
343 const copy = (event: React.ClipboardEvent<HTMLElement>, cut: boolean) => {446 const copy = (event: React.ClipboardEvent<HTMLElement>, cut: boolean) => {
...@@ -381,7 +484,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {...@@ -381,7 +484,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
381 onKeyUp={(event) => props.onPageScrollKeyUp?.(event.key)}484 onKeyUp={(event) => props.onPageScrollKeyUp?.(event.key)}
382 onBlur={props.onPageScrollRelease}485 onBlur={props.onPageScrollRelease}
383 >486 >
384 <div ref={host} />487 <div ref={host} data-ui-refresh-composer={props.persistenceKey} />
385 {props.value.length === 0 && props.contextRecords.size === 0 && props.placeholder ? (488 {props.value.length === 0 && props.contextRecords.size === 0 && props.placeholder ? (
386 <div489 <div
387 className={cn(490 className={cn(
apps/web/src/components/chat/ChatComposer.tsx+12
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
2import { useComposerTypingGuard } from "./useComposerTypingGuard";2import { useComposerTypingGuard } from "./useComposerTypingGuard";
3import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";3import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";
4import { PREPARE_UI_REFRESH_EVENT } from "../../uiRefresh";
4import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig";5import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig";
5import { isLocalEnvironmentDisabled } from "../../localEnvironment";6import { isLocalEnvironmentDisabled } from "../../localEnvironment";
6import { usePrimaryEnvironmentId } from "../../state/environments";7import { usePrimaryEnvironmentId } from "../../state/environments";
...@@ -2568,6 +2569,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2568,6 +2569,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2568 const onBlur = () => {2569 const onBlur = () => {
2569 pasteAsTextShortcutUntilRef.current = 0;2570 pasteAsTextShortcutUntilRef.current = 0;
2570 };2571 };
2572 const onPrepareUiRefresh = (event: Event) => {
2573 if (
2574 pendingImageCompressionsRef.current.size > 0 ||
2575 pendingThreadImportsRef.current.size > 0 ||
2576 stashInFlightRef.current.size > 0
2577 )
2578 event.preventDefault();
2579 };
2571 const onDesktopPasteAsText = () => {2580 const onDesktopPasteAsText = () => {
2572 const activeElement = document.activeElement;2581 const activeElement = document.activeElement;
2573 const blocksPasteToFocus =2582 const blocksPasteToFocus =
...@@ -2585,10 +2594,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2585,10 +2594,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2585 window.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText);2594 window.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText);
2586 window.addEventListener("keydown", onKeyDown, true);2595 window.addEventListener("keydown", onKeyDown, true);
2587 window.addEventListener("blur", onBlur);2596 window.addEventListener("blur", onBlur);
2597 window.addEventListener(PREPARE_UI_REFRESH_EVENT, onPrepareUiRefresh);
2588 return () => {2598 return () => {
2589 window.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText);2599 window.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText);
2590 window.removeEventListener("keydown", onKeyDown, true);2600 window.removeEventListener("keydown", onKeyDown, true);
2591 window.removeEventListener("blur", onBlur);2601 window.removeEventListener("blur", onBlur);
2602 window.removeEventListener(PREPARE_UI_REFRESH_EVENT, onPrepareUiRefresh);
2592 };2603 };
2593 }, []);2604 }, []);
25942605
...@@ -7691,6 +7702,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -7691,6 +7702,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
7691 ) : null}7702 ) : null}
7692 <ComposerContextActionsContext value={composerContextActions}>7703 <ComposerContextActionsContext value={composerContextActions}>
7693 <ComposerPromptEditor7704 <ComposerPromptEditor
7705 persistenceKey={activePendingProgress ? undefined : composerDraftTargetKey}
7694 key={`${composerSuggestionListId}:${activePendingProgress?.activeQuestion?.id ?? "thread"}`}7706 key={`${composerSuggestionListId}:${activePendingProgress?.activeQuestion?.id ?? "thread"}`}
7695 ariaLabel="Message"7707 ariaLabel="Message"
7696 suggestionListId={composerSuggestionListId}7708 suggestionListId={composerSuggestionListId}
apps/web/src/components/chat/MessagesTimeline.test.tsx+70-2
...@@ -896,6 +896,76 @@ describe("MessagesTimeline", () => {...@@ -896,6 +896,76 @@ describe("MessagesTimeline", () => {
896 }896 }
897 });897 });
898898
899 it("loads missing history before restoring a saved reading anchor", async () => {
900 const { rememberTimelinePosition } = await import("./timelineScrollAnchoring");
901 const props = { ...buildProps(), routeThreadKey: "environment-local:refresh-old-history" };
902 rememberTimelinePosition(
903 props.routeThreadKey,
904 {
905 rowId: "entry-old",
906 offsetWithinRow: 20,
907 scrollOffset: 700,
908 atEnd: false,
909 },
910 false,
911 );
912 const viewport = {
913 scrollTop: 0,
914 ownerDocument: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
915 addEventListener: vi.fn(),
916 removeEventListener: vi.fn(),
917 getBoundingClientRect: () => ({ top: 0 }),
918 };
919 const scrollToIndex = vi.fn(async () => {});
920 const scrollToOffset = vi.fn(async () => {});
921 props.listRef.current = {
922 getScrollableNode: () => viewport,
923 getState: () => undefined,
924 scrollToIndex,
925 scrollToOffset,
926 } as unknown as LegendListRef;
927 const onLoadEarlier = vi.fn();
928 const historyControls = { hasMoreHistory: true, loading: false, error: null, onLoadEarlier };
929 const latest = buildUserTimelineEntry("Latest prompt");
930 let renderer: ReactTestRenderer | undefined;
931 try {
932 await act(() => {
933 renderer = create(
934 <MessagesTimeline
935 {...props}
936 timelineEntries={[latest]}
937 historyControls={historyControls}
938 />,
939 );
940 });
941 expect(onLoadEarlier).toHaveBeenCalledWith("entry-old");
942 expect(scrollToOffset).not.toHaveBeenCalled();
943 expect(scrollToIndex).not.toHaveBeenCalled();
944 const older = {
945 ...buildUserTimelineEntry("Old prompt"),
946 id: "entry-old",
947 message: { ...latest.message, id: MessageId.make("old-message"), text: "Old prompt" },
948 };
949 await act(() =>
950 renderer!.update(
951 <MessagesTimeline
952 {...props}
953 timelineEntries={[older, latest]}
954 historyControls={{ ...historyControls, hasMoreHistory: false }}
955 />,
956 ),
957 );
958 expect(scrollToIndex).toHaveBeenCalledWith({
959 index: 0,
960 animated: false,
961 viewPosition: 0,
962 viewOffset: -20,
963 });
964 } finally {
965 await act(() => renderer?.unmount());
966 }
967 });
968
899 it("keeps a following thread's intent while its timeline is held for the next thread", async () => {969 it("keeps a following thread's intent while its timeline is held for the next thread", async () => {
900 const { readTimelinePosition } = await import("./timelineScrollAnchoring");970 const { readTimelinePosition } = await import("./timelineScrollAnchoring");
901 const timeline = stubFollowTimeline("environment-local:held-departure");971 const timeline = stubFollowTimeline("environment-local:held-departure");
...@@ -2647,8 +2717,6 @@ describe("MessagesTimeline", () => {...@@ -2647,8 +2717,6 @@ describe("MessagesTimeline", () => {
2647 />,2717 />,
2648 );2718 );
26492719
2650 // The T3 wordmark replaces the generic tool icon for T3 MCP calls.
2651 expect(markup).toContain('viewBox="15.5309 37 94.3941 56.96"');
2652 expect(markup).toContain("Read a T3 thread");2720 expect(markup).toContain("Read a T3 thread");
2653 expect(markup).not.toContain("mcp__t3-code__t3_thread_read");2721 expect(markup).not.toContain("mcp__t3-code__t3_thread_read");
2654 });2722 });
apps/web/src/components/chat/MessagesTimeline.tsx+22-11
...@@ -1003,6 +1003,26 @@ const ConversationTimeline = memo(function ConversationTimeline({...@@ -1003,6 +1003,26 @@ const ConversationTimeline = memo(function ConversationTimeline({
1003 const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1;1003 const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1;
1004 if (position?.atEnd === false) onManualNavigation();1004 if (position?.atEnd === false) onManualNavigation();
1005 if (cancelPositionRestoreRef) cancelPositionRestoreRef.current = cancelRestoration;1005 if (cancelPositionRestoreRef) cancelPositionRestoreRef.current = cancelRestoration;
1006 const cleanup = () => {
1007 cancelled = true;
1008 if (cancelPositionRestoreRef?.current === cancelRestoration) {
1009 cancelPositionRestoreRef.current = null;
1010 }
1011 if (settleFrame !== null) cancelAnimationFrame(settleFrame);
1012 viewport?.removeEventListener("wheel", cancelForNavigation);
1013 viewport?.removeEventListener("touchmove", cancelForNavigation);
1014 viewport?.removeEventListener("pointerdown", cancelForNavigation);
1015 viewport?.ownerDocument.removeEventListener("keydown", onScrollKey);
1016 };
1017 if (
1018 position?.atEnd === false &&
1019 index < 0 &&
1020 historyControls?.hasMoreHistory &&
1021 !historyControls.error
1022 ) {
1023 if (!historyControls.loading) historyControls.onLoadEarlier(position.rowId);
1024 return cleanup;
1025 }
1006 const scrolling =1026 const scrolling =
1007 position?.atEnd === false1027 position?.atEnd === false
1008 ? index >= 01028 ? index >= 0
...@@ -1055,23 +1075,14 @@ const ConversationTimeline = memo(function ConversationTimeline({...@@ -1055,23 +1075,14 @@ const ConversationTimeline = memo(function ConversationTimeline({
1055 };1075 };
1056 settleFrame = requestAnimationFrame(reconcile);1076 settleFrame = requestAnimationFrame(reconcile);
1057 });1077 });
1058 return () => {1078 return cleanup;
1059 cancelled = true;
1060 if (cancelPositionRestoreRef?.current === cancelRestoration) {
1061 cancelPositionRestoreRef.current = null;
1062 }
1063 if (settleFrame !== null) cancelAnimationFrame(settleFrame);
1064 viewport?.removeEventListener("wheel", cancelForNavigation);
1065 viewport?.removeEventListener("touchmove", cancelForNavigation);
1066 viewport?.removeEventListener("pointerdown", cancelForNavigation);
1067 viewport?.ownerDocument.removeEventListener("keydown", onScrollKey);
1068 };
1069 }, [1079 }, [
1070 citationRequest,1080 citationRequest,
1071 cancelPositionRestoreRef,1081 cancelPositionRestoreRef,
1072 listIdentityKey,1082 listIdentityKey,
1073 listRef,1083 listRef,
1074 onManualNavigation,1084 onManualNavigation,
1085 historyControls,
1075 rememberedPosition,1086 rememberedPosition,
1076 restoringThreadPosition,1087 restoringThreadPosition,
1077 rows,1088 rows,
apps/web/src/components/chat/timelineRefresh.test.ts created+36
...@@ -0,0 +1,36 @@
1// @vitest-environment jsdom
2
3import { RunAttemptId, RunId } from "@t3tools/contracts";
4import { afterEach, expect, it, vi } from "vite-plus/test";
5
6afterEach(() => {
7 sessionStorage.clear();
8 vi.resetModules();
9});
10
11it("restores the reading anchor, expanded work and nested scroll after a module reload", async () => {
12 const timeline = await import("./timelineScrollAnchoring");
13 const reading = {
14 rowId: "message-50",
15 offsetWithinRow: 23,
16 scrollOffset: 800,
17 atEnd: false,
18 disclosures: {
19 runs: new Set([RunId.make("run-a")]),
20 workGroups: new Set(["work-a"]),
21 attempts: new Set([RunAttemptId.make("attempt-a")]),
22 workGroupState: {
23 scrollPositions: new Map([["work-a", { entryId: "tool-3", offset: 20 }]]),
24 expandedEntries: new Set(["tool-3"]),
25 },
26 },
27 };
28 timeline.rememberTimelinePosition("environment:thread", reading, false);
29 timeline.persistTimelinePositionsForReload();
30 vi.resetModules();
31 const refreshed = await import("./timelineScrollAnchoring");
32 expect(refreshed.readTimelinePosition("environment:thread")).toEqual(reading);
33 vi.resetModules();
34 const later = await import("./timelineScrollAnchoring");
35 expect(later.readTimelinePosition("environment:thread")).toBeUndefined();
36});
apps/web/src/components/chat/timelineScrollAnchoring.ts+87-3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { MessageId, RunId } from "@t3tools/contracts";1import { RunId, RunAttemptId, type MessageId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
23
3export interface TimelineRunObservation {4export interface TimelineRunObservation {
4 readonly threadKey: string | null;5 readonly threadKey: string | null;
...@@ -33,8 +34,6 @@ export function observeTimelineRun(...@@ -33,8 +34,6 @@ export function observeTimelineRun(
33 }34 }
34 return { observation, anchorMessageId: input.messageId };35 return { observation, anchorMessageId: input.messageId };
35}36}
36import type { RunAttemptId } from "@t3tools/contracts";
37
38// Match the titlebar fade inset so draft promotion preserves the first row's position.37// Match the titlebar fade inset so draft promotion preserves the first row's position.
39export const CHAT_TIMELINE_ANCHOR_OFFSET = 24;38export const CHAT_TIMELINE_ANCHOR_OFFSET = 24;
4039
...@@ -161,6 +160,91 @@ export interface RememberedTimelinePosition {...@@ -161,6 +160,91 @@ export interface RememberedTimelinePosition {
161// Scoped thread keys keep separate environments independent. Bound the session cache.160// Scoped thread keys keep separate environments independent. Bound the session cache.
162const rememberedTimelinePositions = new Map<string, RememberedTimelinePosition>();161const rememberedTimelinePositions = new Map<string, RememberedTimelinePosition>();
163162
163const REFRESH_POSITIONS_KEY = "t3code:ui-refresh-timeline:v1";
164const RefreshPositions = Schema.fromJsonString(
165 Schema.Record(
166 Schema.String,
167 Schema.Struct({
168 rowId: Schema.String,
169 offsetWithinRow: Schema.Finite,
170 scrollOffset: Schema.Finite,
171 atEnd: Schema.Boolean,
172 disclosures: Schema.optionalKey(
173 Schema.Struct({
174 runs: Schema.Array(RunId),
175 workGroups: Schema.Array(Schema.String),
176 attempts: Schema.Array(RunAttemptId),
177 workGroupState: Schema.Struct({
178 scrollPositions: Schema.Array(
179 Schema.Tuple([
180 Schema.String,
181 Schema.Struct({ entryId: Schema.String, offset: Schema.Finite }),
182 ]),
183 ),
184 expandedEntries: Schema.Array(Schema.String),
185 }),
186 }),
187 ),
188 }),
189 ),
190);
191
192/** Save only at refresh time; scrolling keeps its existing in-memory cache. */
193export function persistTimelinePositionsForReload(): void {
194 const positions = Object.fromEntries(
195 [...rememberedTimelinePositions].map(([key, position]) => [
196 key,
197 {
198 ...position,
199 ...(position.disclosures
200 ? {
201 disclosures: {
202 runs: [...position.disclosures.runs],
203 workGroups: [...position.disclosures.workGroups],
204 attempts: [...position.disclosures.attempts],
205 workGroupState: {
206 scrollPositions: [...position.disclosures.workGroupState.scrollPositions],
207 expandedEntries: [...position.disclosures.workGroupState.expandedEntries],
208 },
209 },
210 }
211 : {}),
212 },
213 ]),
214 );
215 window.sessionStorage.setItem(REFRESH_POSITIONS_KEY, JSON.stringify(positions));
216}
217
218if (typeof window !== "undefined") {
219 try {
220 const saved = window.sessionStorage.getItem(REFRESH_POSITIONS_KEY);
221 window.sessionStorage.removeItem(REFRESH_POSITIONS_KEY);
222 if (saved) {
223 const positions = Schema.decodeSync(RefreshPositions)(saved);
224 for (const [key, { disclosures, ...position }] of Object.entries(positions)) {
225 rememberedTimelinePositions.set(key, {
226 ...position,
227 ...(disclosures
228 ? {
229 disclosures: {
230 runs: new Set(disclosures.runs),
231 workGroups: new Set(disclosures.workGroups),
232 attempts: new Set(disclosures.attempts),
233 workGroupState: {
234 scrollPositions: new Map(disclosures.workGroupState.scrollPositions),
235 expandedEntries: new Set(disclosures.workGroupState.expandedEntries),
236 },
237 },
238 }
239 : {}),
240 });
241 }
242 }
243 } catch {
244 // Unreadable refresh state falls back to the normal initial position.
245 }
246}
247
164export function readTimelinePosition(threadKey: string) {248export function readTimelinePosition(threadKey: string) {
165 return rememberedTimelinePositions.get(threadKey);249 return rememberedTimelinePositions.get(threadKey);
166}250}
apps/web/src/components/desktopUpdate.actions.test.ts created+81
...@@ -0,0 +1,81 @@
1import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";
2import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
3
4const mocks = vi.hoisted(() => ({ prepare: vi.fn(() => true), reload: vi.fn(), toast: vi.fn() }));
5vi.mock("~/uiRefresh", () => ({ prepareUiRefresh: mocks.prepare }));
6vi.mock("./ui/toast", () => ({
7 toastManager: { add: mocks.toast },
8 stackedThreadToast: (value: unknown) => value,
9}));
10vi.mock("./desktopUpdate.toast", () => ({ showDesktopUpdateDownloadedToast: mocks.toast }));
11
12import { downloadLocalUpdate, installLocalUpdate } from "./desktopUpdate.actions";
13
14const state: DesktopUpdateState = {
15 enabled: true,
16 status: "downloaded",
17 channel: "latest",
18 currentVersion: "0.0.1",
19 hostArch: "arm64",
20 appArch: "arm64",
21 runningUnderArm64Translation: false,
22 availableVersion: "0.0.2",
23 downloadedVersion: "0.0.2",
24 installKind: "ui",
25 releaseNotes: [],
26 omittedReleaseCount: 0,
27 downloadPercent: 100,
28 checkedAt: null,
29 message: null,
30 errorContext: null,
31 canRetry: true,
32};
33const result: DesktopUpdateActionResult = {
34 accepted: true,
35 completed: true,
36 state: { ...state, status: "idle", uiVersion: "0.0.2" },
37};
38
39beforeEach(() => {
40 mocks.prepare.mockReset().mockReturnValue(true);
41 mocks.reload.mockReset();
42 mocks.toast.mockReset();
43 vi.stubGlobal("window", { location: { reload: mocks.reload } });
44});
45afterEach(() => vi.unstubAllGlobals());
46
47it("downloads a UI release without activating it", async () => {
48 const bridge = {
49 openExternal: vi.fn(async () => true),
50 downloadUpdate: vi.fn(async () => ({ ...result, state })),
51 };
52 expect(await downloadLocalUpdate(bridge, { notifyDownloaded: false })).toBe(true);
53 expect(mocks.reload).not.toHaveBeenCalled();
54 expect(mocks.prepare).not.toHaveBeenCalled();
55});
56
57it("activates an automatic update only when state can roundtrip", async () => {
58 const bridge = {
59 openExternal: vi.fn(async () => true),
60 installUpdate: vi.fn(async () => result),
61 };
62 mocks.prepare.mockReturnValue(false);
63 expect(await installLocalUpdate(bridge, undefined, { automatic: true })).toBe(false);
64 expect(bridge.installUpdate).not.toHaveBeenCalled();
65 mocks.prepare.mockReturnValue(true);
66 expect(await installLocalUpdate(bridge, undefined, { automatic: true })).toBe(true);
67 expect(mocks.reload).toHaveBeenCalledOnce();
68});
69
70it("keeps the current UI if unsafe state appears while activation is in progress", async () => {
71 const bridge = {
72 openExternal: vi.fn(async () => true),
73 installUpdate: vi.fn(async () => {
74 mocks.prepare.mockReturnValue(false);
75 return result;
76 }),
77 };
78 expect(await installLocalUpdate(bridge, undefined, { automatic: true })).toBe(true);
79 expect(bridge.installUpdate).toHaveBeenCalledOnce();
80 expect(mocks.reload).not.toHaveBeenCalled();
81});
apps/web/src/components/desktopUpdate.actions.ts+10-7
...@@ -9,14 +9,15 @@ import {...@@ -9,14 +9,15 @@ import {
9} from "./desktopUpdate.logic";9} from "./desktopUpdate.logic";
10import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";10import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";
11import type { ReportLocalUpdateError } from "./sidebar/useUpdateEverything";11import type { ReportLocalUpdateError } from "./sidebar/useUpdateEverything";
12import { prepareUiRefresh } from "~/uiRefresh";
1213
13export const toastUpdateError: ReportLocalUpdateError = (title, description) => {14export const toastUpdateError: ReportLocalUpdateError = (title, description) => {
14 toastManager.add(stackedThreadToast({ type: "error", title, description }));15 toastManager.add(stackedThreadToast({ type: "error", title, description }));
15};16};
1617
17/** Prepares a full-app update or applies a compatible UI without stopping tasks. */18/** Downloads updates; activation waits for confirmation or a safe idle refresh. */
18export async function downloadLocalUpdate(19export async function downloadLocalUpdate(
19 bridge: DesktopBridge,20 bridge: Pick<DesktopBridge, "downloadUpdate" | "openExternal">,
20 {21 {
21 notifyDownloaded,22 notifyDownloaded,
22 reportError = toastUpdateError,23 reportError = toastUpdateError,
...@@ -24,9 +25,6 @@ export async function downloadLocalUpdate(...@@ -24,9 +25,6 @@ export async function downloadLocalUpdate(
24): Promise<boolean> {25): Promise<boolean> {
25 try {26 try {
26 const result = await bridge.downloadUpdate();27 const result = await bridge.downloadUpdate();
27 if (result.completed && result.state.installKind === "ui") {
28 return await installLocalUpdate(bridge, reportError);
29 }
30 if (result.completed && notifyDownloaded) {28 if (result.completed && notifyDownloaded) {
31 showDesktopUpdateDownloadedToast(bridge, result.state);29 showDesktopUpdateDownloadedToast(bridge, result.state);
32 }30 }
...@@ -46,10 +44,12 @@ export async function downloadLocalUpdate(...@@ -46,10 +44,12 @@ export async function downloadLocalUpdate(
4644
47/** Applies a prepared UI in place or starts an app relaunch after the caller confirms. */45/** Applies a prepared UI in place or starts an app relaunch after the caller confirms. */
48export async function installLocalUpdate(46export async function installLocalUpdate(
49 bridge: DesktopBridge,47 bridge: Pick<DesktopBridge, "installUpdate" | "openExternal">,
50 reportError: ReportLocalUpdateError = toastUpdateError,48 reportError: ReportLocalUpdateError = toastUpdateError,
49 options: { readonly automatic?: boolean } = {},
51): Promise<boolean> {50): Promise<boolean> {
52 try {51 try {
52 if (options.automatic && !prepareUiRefresh()) return false;
53 const result = await bridge.installUpdate();53 const result = await bridge.installUpdate();
54 if (!result.accepted) {54 if (!result.accepted) {
55 showDesktopUpdateDownloadedToast(bridge, result.state);55 showDesktopUpdateDownloadedToast(bridge, result.state);
...@@ -59,7 +59,10 @@ export async function installLocalUpdate(...@@ -59,7 +59,10 @@ export async function installLocalUpdate(
59 ? getDesktopUpdateActionError(result)59 ? getDesktopUpdateActionError(result)
60 : null;60 : null;
61 if (!actionError) {61 if (!actionError) {
62 if (result.completed && result.state.uiVersion) window.location.reload();62 if (result.completed && result.state.uiVersion) {
63 const preserved = prepareUiRefresh();
64 if (!options.automatic || preserved) window.location.reload();
65 }
63 return true;66 return true;
64 }67 }
65 reportError("Could not install update", actionError);68 reportError("Could not install update", actionError);
apps/web/src/composerDraftStore.ts+32
...@@ -130,6 +130,38 @@ const composerPersistStorage: PersistStorage<ComposerPersistState> = {...@@ -130,6 +130,38 @@ const composerPersistStorage: PersistStorage<ComposerPersistState> = {
130 removeItem: (name) => composerDebouncedStorage.removeItem(name),130 removeItem: (name) => composerDebouncedStorage.removeItem(name),
131};131};
132132
133/** A reload is safe only after every draft byte is durably represented in storage. */
134export function prepareComposerDraftsForReload(): boolean {
135 const state = useComposerDraftStore.getState();
136 if (
137 state.rewindingThreadKeys.size > 0 ||
138 Object.keys(state.backgroundSubmissionThreadKeys).length > 0 ||
139 Object.values(state.draftThreadsByThreadKey).some(isDraftThreadPromoting) ||
140 Object.values(state.draftsByThreadKey).some(
141 (draft) =>
142 draft.nonPersistedImageIds.length > 0 ||
143 draft.images.some((image) => !draft.persistedAttachments.some(({ id }) => id === image.id)) ||
144 draft.files.some((file) => !file.uploadedAttachmentId || !file.uploadEnvironmentId),
145 )
146 )
147 return false;
148
149 try {
150 composerPersistStorage.setItem(COMPOSER_DRAFT_STORAGE_KEY, {
151 state: { capturedState: state },
152 version: COMPOSER_DRAFT_STORAGE_VERSION,
153 });
154 composerDebouncedStorage.flush();
155 const expected = JSON.stringify({
156 state: partializeComposerDraftStoreState(state),
157 version: COMPOSER_DRAFT_STORAGE_VERSION,
158 });
159 return window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY) === expected;
160 } catch {
161 return false;
162 }
163}
164
133// Flush pending composer draft writes before page unload to prevent data loss.165// Flush pending composer draft writes before page unload to prevent data loss.
134if (typeof window !== "undefined" && typeof window.addEventListener === "function") {166if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
135 window.addEventListener("beforeunload", () => {167 window.addEventListener("beforeunload", () => {
apps/web/src/composerReload.test.ts created+67
...@@ -0,0 +1,67 @@
1// @vitest-environment jsdom
2
3import { scopeThreadRef } from "@t3tools/client-runtime/environment";
4import { EnvironmentId, ThreadId } from "@t3tools/contracts";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6import {
7 COMPOSER_DRAFT_STORAGE_KEY,
8 prepareComposerDraftsForReload,
9 useComposerDraftStore,
10} from "./composerDraftStore";
11
12const target = scopeThreadRef(EnvironmentId.make("refresh-host"), ThreadId.make("refresh-thread"));
13
14beforeEach(() => {
15 vi.useFakeTimers();
16 useComposerDraftStore.setState({
17 draftsByThreadKey: {},
18 draftThreadsByThreadKey: {},
19 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
20 });
21});
22afterEach(() => {
23 vi.restoreAllMocks();
24 vi.useRealTimers();
25});
26
27it("flushes current text synchronously and confirms it survives rehydration", async () => {
28 useComposerDraftStore.getState().setPrompt(target, "latest keystroke");
29 expect(prepareComposerDraftsForReload()).toBe(true);
30 expect(localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY)).toContain("latest keystroke");
31 useComposerDraftStore.setState({ draftsByThreadKey: {} });
32 await useComposerDraftStore.persist.rehydrate();
33 expect(useComposerDraftStore.getState().getComposerDraft(target)?.prompt).toBe(
34 "latest keystroke",
35 );
36});
37
38it("defers while file bytes exist only locally, then permits their uploaded reference", () => {
39 const file = new File(["private bytes"], "note.txt", { type: "text/plain" });
40 useComposerDraftStore
41 .getState()
42 .addFiles(target, [
43 {
44 type: "file",
45 id: "note",
46 name: file.name,
47 mimeType: file.type,
48 sizeBytes: file.size,
49 file,
50 },
51 ]);
52 expect(prepareComposerDraftsForReload()).toBe(false);
53 useComposerDraftStore
54 .getState()
55 .setFileUpload(target, "note", target.environmentId, "uploaded-note");
56 expect(prepareComposerDraftsForReload()).toBe(true);
57});
58
59it("defers when storage rejects or silently drops the flushed draft", () => {
60 useComposerDraftStore.getState().setPrompt(target, "must survive");
61 const write = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
62 throw new DOMException("full", "QuotaExceededError");
63 });
64 expect(prepareComposerDraftsForReload()).toBe(false);
65 write.mockImplementation(() => {});
66 expect(prepareComposerDraftsForReload()).toBe(false);
67});
apps/web/src/routes/__root.tsx+2
...@@ -32,6 +32,7 @@ import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkC...@@ -32,6 +32,7 @@ import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkC
32import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive";32import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive";
33import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";33import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
34import { DesktopUpdateLaunchNotice } from "../components/sidebar/updateEverythingRun";34import { DesktopUpdateLaunchNotice } from "../components/sidebar/updateEverythingRun";
35import { AutomaticUiRefresh } from "../components/AutomaticUiRefresh";
35import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";36import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";
36import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast";37import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast";
37import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";38import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";
...@@ -261,6 +262,7 @@ function RootRouteView() {...@@ -261,6 +262,7 @@ function RootRouteView() {
261 ) : null}262 ) : null}
262 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}263 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
263 {isElectron ? <DesktopUpdateLaunchNotice /> : null}264 {isElectron ? <DesktopUpdateLaunchNotice /> : null}
265 {isElectron ? <AutomaticUiRefresh /> : null}
264 {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */}266 {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */}
265 <NightlyMobileBetaNotice />267 <NightlyMobileBetaNotice />
266 {appShell}268 {appShell}
apps/web/src/uiRefresh.test.ts created+190
...@@ -0,0 +1,190 @@
1// @vitest-environment jsdom
2
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5const mocks = vi.hoisted(() => ({
6 drafts: vi.fn(() => true),
7 timeline: vi.fn(),
8 recordings: vi.fn(() => new Set<string>()),
9}));
10vi.mock("./composerDraftStore", () => ({ prepareComposerDraftsForReload: mocks.drafts }));
11vi.mock("./components/chat/timelineScrollAnchoring", () => ({
12 persistTimelinePositionsForReload: mocks.timeline,
13}));
14vi.mock("./browser/browserRecording", () => ({
15 readActiveBrowserRecordingTabIds: mocks.recordings,
16}));
17
18import { PREPARE_UI_REFRESH_EVENT, prepareUiRefresh, startAutomaticUiRefresh } from "./uiRefresh";
19
20let focused = true;
21let controller: ReturnType<typeof startAutomaticUiRefresh> | undefined;
22
23beforeEach(() => {
24 vi.useFakeTimers();
25 focused = true;
26 vi.spyOn(document, "hasFocus").mockImplementation(() => focused);
27 Object.defineProperty(document, "hidden", { configurable: true, value: false });
28 document.body.replaceChildren();
29 mocks.drafts.mockReset().mockReturnValue(true);
30 mocks.timeline.mockReset();
31 mocks.recordings.mockReset().mockReturnValue(new Set());
32});
33afterEach(() => {
34 controller?.dispose();
35 controller = undefined;
36 vi.restoreAllMocks();
37 vi.useRealTimers();
38});
39
40describe("automatic UI refresh", () => {
41 it("waits one uninterrupted unfocused minute with an update pending", async () => {
42 const apply = vi.fn(async () => {});
43 controller = startAutomaticUiRefresh({ pending: () => true, apply });
44 focused = false;
45 window.dispatchEvent(new Event("blur"));
46 await vi.advanceTimersByTimeAsync(59_999);
47 expect(apply).not.toHaveBeenCalled();
48 focused = true;
49 window.dispatchEvent(new Event("focus"));
50 await vi.advanceTimersByTimeAsync(60_000);
51 expect(apply).not.toHaveBeenCalled();
52 focused = false;
53 window.dispatchEvent(new Event("blur"));
54 await vi.advanceTimersByTimeAsync(60_000);
55 expect(apply).toHaveBeenCalledOnce();
56 });
57
58 it("resets the ten-minute idle threshold on input, and never applies a nonpending update", async () => {
59 let pending = false;
60 const apply = vi.fn(async () => {
61 pending = false;
62 });
63 controller = startAutomaticUiRefresh({ pending: () => pending, apply });
64 await vi.advanceTimersByTimeAsync(9 * 60_000);
65 window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
66 pending = true;
67 controller.check();
68 await vi.advanceTimersByTimeAsync(10 * 60_000 - 1);
69 expect(apply).not.toHaveBeenCalled();
70 await vi.advanceTimersByTimeAsync(1);
71 expect(apply).toHaveBeenCalledOnce();
72 await vi.advanceTimersByTimeAsync(20 * 60_000);
73 expect(apply).toHaveBeenCalledOnce();
74 });
75
76 it("retries an eligible refresh after unsafe local state clears", async () => {
77 const apply = vi.fn(async () => {});
78 const guest = document.createElement("webview");
79 document.body.append(guest);
80 controller = startAutomaticUiRefresh({ pending: () => true, apply });
81 await vi.advanceTimersByTimeAsync(10 * 60_000);
82 expect(apply).not.toHaveBeenCalled();
83 guest.remove();
84 await vi.advanceTimersByTimeAsync(30_000);
85 expect(apply).toHaveBeenCalledOnce();
86 });
87
88 it("does not interrupt IME composition or outlive its owner", async () => {
89 const apply = vi.fn(async () => {});
90 controller = startAutomaticUiRefresh({ pending: () => true, apply });
91 window.dispatchEvent(new CompositionEvent("compositionstart"));
92 await vi.advanceTimersByTimeAsync(10 * 60_000);
93 expect(apply).not.toHaveBeenCalled();
94 controller.dispose();
95 window.dispatchEvent(new CompositionEvent("compositionend"));
96 await vi.advanceTimersByTimeAsync(20 * 60_000);
97 expect(apply).not.toHaveBeenCalled();
98 });
99});
100
101describe("refresh preservation", () => {
102 it.each(["webview", "iframe", "[data-thread-terminal-drawer]", '[role="dialog"]'])(
103 "defers while %s owns live state",
104 (selector) => {
105 const node = document.createElement(selector.startsWith("[") ? "div" : selector);
106 if (selector === "[data-thread-terminal-drawer]") node.dataset.threadTerminalDrawer = "";
107 if (selector === '[role="dialog"]') node.setAttribute("role", "dialog");
108 document.body.append(node);
109 expect(prepareUiRefresh()).toBe(false);
110 expect(mocks.timeline).not.toHaveBeenCalled();
111 },
112 );
113
114 it("defers when drafts cannot be durably saved or an editor vetoes its snapshot", () => {
115 mocks.drafts.mockReturnValue(false);
116 expect(prepareUiRefresh()).toBe(false);
117 mocks.drafts.mockReturnValue(true);
118 const veto = (event: Event) => event.preventDefault();
119 window.addEventListener(PREPARE_UI_REFRESH_EVENT, veto);
120 expect(prepareUiRefresh()).toBe(false);
121 window.removeEventListener(PREPARE_UI_REFRESH_EVENT, veto);
122 expect(prepareUiRefresh()).toBe(true);
123 expect(mocks.timeline).toHaveBeenCalledOnce();
124 });
125
126 it("keeps unsaved form input, recordings and unhandled scroll positions intact", () => {
127 const input = document.createElement("input");
128 input.value = "unsaved edit";
129 document.body.append(input);
130 expect(prepareUiRefresh()).toBe(false);
131 input.remove();
132 mocks.recordings.mockReturnValue(new Set(["recording"]));
133 expect(prepareUiRefresh()).toBe(false);
134 mocks.recordings.mockReturnValue(new Set());
135 const sidebar = document.createElement("div");
136 sidebar.scrollTop = 140;
137 document.body.append(sidebar);
138 expect(prepareUiRefresh()).toBe(false);
139 sidebar.className = "messages-timeline-scroll";
140 expect(prepareUiRefresh()).toBe(true);
141 });
142
143 it("allows hidden controls that reflect saved selections", () => {
144 const hidden = document.createElement("input");
145 hidden.type = "hidden";
146 hidden.value = "selected-model";
147 const toggle = document.createElement("input");
148 toggle.type = "checkbox";
149 toggle.checked = true;
150 document.body.append(hidden, toggle);
151 expect(prepareUiRefresh()).toBe(true);
152 });
153
154 it("ignores Base UI's hidden text controls while retaining visible edits", () => {
155 for (const value of ["all", "full-access"]) {
156 const hidden = document.createElement("input");
157 hidden.type = "text";
158 hidden.value = value;
159 hidden.setAttribute("aria-hidden", "true");
160 hidden.style.cssText = "position:fixed;left:-1px;top:-1px;width:1px;height:1px";
161 document.body.append(hidden);
162 }
163 expect(prepareUiRefresh()).toBe(true);
164 const edited = document.createElement("input");
165 edited.value = "Unsaved settings edit";
166 document.body.append(edited);
167 expect(prepareUiRefresh()).toBe(false);
168 });
169
170 it("ignores retained closed Base UI popovers but defers for an open dialog", () => {
171 for (const attribute of ["data-closed", "inert", "hidden"]) {
172 const popover = document.createElement("div");
173 popover.setAttribute("role", "dialog");
174 popover.setAttribute(attribute, "");
175 const input = document.createElement("input");
176 input.value = "retained menu selection";
177 popover.append(input);
178 const editor = document.createElement("div");
179 editor.className = "cm-editor";
180 popover.append(editor);
181 popover.scrollTop = 30;
182 document.body.append(popover);
183 }
184 expect(prepareUiRefresh()).toBe(true);
185 const open = document.createElement("div");
186 open.setAttribute("role", "dialog");
187 document.body.append(open);
188 expect(prepareUiRefresh()).toBe(false);
189 });
190});
apps/web/src/uiRefresh.ts created+134
...@@ -0,0 +1,134 @@
1import { prepareComposerDraftsForReload } from "./composerDraftStore";
2import { persistTimelinePositionsForReload } from "./components/chat/timelineScrollAnchoring";
3import { readActiveBrowserRecordingTabIds } from "./browser/browserRecording";
4import { useThemeEditorStore } from "./components/settings/themeEditorStore";
5
6export const PREPARE_UI_REFRESH_EVENT = "t3:prepare-ui-refresh";
7const HIDDEN_UI_SELECTOR = '[data-closed], [aria-hidden="true"], [hidden], [inert]';
8
9/** DOM-owned guests and terminal viewports cannot survive a renderer replacement. */
10export function prepareUiRefresh(): boolean {
11 const selection = document.getSelection();
12 const selectionElement = selection?.anchorNode?.parentElement;
13 if (
14 document.querySelector(
15 "webview, iframe, [data-thread-terminal-drawer], [data-timeline-loading]",
16 ) ||
17 [...document.querySelectorAll('[role="dialog"], [role="alertdialog"]')].some(
18 (dialog) => !dialog.closest(HIDDEN_UI_SELECTOR),
19 ) ||
20 readActiveBrowserRecordingTabIds().size > 0 ||
21 useThemeEditorStore.getState().session !== null ||
22 (selection &&
23 !selection.isCollapsed &&
24 !selectionElement?.closest("[data-ui-refresh-composer]")) ||
25 [...document.querySelectorAll(".cm-editor")].some(
26 (editor) =>
27 !editor.closest("[data-ui-refresh-composer]") &&
28 !editor.closest(HIDDEN_UI_SELECTOR),
29 ) ||
30 [
31 ...document.querySelectorAll(
32 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]):not([type="button"]):not([type="submit"]):not([type="reset"]), textarea, [contenteditable=true]',
33 ),
34 ].some(
35 (element) =>
36 !element.closest("[data-ui-refresh-composer]") &&
37 !element.closest(HIDDEN_UI_SELECTOR) &&
38 (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement
39 ? element.value.length > 0
40 : (element.textContent?.length ?? 0) > 0),
41 ) ||
42 [...document.querySelectorAll<HTMLElement>("*")].some(
43 (element) =>
44 (element.scrollTop !== 0 || element.scrollLeft !== 0) &&
45 !element.closest(HIDDEN_UI_SELECTOR) &&
46 !element.matches(".messages-timeline-scroll, [data-tool-group-scroll]") &&
47 !(element.matches(".cm-scroller") && element.closest("[data-ui-refresh-composer]")),
48 )
49 )
50 return false;
51
52 try {
53 if (!window.dispatchEvent(new Event(PREPARE_UI_REFRESH_EVENT, { cancelable: true })))
54 return false;
55 if (!prepareComposerDraftsForReload()) return false;
56 persistTimelinePositionsForReload();
57 return true;
58 } catch {
59 return false;
60 }
61}
62
63export function startAutomaticUiRefresh(options: {
64 readonly pending: () => boolean;
65 readonly apply: () => Promise<unknown>;
66}) {
67 let lastActivity = Date.now();
68 let unfocusedSince = document.hasFocus() && !document.hidden ? null : lastActivity;
69 let composing = false;
70 let applying = false;
71 let disposed = false;
72 let timer: ReturnType<typeof setTimeout> | undefined;
73
74 const check = () => {
75 clearTimeout(timer);
76 if (disposed || applying || !options.pending()) return;
77 const now = Date.now();
78 const idleDelay = 10 * 60_000 - (now - lastActivity);
79 const unfocusedDelay = unfocusedSince === null ? Infinity : 60_000 - (now - unfocusedSince);
80 const delay = Math.min(idleDelay, unfocusedDelay);
81 if (delay > 0) {
82 timer = setTimeout(check, delay);
83 return;
84 }
85 if (composing || !prepareUiRefresh()) {
86 timer = setTimeout(check, 30_000);
87 return;
88 }
89 applying = true;
90 void options
91 .apply()
92 .catch(() => undefined)
93 .finally(() => {
94 applying = false;
95 if (!disposed) timer = setTimeout(check, 30_000);
96 });
97 };
98 const activity = () => {
99 lastActivity = Date.now();
100 check();
101 };
102 const focus = () => {
103 unfocusedSince =
104 document.hasFocus() && !document.hidden ? null : (unfocusedSince ?? Date.now());
105 activity();
106 };
107 const composition = (event: Event) => {
108 composing = event.type === "compositionstart";
109 activity();
110 };
111 const activityEvents = ["pointerdown", "pointermove", "keydown", "wheel", "input", "touchstart"];
112 for (const name of activityEvents)
113 window.addEventListener(name, activity, { capture: true, passive: true });
114 window.addEventListener("focus", focus);
115 window.addEventListener("blur", focus);
116 document.addEventListener("visibilitychange", focus);
117 window.addEventListener("compositionstart", composition, true);
118 window.addEventListener("compositionend", composition, true);
119 check();
120
121 return {
122 check,
123 dispose: () => {
124 disposed = true;
125 clearTimeout(timer);
126 for (const name of activityEvents) window.removeEventListener(name, activity, true);
127 window.removeEventListener("focus", focus);
128 window.removeEventListener("blur", focus);
129 document.removeEventListener("visibilitychange", focus);
130 window.removeEventListener("compositionstart", composition, true);
131 window.removeEventListener("compositionend", composition, true);
132 },
133 };
134}
docs/operations/development.md+159
...@@ -214,3 +214,162 @@ Licensed Name Sans and Name Mono files live in the ignored...@@ -214,3 +214,162 @@ Licensed Name Sans and Name Mono files live in the ignored
214build workspace before building with these fonts. The web and desktop builds214build workspace before building with these fonts. The web and desktop builds
215bundle the local files automatically; source checkouts without them use system215bundle the local files automatically; source checkouts without them use system
216fallback fonts. Keep the font binaries out of source history.216fallback fonts. Keep the font binaries out of source history.
217
218Create `~/.config/t3-fork-release.json` with permissions `600`:
219
220```json
221{
222 "feedUrl": "https://files.example.com/read-only-share/",
223 "uploadUrl": "https://files.example.com/private-write-share/",
224 "uploadApiKey": "<Copyparty API password>",
225 "signingIdentity": "<40-character certificate fingerprint>",
226 "signingKeychain": "/Users/you/.config/t3-fork-signing.keychain-db"
227}
228```
229
230Only the public feed URL is bundled in the app. Keep the upload URL, API key, and
231signing credentials outside the repository. Uploads authenticate through Copyparty's
232`PW` header. The publishing account needs to create, overwrite, and delete files;
233the public share needs GET, HEAD, and Copyparty's `?ls` listing access to the same
234files. No application server or package registry is required for the feed.
235
236Import the signing certificate's `.p12` into a dedicated build keychain so signing
237works from Terminal without granting access to other login-keychain keys. Put its
238password in a private file such as `/tmp/certpass.txt`:
239
240```sh
241umask 077
242fork_keychain="$HOME/.config/t3-fork-signing.keychain-db"
243openssl rand -base64 32 > "$fork_keychain.password"
244security create-keychain -p "$(cat "$fork_keychain.password")" "$fork_keychain"
245security unlock-keychain -p "$(cat "$fork_keychain.password")" "$fork_keychain"
246security import "$HOME/Certificates.p12" -k "$fork_keychain" \
247 -P "$(cat /tmp/certpass.txt)" -T /usr/bin/codesign
248security set-key-partition-list -S apple-tool:,apple: -s \
249 -k "$(cat "$fork_keychain.password")" "$fork_keychain"
250security find-identity -v -p codesigning "$fork_keychain"
251```
252
253Copy the certificate fingerprint into `signingIdentity`. Releases unlock this
254keychain using its adjacent `.password` file. The fingerprint avoids selecting
255an older or revoked certificate with the same display name.
256
257Direct downloads that pass Gatekeeper need a **Developer ID Application**
258certificate from the same Apple Developer account used for iOS, plus Apple's
259notarization service. Apple Development and iOS distribution certificates do not
260replace Developer ID. Store notarization credentials in the build keychain with
261`xcrun notarytool store-credentials knife-notary --keychain "$fork_keychain"`,
262then add `"notarizationKeychainProfile": "knife-notary"` to the private release
263config and select the Developer ID certificate fingerprint. The release checks
264the certificate and credentials before building, signs with the hardened runtime,
265notarizes and staples the app, then verifies Gatekeeper acceptance. It stops if
266any of those checks fail. The notarization profile stays outside the repository.
267
268```sh
269vp run fork:build
270vp run fork:publish release/fork/0.0.<timestamp>
271# Build and publish together:
272vp run fork:release
273```
274
275Builds still verify a signed Apple Silicon app locally. Publishing separates UI,
276desktop application code, portable server code, and static assets into checksum-verified archives;
277unchanged archives are reused. The full app ZIP is uploaded only when Electron,
278the bootstrap, signing policy, or shell resources change. A UI edit therefore uploads
279the UI code archive and small release manifests. Static models, fonts, and images
280are cached separately across UI edits. Versions increase independently of
281upstream versions and rebases. Publishing makes every referenced archive readable,
282then creates the immutable version manifest. Clients select the newest completed
283version from the directory listing. Run one publisher at a time: installer URLs are
284mutable, so simultaneous installer replacement is unsupported.
285The root `preadme.md` links to the available platform installers and is refreshed
286after a completed publication. Version-free download entrypoints use
287`The-Knife-darwin-arm64.zip`, `The-Knife-linux-x64.AppImage`, and
288`The-Knife-win32-x64.exe`. Publication uploads replacements under temporary names,
289verifies their checksums, then promotes them with WebDAV MOVE. Native versions
290and checksums remain in the completion JSON and per-version updater YAML.
291Before replacement, conflicting completed releases and unreferenced updater YAML
292are retired. The two newest nonconflicting completed releases and their referenced
293components are retained; unrelated files and incomplete uploads are left alone.
294If publication fails after retirement, retry the same local release directory.
295Its atomically written `publication.json` preserves the merged release and cleanup
296ownership until the exact completion and all dependencies are verified.
297
298Build Linux installers on Linux for the native capture and credential helpers.
299Windows builds can run natively, or use matching prebuilt helpers with
300`T3CODE_DESKTOP_WINDOWS_RUNTIME` pointing to a Wine launcher for the isolated
301backend probe. Native Windows builds also exercise the packaged file index.
302Export the packaged resources alongside the installer and updater manifest:
303
304```sh
305# On Linux (use --platform win --target nsis on Windows):
306T3CODE_DESKTOP_FORK_RESOURCES=true T3CODE_DESKTOP_UPDATE_URL=https://files.example.com/read-only-share/ \
307 node scripts/build-desktop-artifact.ts --platform linux --target AppImage \
308 --arch x64 --build-version 0.0.<timestamp> --output-dir /path/to/native-build
309```
310
311Copy that output directory to the publishing host, then run:
312
313```sh
314node scripts/release-fork.ts ingest linux-x64 /path/to/native-build
315# Windows:
316node scripts/release-fork.ts ingest win32-x64 /path/to/native-build
317```
318
319Ingestion verifies the installer and resource versions, separates reusable
320components, and publishes a new completed manifest. Each platform keeps its own
321backend, UI, and asset versions until its next native build is published.
322
323Install the generated `.app` once on each Mac to bootstrap this updater. Subsequent
324code updates download without replacing Electron. Application code and shell changes
325require a confirmed restart. UI updates can apply after one minute unfocused or ten
326minutes idle once restorable state is saved; live browser guests and other state
327that cannot be restored defer automatic refresh.
328The Knife retains the shipped bundle ID, data directory, credential storage, and
329Connect configuration. Its display name and new artifacts use The Knife; the
330existing updater accepts the new artifact filenames. The initial
331build must include any public Connect configuration used by that installation;
332the build loads the repository's local environment files.
333
334`security find-identity -v -p codesigning` lists usable signing identities. A
335certificate needs its private key in the keychain; a `.p8` App Store Connect key
336alone cannot sign an app. Keep the same signing identity across releases. A local
337Apple Development identity still signs private builds, with a build warning that
338Gatekeeper may block downloaded copies. Native passkeys additionally need the
339provisioned signing procedure linked above.
340
341### Private server container
342
343The [Compose configuration](../../compose.yaml) runs a persistent Knife server,
344including the private shared-account authority. The public
345`knife-server-linux-amd64.tar.gz` deployment bundle contains the image, this Compose
346configuration, and a public-feed environment example. Verify it against its
347adjacent `.sha256` file, extract it, and load `image.tar` with
348`docker image load -i image.tar`. Copy `.env.example` to `.env` and prepare the
349archive and workspace directories with write access for UID 1000. Start it with
350`docker compose up -d --no-build`; the deployment bundle needs no source checkout
351or registry account.
352
353To build the base image from source, run `docker build -t knife:local .` on the
354target Linux architecture. `docker image save knife:local` exports a loadable image.
355A registry can supply `KNIFE_IMAGE` instead.
356
357Set `KNIFE_UPDATE_URL` to the public release directory,
358`KNIFE_ARCHIVES_DIRECTORY` and `KNIFE_WORKSPACES_DIRECTORY` to host directories,
359then run `docker compose up -d`. The default port binds to localhost for a reverse
360proxy; set `KNIFE_BIND_ADDRESS` for a different binding. `/data` and the provider home
361use persistent volumes. Keep both private and backed up; account credentials never
362belong on the public build share.
363
364At startup the container downloads only missing server/UI/assets archives from the same
365feed, checks their hashes and native dependency contract, and runs the selected
366release. `docker compose restart knife` applies a published code update after
367confirming the restart with Clover. While running it prefetches new archives every
368four minutes without restarting or replacing the running code. A feed outage uses the cached release or bundled
369baseline; a checksum or dependency mismatch stops startup. Rebuild the base image
370when the server's locked native dependency closure or container bootstrap changes.
371
372Enroll execution hosts with the private authority using the existing
373[`t3 shared` commands](../user/shared-server.md). Each machine gets its own
374revocable session credential. The authority owns Codex token refreshes; Claude
375accounts need setup tokens. Connected hosts synchronize accounts automatically.
infra/container/knife created+20
...@@ -0,0 +1,20 @@
1#!/bin/sh
2set -eu
3
4if [ "${1:-serve}" = serve ]; then
5 node /opt/knife/scripts/container-runtime.ts
6fi
7
8runtime="${T3CODE_HOME:-/data}/runtime/knife-container/current"
9if [ ! -f "$runtime/apps/server/dist/bin.mjs" ] ||
10 [ "$(cat "$runtime/knife-server-runtime")" != "$(cat /opt/knife/knife-server-runtime)" ]; then
11 runtime=/opt/knife
12fi
13if [ -f "$runtime/version" ]; then
14 T3CODE_VERSION_OVERRIDE="$(cat "$runtime/version")"
15 export T3CODE_VERSION_OVERRIDE
16fi
17if [ "${1:-serve}" = serve ]; then
18 exec node --import /opt/knife/scripts/container-runtime.ts "$runtime/apps/server/dist/bin.mjs" "$@"
19fi
20exec node "$runtime/apps/server/dist/bin.mjs" "$@"
package.json+3
...@@ -38,6 +38,9 @@...@@ -38,6 +38,9 @@
38 "fmt": "vp fmt",38 "fmt": "vp fmt",
39 "fmt:check": "vp fmt --check",39 "fmt:check": "vp fmt --check",
40 "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts",40 "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts",
41 "fork:build": "node scripts/release-fork.ts build",
42 "fork:publish": "node scripts/release-fork.ts publish",
43 "fork:release": "node scripts/release-fork.ts release",
41 "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg",44 "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg",
42 "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64",45 "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64",
43 "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64",46 "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64",
packages/contracts/src/ipc.ts+4
...@@ -290,6 +290,8 @@ export interface DesktopUpdateState {...@@ -290,6 +290,8 @@ export interface DesktopUpdateState {
290 * not install it again; the user can retry it from the update button.290 * not install it again; the user can retry it from the update button.
291 */291 */
292 notAppliedVersion?: string;292 notAppliedVersion?: string;
293 installKind?: "ui" | "app";
294 uiVersion?: string;
293}295}
294296
295export interface DesktopUpdateReleaseNote {297export interface DesktopUpdateReleaseNote {
...@@ -322,6 +324,8 @@ export const DesktopUpdateStateSchema = Schema.Struct({...@@ -322,6 +324,8 @@ export const DesktopUpdateStateSchema = Schema.Struct({
322 errorContext: Schema.NullOr(Schema.Literals(["check", "download", "install"])),324 errorContext: Schema.NullOr(Schema.Literals(["check", "download", "install"])),
323 canRetry: Schema.Boolean,325 canRetry: Schema.Boolean,
324 notAppliedVersion: Schema.optionalKey(Schema.String),326 notAppliedVersion: Schema.optionalKey(Schema.String),
327 installKind: Schema.optionalKey(Schema.Literals(["ui", "app"])),
328 uiVersion: Schema.optionalKey(Schema.String),
325});329});
326330
327/** The desktop app's `t3` command on PATH, managed from Settings. */331/** The desktop app's `t3` command on PATH, managed from Settings. */
packages/shared/package.json+4
...@@ -3,6 +3,10 @@...@@ -3,6 +3,10 @@
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "exports": {5 "exports": {
6 "./forkUpdateDirectory": {
7 "types": "./src/forkUpdateDirectory.ts",
8 "import": "./src/forkUpdateDirectory.ts"
9 },
6 "./githubMarkdownLinks": {10 "./githubMarkdownLinks": {
7 "types": "./src/githubMarkdownLinks.ts",11 "types": "./src/githubMarkdownLinks.ts",
8 "import": "./src/githubMarkdownLinks.ts"12 "import": "./src/githubMarkdownLinks.ts"
packages/shared/src/forkUpdateDirectory.test.ts created+16
...@@ -0,0 +1,16 @@
1import { expect, it } from "vite-plus/test";
2import { latestForkRelease } from "./forkUpdateDirectory.ts";
3
4it("selects the newest immutable release regardless of upload completion order", () => {
5 const listing = {
6 files: [
7 { href: "0.0.2000000001.json" },
8 { href: "0.0.2000000000.json" },
9 { href: "0.0.3000000000.json.PARTIAL" },
10 { href: "../0.0.4000000000.json" },
11 { href: "0.0.5000000000-mac.yml" },
12 ],
13 };
14 expect(latestForkRelease(listing)).toBe("0.0.2000000001");
15 expect(latestForkRelease({ files: [] })).toBeUndefined();
16});
packages/shared/src/forkUpdateDirectory.ts created+53
...@@ -0,0 +1,53 @@
1import * as Schema from "effect/Schema";
2
3const ForkVersion = Schema.String.check(Schema.isPattern(/^0\.0\.[0-9]+$/));
4
5export const ForkReleaseListing = Schema.Struct({
6 files: Schema.Array(Schema.Struct({ href: Schema.String })),
7});
8
9export function latestForkRelease(listing: typeof ForkReleaseListing.Type): string | undefined {
10 let latest: string | undefined;
11 for (const { href } of listing.files) {
12 const version = /^(0\.0\.[0-9]+)\.json$/.exec(href)?.[1];
13 if (version && (!latest || BigInt(version.slice(4)) > BigInt(latest.slice(4))))
14 latest = version;
15 }
16 return latest;
17}
18
19export const FORK_RUNTIME_MANIFEST_FILE = "knife-runtime.json";
20export const ForkRuntimeManifest = Schema.Struct({
21 version: ForkVersion,
22 sha256: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
23 shell: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
24});
25
26export const ForkArtifact = Schema.Struct({
27 file: Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)),
28 sha256: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
29 size: Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)),
30});
31
32export const ForkReleaseManifest = Schema.Struct({
33 version: ForkVersion,
34 ui: ForkArtifact,
35 assets: ForkArtifact,
36 server: Schema.Struct({
37 ...ForkArtifact.fields,
38 version: ForkVersion,
39 runtime: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
40 }),
41 desktop: Schema.Record(
42 Schema.String,
43 Schema.Struct({
44 version: ForkVersion,
45 ui: ForkArtifact,
46 assets: ForkArtifact,
47 shell: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
48 runtime: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
49 code: ForkArtifact,
50 app: Schema.Struct({ ...ForkArtifact.fields, version: ForkVersion }),
51 }),
52 ),
53});
scripts/build-desktop-artifact.test.ts+168-1
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24 createStagePatchedDependencies,24 createStagePatchedDependencies,
25 readWorkspaceConfig,25 readWorkspaceConfig,
26 createBuildConfig,26 createBuildConfig,
27 exportForkResources,
27 DESKTOP_ELECTRON_LANGUAGES,28 DESKTOP_ELECTRON_LANGUAGES,
28 DESKTOP_FILE_EXCLUSIONS,29 DESKTOP_FILE_EXCLUSIONS,
29 DESKTOP_EXTRA_RESOURCES,30 DESKTOP_EXTRA_RESOURCES,
...@@ -98,6 +99,54 @@ import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";...@@ -98,6 +99,54 @@ import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
98import * as HostProcess from "@t3tools/shared/HostProcess";99import * as HostProcess from "@t3tools/shared/HostProcess";
99import { symlinksSupported } from "@t3tools/shared/testing/symlinks";100import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
100101
102it.effect.each(["linux", "win"] as const)(
103 "exports the actual %s packaged archives and their native sidecars",
104 (platform) =>
105 Effect.scoped(
106 Effect.gen(function* () {
107 const fs = yield* FileSystem.FileSystem;
108 const path = yield* Path.Path;
109 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
110 const root = yield* fs.makeTempDirectoryScoped();
111 const stageDistDir = path.join(root, "dist");
112 const resources = path.join(stageDistDir, `${platform}-unpacked/resources`);
113 const files = [
114 "app.asar",
115 "app.asar.unpacked/native.node",
116 ...(platform === "win" ? ["server.asar", "server.asar.unpacked/server.node"] : []),
117 ];
118 for (const file of [...files, "unrelated-electron-resource"]) {
119 yield* fs.makeDirectory(path.dirname(path.join(resources, file)), { recursive: true });
120 yield* fs.writeFileString(path.join(resources, file), file);
121 }
122 const archive = yield* exportForkResources({
123 stageDistDir,
124 outputDir: path.join(root, "output"),
125 platform,
126 arch: "x64",
127 serverRuntime: "a".repeat(64),
128 });
129 assert.equal(
130 path.basename(archive),
131 `knife-resources-${platform === "win" ? "win32" : platform}-x64.tar.gz`,
132 );
133 const extracted = path.join(root, "extracted");
134 yield* fs.makeDirectory(extracted);
135 const child = yield* spawner.spawn(
136 ChildProcess.make("tar", ["-xzf", archive, "-C", extracted]),
137 );
138 assert.equal(Number(yield* child.exitCode), 0);
139 for (const file of files)
140 assert.equal(yield* fs.readFileString(path.join(extracted, file)), file);
141 assert.equal(
142 yield* fs.readFileString(path.join(extracted, "knife-server-runtime")),
143 "a".repeat(64),
144 );
145 assert.equal(yield* fs.exists(path.join(extracted, "unrelated-electron-resource")), false);
146 }),
147 ).pipe(Effect.provide(NodeServices.layer)),
148);
149
101// Keeps pnpm from auto-installing TypeScript, a types-only peer, into the app.150// Keeps pnpm from auto-installing TypeScript, a types-only peer, into the app.
102const stagePackageExtensions = {151const stagePackageExtensions = {
103 "electron-webauthn": { peerDependenciesMeta: { typescript: { optional: true } } },152 "electron-webauthn": { peerDependenciesMeta: { typescript: { optional: true } } },
...@@ -267,6 +316,77 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu...@@ -267,6 +316,77 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu
267});316});
268317
269it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {318it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
319 it.effect("packages a signed private feed instead of the upstream release channel", () =>
320 Effect.gen(function* () {
321 const config = yield* createBuildConfig(
322 "mac",
323 "zip",
324 "0.0.1791936000",
325 false,
326 false,
327 undefined,
328 undefined,
329 );
330 assert.deepStrictEqual(config.publish, [
331 { provider: "generic", url: "https://updates.example.test/fork/?ls=" },
332 ]);
333 assert.equal(config.forceCodeSigning, true);
334 assert.containsSubset(config.mac, {
335 identity: "Apple Development: Private Owner (TESTTEAM)",
336 type: "development",
337 });
338 assert.property(config.mac, "sign");
339 assert.notProperty(config.mac, "provisioningProfile");
340 }).pipe(
341 Effect.provide(
342 ConfigProvider.layer(
343 ConfigProvider.fromEnv({
344 env: {
345 GITHUB_REPOSITORY: "pingdotgg/t3code",
346 T3CODE_DESKTOP_UPDATE_URL: "https://updates.example.test/fork/",
347 T3CODE_DESKTOP_MAC_SIGN_IDENTITY: "Apple Development: Private Owner (TESTTEAM)",
348 },
349 }),
350 ),
351 ),
352 ),
353 );
354
355 it.effect("notarizes Developer ID fork builds without requiring passkey provisioning", () =>
356 Effect.gen(function* () {
357 const config = yield* createBuildConfig(
358 "mac",
359 "zip",
360 "0.0.1791936000",
361 false,
362 false,
363 undefined,
364 undefined,
365 );
366
367 assert.equal(config.forceCodeSigning, true);
368 assert.containsSubset(config.mac, {
369 identity: "A".repeat(40),
370 type: "distribution",
371 hardenedRuntime: true,
372 notarize: true,
373 });
374 assert.property(config.mac, "sign");
375 assert.notProperty(config.mac, "provisioningProfile");
376 }).pipe(
377 Effect.provide(
378 ConfigProvider.layer(
379 ConfigProvider.fromEnv({
380 env: {
381 T3CODE_DESKTOP_MAC_SIGN_IDENTITY: "A".repeat(40),
382 T3CODE_DESKTOP_MAC_SIGN_TYPE: "distribution",
383 },
384 }),
385 ),
386 ),
387 ),
388 );
389
270 it("resolves the dedicated nightly updater channel from nightly versions", () => {390 it("resolves the dedicated nightly updater channel from nightly versions", () => {
271 assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly");391 assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly");
272 assert.equal(resolveDesktopUpdateChannel("0.0.17"), "latest");392 assert.equal(resolveDesktopUpdateChannel("0.0.17"), "latest");
...@@ -1608,6 +1728,53 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1608,6 +1728,53 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1608 );1728 );
1609 });1729 });
16101730
1731 it.effect("checks the isolated Windows bundle through an explicit Wine runtime", () => {
1732 const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = [];
1733 const spawnerLayer = Layer.succeed(
1734 ChildProcessSpawner.ChildProcessSpawner,
1735 ChildProcessSpawner.make((command) => {
1736 if (!ChildProcess.isStandardCommand(command)) {
1737 return Effect.die(new Error("Expected a standard command"));
1738 }
1739 commands.push(command);
1740 return Effect.succeed(mockProcess(0));
1741 }),
1742 );
1743 return Effect.scoped(
1744 Effect.gen(function* () {
1745 const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true });
1746 yield* validateWindowsPackagedPayload({
1747 stageDistDir: fixture.stageDistDir,
1748 appExecutableName: fixture.appExecutableName,
1749 targetArch: "x64",
1750 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1751 });
1752 const probes = commands.filter((command) => command.command === "/tools/wine");
1753 assert.lengthOf(probes, 1);
1754 const [isolated] = probes;
1755 if (isolated === undefined) return assert.fail("The Windows bundle probe must run");
1756 assert.isTrue(
1757 probes.every((command) => command.args[0]?.endsWith(fixture.appExecutableName)),
1758 );
1759 assert.equal(isolated.args.at(-1), "--version");
1760 assert.isTrue(
1761 isolated.args.some((arg) => arg.startsWith("Z:\\") && arg.endsWith("bin.mjs")),
1762 );
1763 }),
1764 ).pipe(
1765 Effect.provide(
1766 Layer.mergeAll(
1767 spawnerLayer,
1768 Layer.succeed(HostProcess.Platform, "darwin"),
1769 Layer.succeed(HostProcess.Architecture, "arm64"),
1770 ConfigProvider.layer(
1771 ConfigProvider.fromEnv({ env: { T3CODE_DESKTOP_WINDOWS_RUNTIME: "/tools/wine" } }),
1772 ),
1773 ),
1774 ),
1775 );
1776 });
1777
1611 it.effect("refuses a Linux build on a host that cannot build the browser secret helper", () => {1778 it.effect("refuses a Linux build on a host that cannot build the browser secret helper", () => {
1612 const commands: Array<{ readonly command: string }> = [];1779 const commands: Array<{ readonly command: string }> = [];
1613 const spawnerLayer = Layer.succeed(1780 const spawnerLayer = Layer.succeed(
...@@ -1670,7 +1837,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1670,7 +1837,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1670 });1837 });
16711838
1672 assert.isFalse(1839 assert.isFalse(
1673 commands.some((command) => command.options.env?.ELECTRON_RUN_AS_NODE === "1"),1840 commands.some((command) => command.command.endsWith(fixture.appExecutableName)),
1674 );1841 );
1675 assert.isTrue(1842 assert.isTrue(
1676 commands.some(1843 commands.some(
scripts/build-desktop-artifact.ts+144-24
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1#!/usr/bin/env node1#!/usr/bin/env node
2import { fingerprintForkRuntime, fingerprintForkServerRuntime } from "./lib/fork-runtime.ts";
3import { FORK_RUNTIME_MANIFEST_FILE } from "@t3tools/shared/forkUpdateDirectory";
2// @effect-diagnostics nodeBuiltinImport:off - Node's typed junction API avoids Windows symlink privileges while keeping the probe isolated.4// @effect-diagnostics nodeBuiltinImport:off - Node's typed junction API avoids Windows symlink privileges while keeping the probe isolated.
35
4import * as NodeFSP from "node:fs/promises";6import * as NodeFSP from "node:fs/promises";
...@@ -845,23 +847,19 @@ const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* (...@@ -845,23 +847,19 @@ const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* (
845 return { stdout, stderr, exitCode } as const;847 return { stdout, stderr, exitCode } as const;
846});848});
847849
848const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRoot: string) {850const resolveCommitHash = Effect.fn("resolveCommitHash")(function* (repoRoot: string) {
849 const result = yield* spawnAndCollectOutput(851 const configuredHash = yield* Config.String("T3CODE_COMMIT_HASH").pipe(Config.option);
850 ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], {852 const output = yield* Option.match(configuredHash, {
851 cwd: repoRoot,853 onSome: Effect.succeed,
852 }),854 onNone: () =>
853 ).pipe(855 spawnAndCollectOutput(
854 Effect.orElseSucceed(() => ({856 ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], { cwd: repoRoot }),
855 stdout: "",857 ).pipe(
856 stderr: "",858 Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")),
857 exitCode: 1,859 Effect.orElseSucceed(() => ""),
858 })),860 ),
859 );861 });
860862 const hash = output.trim();
861 if (result.exitCode !== 0) {
862 return "unknown";
863 }
864 const hash = result.stdout.trim();
865 if (!/^[0-9a-f]{7,40}$/i.test(hash)) {863 if (!/^[0-9a-f]{7,40}$/i.test(hash)) {
866 return "unknown";864 return "unknown";
867 }865 }
...@@ -2158,7 +2156,11 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin...@@ -2158,7 +2156,11 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin
2158);2156);
21592157
2160const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")(2158const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")(
2161 function* (input: { readonly asarPath: string; readonly verbose: boolean }) {2159 function* (input: {
2160 readonly asarPath: string;
2161 readonly executablePath: string;
2162 readonly verbose: boolean;
2163 }) {
2162 const fs = yield* FileSystem.FileSystem;2164 const fs = yield* FileSystem.FileSystem;
2163 const path = yield* Path.Path;2165 const path = yield* Path.Path;
21642166
...@@ -2206,14 +2208,23 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel...@@ -2206,14 +2208,23 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel
2206 // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node2208 // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node
2207 // and the bun adapters are covered by the shared runtime-external closure2209 // and the bun adapters are covered by the shared runtime-external closure
2208 // and emitted-bundle checks.2210 // and emitted-bundle checks.
2211 const wine = Option.getOrUndefined(
2212 yield* Config.String("T3CODE_DESKTOP_WINDOWS_RUNTIME").pipe(Config.option),
2213 );
2214 const entry = wine === undefined ? entryPoint : `Z:${entryPoint.replaceAll("/", "\\")}`;
2209 yield* runCommand(2215 yield* runCommand(
2210 ChildProcess.make(2216 ChildProcess.make(
2211 process.execPath,2217 wine ?? process.execPath,
2212 // --no-global-search-paths because clearing NODE_PATH is not enough:2218 // --no-global-search-paths because clearing NODE_PATH is not enough:
2213 // CommonJS resolution still falls back to $HOME/.node_modules,2219 // CommonJS resolution still falls back to $HOME/.node_modules,
2214 // $HOME/.node_libraries and the install prefix, so a globally installed2220 // $HOME/.node_libraries and the install prefix, so a globally installed
2215 // copy of a missing dependency would quietly satisfy this check.2221 // copy of a missing dependency would quietly satisfy this check.
2216 ["--no-global-search-paths", entryPoint, "--version"],2222 [
2223 ...(wine === undefined ? [] : [input.executablePath]),
2224 "--no-global-search-paths",
2225 entry,
2226 "--version",
2227 ],
2217 {2228 {
2218 cwd: probeApp,2229 cwd: probeApp,
2219 stdout: "pipe",2230 stdout: "pipe",
...@@ -2221,7 +2232,7 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel...@@ -2221,7 +2232,7 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel
2221 // NODE_PATH would let a createRequire call inside the bundle resolve2232 // NODE_PATH would let a createRequire call inside the bundle resolve
2222 // a missing external from outside the packaged tree, which is the2233 // a missing external from outside the packaged tree, which is the
2223 // whole thing this is trying to rule out.2234 // whole thing this is trying to rule out.
2224 env: { ...process.env, NODE_PATH: "" },2235 env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", NODE_PATH: "" },
2225 },2236 },
2226 ),2237 ),
2227 {2238 {
...@@ -2800,8 +2811,12 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -2800,8 +2811,12 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
2800 };2811 };
2801 const updateChannel = resolveDesktopUpdateChannel(version);2812 const updateChannel = resolveDesktopUpdateChannel(version);
2802 if (!isDesktopPreviewVersion(version)) {2813 if (!isDesktopPreviewVersion(version)) {
2814 const updateUrl = yield* Config.URL("T3CODE_DESKTOP_UPDATE_URL").pipe(Config.option);
2803 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);2815 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);
2804 if (publishConfig) {2816 if (Option.isSome(updateUrl)) {
2817 updateUrl.value.searchParams.set("ls", "");
2818 buildConfig.publish = [{ provider: "generic", url: updateUrl.value.href }];
2819 } else if (publishConfig) {
2805 buildConfig.publish = [publishConfig];2820 buildConfig.publish = [publishConfig];
2806 } else if (mockUpdates) {2821 } else if (mockUpdates) {
2807 buildConfig.publish = [2822 buildConfig.publish = [
...@@ -2816,6 +2831,14 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -2816,6 +2831,14 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
2816 if (platform === "mac") {2831 if (platform === "mac") {
2817 const path = yield* Path.Path;2832 const path = yield* Path.Path;
2818 const repoRoot = yield* RepoRoot;2833 const repoRoot = yield* RepoRoot;
2834 const configuredIdentity = yield* Config.String("T3CODE_DESKTOP_MAC_SIGN_IDENTITY").pipe(
2835 Config.option,
2836 );
2837 const identity = Option.getOrUndefined(configuredIdentity)?.trim();
2838 const signingType = yield* Config.Literals(
2839 ["development", "distribution"],
2840 "T3CODE_DESKTOP_MAC_SIGN_TYPE",
2841 ).pipe(Config.withDefault("development"));
2819 buildConfig.mac = {2842 buildConfig.mac = {
2820 target: target === "dmg" ? [target, "zip"] : [target],2843 target: target === "dmg" ? [target, "zip"] : [target],
2821 icon: "icon.icns",2844 icon: "icon.icns",
...@@ -2851,7 +2874,15 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -2851,7 +2874,15 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
2851 role: "Viewer",2874 role: "Viewer",
2852 },2875 },
2853 ],2876 ],
2854 ...(signed ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}),2877 ...(signed || identity ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}),
2878 ...(identity
2879 ? {
2880 identity,
2881 type: signingType,
2882 hardenedRuntime: true,
2883 notarize: signingType === "distribution",
2884 }
2885 : {}),
2855 ...(macPasskeySigning2886 ...(macPasskeySigning
2856 ? {2887 ? {
2857 entitlements: macPasskeySigning.entitlementsPath,2888 entitlements: macPasskeySigning.entitlementsPath,
...@@ -2859,6 +2890,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -2859,6 +2890,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
2859 }2890 }
2860 : {}),2891 : {}),
2861 };2892 };
2893 if (identity) buildConfig.forceCodeSigning = true;
2862 }2894 }
28632895
2864 if (platform === "mac" && target === "dmg") {2896 if (platform === "mac" && target === "dmg") {
...@@ -3524,6 +3556,7 @@ export const validateWindowsPackagedPayload = Effect.fn(...@@ -3524,6 +3556,7 @@ export const validateWindowsPackagedPayload = Effect.fn(
35243556
3525 yield* verifyPackagedBundleIsSelfContained({3557 yield* verifyPackagedBundleIsSelfContained({
3526 asarPath,3558 asarPath,
3559 executablePath: path.join(packagedAppDir, input.appExecutableName),
3527 verbose: input.verbose ?? false,3560 verbose: input.verbose ?? false,
3528 });3561 });
35293562
...@@ -3533,6 +3566,53 @@ export const validateWindowsPackagedPayload = Effect.fn(...@@ -3533,6 +3566,53 @@ export const validateWindowsPackagedPayload = Effect.fn(
3533 return { packagedAppDir, fileCount, unpackedFiles } as const;3566 return { packagedAppDir, fileCount, unpackedFiles } as const;
3534});3567});
35353568
3569export const exportForkResources = Effect.fn("desktopArtifact.exportForkResources")(
3570 function* (input: {
3571 readonly stageDistDir: string;
3572 readonly outputDir: string;
3573 readonly platform: "linux" | "win";
3574 readonly arch: typeof BuildArch.Type;
3575 readonly serverRuntime: string;
3576 }) {
3577 const fs = yield* FileSystem.FileSystem;
3578 const path = yield* Path.Path;
3579 const entries = yield* fs.readDirectory(input.stageDistDir);
3580 const unpacked = entries.find((entry) => entry.endsWith("-unpacked"));
3581 const resources = path.join(
3582 input.stageDistDir,
3583 unpacked ?? `${input.platform}-unpacked`,
3584 "resources",
3585 );
3586 const required = input.platform === "win" ? ["app.asar", "server.asar"] : ["app.asar"];
3587 const members: string[] = [];
3588 for (const file of required) {
3589 yield* fs.stat(path.join(resources, file));
3590 members.push(file);
3591 if (yield* fs.exists(path.join(resources, `${file}.unpacked`)))
3592 members.push(`${file}.unpacked`);
3593 }
3594 const platform = input.platform === "win" ? "win32" : "linux";
3595 const archive = path.join(input.outputDir, `knife-resources-${platform}-${input.arch}.tar.gz`);
3596 const metadata = yield* fs.makeTempDirectoryScoped();
3597 yield* fs.writeFileString(path.join(metadata, "knife-server-runtime"), input.serverRuntime);
3598 yield* fs.makeDirectory(input.outputDir, { recursive: true });
3599 yield* runCommand(
3600 ChildProcess.make("tar", [
3601 "-czf",
3602 archive,
3603 "-C",
3604 resources,
3605 ...members,
3606 "-C",
3607 metadata,
3608 "knife-server-runtime",
3609 ]),
3610 { label: "Archive desktop resources for publishing", verbose: false },
3611 );
3612 return archive;
3613 },
3614);
3615
3536const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (3616const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3537 options: ResolvedBuildOptions,3617 options: ResolvedBuildOptions,
3538) {3618) {
...@@ -3611,7 +3691,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3611,7 +3691,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
36113691
3612 const appVersion = options.version ?? serverPackageJson.version;3692 const appVersion = options.version ?? serverPackageJson.version;
3613 const iconAssets = resolveDesktopBuildIconAssets(appVersion);3693 const iconAssets = resolveDesktopBuildIconAssets(appVersion);
3614 const commitHash = yield* resolveGitCommitHash(repoRoot);3694 const commitHash = yield* resolveCommitHash(repoRoot);
3615 const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped;3695 const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped;
3616 const stageRoot = yield* mkdir({3696 const stageRoot = yield* mkdir({
3617 prefix: `t3code-desktop-${options.platform}-stage-`,3697 prefix: `t3code-desktop-${options.platform}-stage-`,
...@@ -3964,6 +4044,32 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3964,6 +4044,32 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3964 });4044 });
3965 }4045 }
39664046
4047 {
4048 const runtime = yield* Effect.tryPromise(async () => {
4049 const input = {
4050 platform: options.platform,
4051 arch: options.arch,
4052 signingFiles:
4053 options.platform === "mac"
4054 ? [
4055 path.join(repoRoot, "scripts/sign-macos.ts"),
4056 ...(macEntitlementsPath ? [macEntitlementsPath] : []),
4057 ...(macPasskeySigning ? [macPasskeySigning.provisioningProfilePath] : []),
4058 ]
4059 : [],
4060 ...(options.platform === "win" ? { serverDirectory: path.join(stageRoot, "server") } : {}),
4061 };
4062 return {
4063 sha256: await fingerprintForkRuntime(stageAppDir, input),
4064 shell: await fingerprintForkRuntime(stageAppDir, { ...input, shellOnly: true }),
4065 };
4066 });
4067 yield* fs.writeFileString(
4068 path.join(stageAppDir, FORK_RUNTIME_MANIFEST_FILE),
4069 yield* encodeJsonString({ version: appVersion, ...runtime }),
4070 );
4071 }
4072
3967 // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="")4073 // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="")
3968 // as enabled, so copy the host env and scrub empty values instead of relying4074 // as enabled, so copy the host env and scrub empty values instead of relying
3969 // on `extendEnv` merging.4075 // on `extendEnv` merging.
...@@ -4076,6 +4182,20 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -4076,6 +4182,20 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
4076 yield* fs.makeDirectory(options.outputDir, { recursive: true });4182 yield* fs.makeDirectory(options.outputDir, { recursive: true });
40774183
4078 const copiedArtifacts: string[] = [];4184 const copiedArtifacts: string[] = [];
4185 if (
4186 options.platform !== "mac" &&
4187 (yield* Config.Boolean("T3CODE_DESKTOP_FORK_RESOURCES").pipe(Config.withDefault(false)))
4188 ) {
4189 copiedArtifacts.push(
4190 yield* exportForkResources({
4191 stageDistDir,
4192 outputDir: options.outputDir,
4193 platform: options.platform,
4194 arch: options.arch,
4195 serverRuntime: yield* Effect.tryPromise(() => fingerprintForkServerRuntime(repoRoot)),
4196 }),
4197 );
4198 }
4079 for (const entry of stageEntries) {4199 for (const entry of stageEntries) {
4080 const from = path.join(stageDistDir, entry);4200 const from = path.join(stageDistDir, entry);
4081 const stat = yield* fs.stat(from).pipe(Effect.orElseSucceed(() => null));4201 const stat = yield* fs.stat(from).pipe(Effect.orElseSucceed(() => null));
scripts/container-runtime.test.ts created+460
...@@ -0,0 +1,460 @@
1// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Exercises real HTTP bodies and tar archives, including deterministic connection interruption.
2import * as NodeAssert from "node:assert/strict";
3import * as NodeCrypto from "node:crypto";
4import * as NodeChildProcess from "node:child_process";
5import * as NodeFSP from "node:fs/promises";
6import * as NodeHttp from "node:http";
7import * as NodeOS from "node:os";
8import * as NodePath from "node:path";
9import * as NodeUtil from "node:util";
10import * as NodeURL from "node:url";
11import { afterEach, it, vi } from "vite-plus/test";
12
13import { prepareRuntime } from "./container-runtime.ts";
14
15const run = NodeUtil.promisify(NodeChildProcess.execFile);
16const cleanups: Array<() => Promise<void>> = [];
17
18afterEach(async () => {
19 vi.restoreAllMocks();
20 for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
21});
22
23async function fixture() {
24 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "knife-container-"));
25 cleanups.push(() => NodeFSP.rm(root, { recursive: true, force: true }));
26 const baseDirectory = NodePath.join(root, "base");
27 const cacheDirectory = NodePath.join(root, "cache");
28 await NodeFSP.mkdir(NodePath.join(baseDirectory, "apps/server/node_modules/container-native"), {
29 recursive: true,
30 });
31 await NodeFSP.writeFile(NodePath.join(baseDirectory, "knife-server-runtime"), "a".repeat(64));
32 await NodeFSP.writeFile(
33 NodePath.join(baseDirectory, "apps/server/node_modules/container-native/package.json"),
34 '{"main":"index.js"}',
35 );
36 await NodeFSP.writeFile(
37 NodePath.join(baseDirectory, "apps/server/node_modules/container-native/index.js"),
38 'module.exports = "native dependency resolved";',
39 );
40 const serverRoot = NodePath.join(root, "server");
41 const uiRoot = NodePath.join(root, "ui");
42 const assetsRoot = NodePath.join(root, "assets");
43 await NodeFSP.mkdir(NodePath.join(serverRoot, "apps/server/dist"), { recursive: true });
44 await NodeFSP.mkdir(uiRoot);
45 await NodeFSP.mkdir(NodePath.join(assetsRoot, "fonts"), { recursive: true });
46 await NodeFSP.writeFile(NodePath.join(assetsRoot, "fonts/ui.woff2"), "static font bytes");
47 await NodeFSP.writeFile(
48 NodePath.join(serverRoot, "apps/server/dist/bin.mjs"),
49 'import native from "container-native"; console.log(native);',
50 );
51 await NodeFSP.writeFile(NodePath.join(uiRoot, "index.html"), "<html>latest client</html>");
52
53 const archive = async (name: string, directory: string, members: string[]) => {
54 const file = `${name}.tar.gz`;
55 const target = NodePath.join(root, file);
56 await run("tar", ["-czf", target, "-C", directory, ...members]);
57 const bytes = await NodeFSP.readFile(target);
58 return {
59 artifact: {
60 file,
61 size: bytes.length,
62 sha256: NodeCrypto.createHash("sha256").update(bytes).digest("hex"),
63 },
64 bytes,
65 };
66 };
67 const server = await archive("server", serverRoot, ["."]);
68 const ui = await archive("ui", uiRoot, ["."]);
69 const assets = await archive("assets", assetsRoot, ["."]);
70 const release = {
71 version: "0.0.123",
72 server: { ...server.artifact, version: "0.0.123", runtime: "a".repeat(64) },
73 ui: ui.artifact,
74 assets: assets.artifact,
75 desktop: {},
76 };
77 const requests: string[] = [];
78 const statuses = new Map<string, number>();
79 const interrupted = new Map<string, NodeHttp.ServerResponse | undefined>();
80 const responses = new Map<string, Buffer>([
81 [
82 "/?ls=",
83 Buffer.from(
84 JSON.stringify({
85 files: [
86 { href: "0.0.9.json" },
87 { href: "0.0.123.json" },
88 { href: "0.0.100-mac.yml" },
89 { href: "notes.json" },
90 ],
91 }),
92 ),
93 ],
94 ["/0.0.123.json", Buffer.from(JSON.stringify(release))],
95 ["/server.tar.gz", server.bytes],
96 ["/ui.tar.gz", ui.bytes],
97 ["/assets.tar.gz", assets.bytes],
98 ]);
99 const listener = NodeHttp.createServer((request, response) => {
100 requests.push(request.url ?? "");
101 const bytes = responses.get(request.url ?? "");
102 if (bytes && interrupted.has(request.url ?? "")) {
103 interrupted.set(request.url ?? "", response);
104 response.writeHead(200, { "Content-Length": bytes.length });
105 response.write(bytes.subarray(0, Math.max(1, Math.floor(bytes.length / 2))));
106 return;
107 }
108 response.writeHead(statuses.get(request.url ?? "") ?? (bytes ? 200 : 404));
109 response.end(bytes);
110 });
111 await new Promise<void>((resolve) => listener.listen(0, "127.0.0.1", resolve));
112 cleanups.push(
113 () =>
114 new Promise<void>((resolve, reject) =>
115 listener.close((error) => (error ? reject(error) : resolve())),
116 ),
117 );
118 const address = listener.address();
119 if (!address || typeof address === "string") throw new Error("HTTP fixture has no port.");
120 return {
121 root,
122 input: { baseDirectory, cacheDirectory, feedUrl: `http://127.0.0.1:${address.port}` },
123 release,
124 responses,
125 requests,
126 statuses,
127 interrupted,
128 archive,
129 };
130}
131
132it("installs the published server and client, resolving native modules from the base image", async () => {
133 const { input, requests } = await fixture();
134 const entrypoint = await prepareRuntime(input);
135 const { stdout } = await run(process.execPath, [entrypoint]);
136 NodeAssert.equal(stdout.trim(), "native dependency resolved");
137 NodeAssert.equal(
138 await NodeFSP.readFile(
139 NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/index.html"),
140 "utf8",
141 ),
142 "<html>latest client</html>",
143 );
144 NodeAssert.equal(
145 await NodeFSP.readFile(NodePath.join(input.cacheDirectory, "current/version"), "utf8"),
146 "0.0.123",
147 );
148 await prepareRuntime(input);
149 NodeAssert.deepEqual(requests, [
150 "/?ls=",
151 "/0.0.123.json",
152 "/server.tar.gz",
153 "/assets.tar.gz",
154 "/ui.tar.gz",
155 "/?ls=",
156 "/0.0.123.json",
157 ]);
158});
159
160it("keeps the compiled server version when a newer feed manifest includes an older native build", async () => {
161 const { input, root, responses, release, archive } = await fixture();
162 await NodeFSP.writeFile(
163 NodePath.join(root, "server/apps/server/dist/bin.mjs"),
164 'if (process.env.T3CODE_VERSION_OVERRIDE !== "0.0.123") process.exit(23);',
165 );
166 const server = await archive("versioned-server", NodePath.join(root, "server"), ["."]);
167 const newer = {
168 ...release,
169 version: "0.0.124",
170 server: { ...release.server, ...server.artifact },
171 };
172 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] })));
173 responses.set("/0.0.124.json", Buffer.from(JSON.stringify(newer)));
174 responses.set("/versioned-server.tar.gz", server.bytes);
175
176 await prepareRuntime(input);
177
178 NodeAssert.equal(
179 await NodeFSP.readFile(NodePath.join(input.cacheDirectory, "current/version"), "utf8"),
180 "0.0.123",
181 );
182});
183
184it("keeps the installed runtime when a later archive fails its checksum", async () => {
185 const { input, responses, release } = await fixture();
186 const original = await prepareRuntime(input);
187 const changed = {
188 ...release,
189 version: "0.0.124",
190 server: { ...release.server, sha256: "f".repeat(64) },
191 };
192 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] })));
193 responses.set("/0.0.124.json", Buffer.from(JSON.stringify(changed)));
194 await NodeAssert.rejects(prepareRuntime(input), /checksum verification/);
195 NodeAssert.equal(
196 await NodeFSP.realpath(NodePath.join(input.cacheDirectory, "current/apps/server/dist/bin.mjs")),
197 await NodeFSP.realpath(original),
198 );
199 NodeAssert.equal(
200 (await NodeFSP.readdir(input.cacheDirectory)).some((name) => name.startsWith(".download-")),
201 false,
202 );
203});
204
205it("rejects native dependency changes before downloading either archive", async () => {
206 const { input, responses, release, requests } = await fixture();
207 responses.set(
208 "/0.0.123.json",
209 Buffer.from(
210 JSON.stringify({ ...release, server: { ...release.server, runtime: "b".repeat(64) } }),
211 ),
212 );
213 await NodeAssert.rejects(prepareRuntime(input), /newer container image/);
214 NodeAssert.deepEqual(requests, ["/?ls=", "/0.0.123.json"]);
215});
216
217it("rejects artifact paths that leave the public release directory", async () => {
218 const { input, responses, release } = await fixture();
219 responses.set(
220 "/0.0.123.json",
221 Buffer.from(JSON.stringify({ ...release, ui: { ...release.ui, file: "../ui.tar.gz" } })),
222 );
223 await NodeAssert.rejects(prepareRuntime(input), /ForkReleaseManifest|Expected|file|Invalid/);
224});
225
226it("lets the launcher use its installed runtime while the release share is unavailable", async () => {
227 const { input, statuses } = await fixture();
228 statuses.set("/?ls=", 503);
229 const result = await run(
230 process.execPath,
231 [NodeURL.fileURLToPath(new URL("./container-runtime.ts", import.meta.url))],
232 {
233 env: { ...process.env, KNIFE_UPDATE_URL: input.feedUrl },
234 },
235 );
236 NodeAssert.match(result.stderr, /Starting the installed runtime/);
237});
238
239it("keeps the installed runtime when downloaded server code cannot start", async () => {
240 const { input, root, responses, release } = await fixture();
241 const original = await prepareRuntime(input);
242 await NodeFSP.writeFile(
243 NodePath.join(root, "server/apps/server/dist/bin.mjs"),
244 'throw new Error("broken release");',
245 );
246 const archive = NodePath.join(root, "broken.tar.gz");
247 await run("tar", ["-czf", archive, "-C", NodePath.join(root, "server"), "."]);
248 const bytes = await NodeFSP.readFile(archive);
249 const changed = {
250 ...release,
251 version: "0.0.124",
252 server: {
253 ...release.server,
254 size: bytes.length,
255 sha256: NodeCrypto.createHash("sha256").update(bytes).digest("hex"),
256 },
257 };
258 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] })));
259 responses.set("/0.0.124.json", Buffer.from(JSON.stringify(changed)));
260 responses.set("/server.tar.gz", bytes);
261 await NodeAssert.rejects(prepareRuntime(input), /broken release/);
262 NodeAssert.equal(
263 await NodeFSP.realpath(NodePath.join(input.cacheDirectory, "current/apps/server/dist/bin.mjs")),
264 await NodeFSP.realpath(original),
265 );
266});
267
268it("downloads only changed UI or static assets and bounds the verified artifact cache", async () => {
269 const { input, root, responses, requests, release, archive } = await fixture();
270 const original = await prepareRuntime(input);
271 await NodeFSP.writeFile(NodePath.join(root, "ui/index.html"), "<html>updated client</html>");
272 const nextUi = await archive("next-ui", NodePath.join(root, "ui"), ["."]);
273 const next = {
274 ...release,
275 version: "0.0.124",
276 server: { ...release.server, version: "0.0.124" },
277 ui: nextUi.artifact,
278 };
279 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] })));
280 responses.set("/0.0.124.json", Buffer.from(JSON.stringify(next)));
281 responses.set("/next-ui.tar.gz", nextUi.bytes);
282 const nextEntrypoint = await prepareRuntime(input);
283 NodeAssert.notEqual(nextEntrypoint, original);
284 NodeAssert.equal(requests.filter((url) => url === "/server.tar.gz").length, 1);
285 NodeAssert.equal(requests.filter((url) => url === "/assets.tar.gz").length, 1);
286 NodeAssert.equal(requests.filter((url) => url === "/next-ui.tar.gz").length, 1);
287 NodeAssert.equal(
288 await NodeFSP.readFile(
289 NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/index.html"),
290 "utf8",
291 ),
292 "<html>updated client</html>",
293 );
294 NodeAssert.equal(
295 await NodeFSP.readFile(
296 NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/fonts/ui.woff2"),
297 "utf8",
298 ),
299 "static font bytes",
300 );
301
302 await NodeFSP.writeFile(NodePath.join(root, "assets/fonts/ui.woff2"), "updated font bytes");
303 const nextAssets = await archive("next-assets", NodePath.join(root, "assets"), ["."]);
304 const final = {
305 ...next,
306 version: "0.0.125",
307 server: { ...next.server, version: "0.0.125" },
308 assets: nextAssets.artifact,
309 };
310 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.125.json" }] })));
311 responses.set("/0.0.125.json", Buffer.from(JSON.stringify(final)));
312 responses.set("/next-assets.tar.gz", nextAssets.bytes);
313 const finalEntrypoint = await prepareRuntime(input);
314 NodeAssert.notEqual(finalEntrypoint, nextEntrypoint);
315 NodeAssert.equal(requests.filter((url) => url === "/server.tar.gz").length, 1);
316 NodeAssert.equal(requests.filter((url) => url === "/next-ui.tar.gz").length, 1);
317 NodeAssert.equal(requests.filter((url) => url === "/next-assets.tar.gz").length, 1);
318 NodeAssert.equal(
319 await NodeFSP.readFile(
320 NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/fonts/ui.woff2"),
321 "utf8",
322 ),
323 "updated font bytes",
324 );
325 const cached = await NodeFSP.readdir(NodePath.join(input.cacheDirectory, "artifacts"));
326 NodeAssert.deepEqual(
327 cached.sort(),
328 [final.server, final.assets, final.ui].map(({ sha256 }) => `${sha256}.tar.gz`).sort(),
329 );
330 NodeAssert.equal(
331 (await NodeFSP.readdir(input.cacheDirectory)).filter((name) => name.startsWith("release-"))
332 .length,
333 1,
334 );
335});
336
337it("prefetches updates without changing the running release, then activates on restart", async () => {
338 const { input, root, responses, requests, release, archive } = await fixture();
339 const original = await prepareRuntime(input);
340 const current = NodePath.join(input.cacheDirectory, "current");
341 const pointer = await NodeFSP.readlink(current);
342 let pending = original;
343 for (const version of ["0.0.124", "0.0.125"]) {
344 await NodeFSP.writeFile(NodePath.join(root, "ui/index.html"), `<html>${version}</html>`);
345 const nextUi = await archive(`ui-${version}`, NodePath.join(root, "ui"), ["."]);
346 const next = {
347 ...release,
348 version,
349 server: { ...release.server, version },
350 ui: nextUi.artifact,
351 };
352 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: `${version}.json` }] })));
353 responses.set(`/${version}.json`, Buffer.from(JSON.stringify(next)));
354 responses.set(`/${nextUi.artifact.file}`, nextUi.bytes);
355 const staged = await prepareRuntime({ ...input, activate: false });
356 NodeAssert.notEqual(staged, original);
357 if (pending !== original) await NodeAssert.rejects(NodeFSP.access(pending));
358 pending = staged;
359 NodeAssert.equal(await NodeFSP.readlink(current), pointer);
360 NodeAssert.equal(await NodeFSP.readFile(NodePath.join(current, "version"), "utf8"), "0.0.123");
361 NodeAssert.equal(
362 await NodeFSP.readFile(NodePath.join(current, "apps/server/dist/client/index.html"), "utf8"),
363 "<html>latest client</html>",
364 );
365 NodeAssert.equal(
366 (await run(process.execPath, [original])).stdout.trim(),
367 "native dependency resolved",
368 );
369 NodeAssert.equal(
370 (await NodeFSP.readdir(input.cacheDirectory)).filter((name) => name.startsWith("release-"))
371 .length,
372 2,
373 );
374 }
375 const downloads = requests.length;
376 NodeAssert.equal(await prepareRuntime(input), pending);
377 NodeAssert.deepEqual(requests.slice(downloads), ["/?ls=", "/0.0.125.json"]);
378 NodeAssert.equal(await NodeFSP.readFile(NodePath.join(current, "version"), "utf8"), "0.0.125");
379 await NodeAssert.rejects(NodeFSP.access(original));
380});
381
382it("preserves the running version when prefetching identical artifacts under a newer release", async () => {
383 const { input, responses, release } = await fixture();
384 await prepareRuntime(input);
385 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] })));
386 responses.set("/0.0.124.json", Buffer.from(JSON.stringify({ ...release, version: "0.0.124" })));
387 await prepareRuntime({ ...input, activate: false });
388 NodeAssert.equal(
389 await NodeFSP.readFile(NodePath.join(input.cacheDirectory, "current/version"), "utf8"),
390 "0.0.123",
391 );
392});
393
394it("lets the actual serve --version command exit with the background downloader loaded", async () => {
395 const { input, root, requests } = await fixture();
396 const result = await run(
397 process.execPath,
398 [
399 "--import",
400 NodeURL.fileURLToPath(new URL("./container-runtime.ts", import.meta.url)),
401 NodeURL.fileURLToPath(new URL("../apps/server/src/bin.ts", import.meta.url)),
402 "serve",
403 "--version",
404 ],
405 {
406 env: {
407 ...process.env,
408 KNIFE_UPDATE_URL: input.feedUrl,
409 T3CODE_HOME: NodePath.join(root, "home"),
410 },
411 timeout: 10_000,
412 },
413 );
414 NodeAssert.match(result.stdout, /^t3 v/);
415 NodeAssert.deepEqual(requests, []);
416});
417
418it.each(["/?ls=", "/0.0.124.json", "/server.tar.gz"])(
419 "classifies a mid-body disconnect at %s as a transient outage while preserving the installed release",
420 async (interruptedPath) => {
421 const { input, release, responses, interrupted } = await fixture();
422 const original = await prepareRuntime(input);
423 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] })));
424 responses.set(
425 "/0.0.124.json",
426 Buffer.from(
427 JSON.stringify({
428 ...release,
429 version: "0.0.124",
430 server: { ...release.server, sha256: "f".repeat(64) },
431 }),
432 ),
433 );
434 interrupted.set(interruptedPath, undefined);
435 const fetch = globalThis.fetch;
436 vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => {
437 const response = await fetch(url, options);
438 const requested = new URL(response.url);
439 interrupted.get(`${requested.pathname}${requested.search}`)?.destroy();
440 return response;
441 });
442 await NodeAssert.rejects(prepareRuntime(input), /release share is unavailable/);
443 NodeAssert.equal(
444 await NodeFSP.realpath(
445 NodePath.join(input.cacheDirectory, "current/apps/server/dist/bin.mjs"),
446 ),
447 await NodeFSP.realpath(original),
448 );
449 NodeAssert.equal(
450 (await NodeFSP.readdir(input.cacheDirectory)).some((name) => name.startsWith(".download-")),
451 false,
452 );
453 },
454);
455
456it("keeps malformed JSON fatal instead of treating it as a connection outage", async () => {
457 const { input, responses } = await fixture();
458 responses.set("/0.0.123.json", Buffer.from("{"));
459 await NodeAssert.rejects(prepareRuntime(input), SyntaxError);
460});
scripts/container-runtime.ts created+259
...@@ -0,0 +1,259 @@
1// @effect-diagnostics nodeBuiltinImport:off globalFetch:off globalConsole:off globalTimers:off - Container bootstrap runs before the Effect server starts.
2import * as NodeCrypto from "node:crypto";
3import * as NodeFS from "node:fs";
4import * as NodeFSP from "node:fs/promises";
5import * as NodePath from "node:path";
6import * as NodeStreamPromises from "node:stream/promises";
7import * as NodeChildProcess from "node:child_process";
8import * as NodeUtil from "node:util";
9import * as NodeURL from "node:url";
10import * as Schema from "effect/Schema";
11import {
12 ForkReleaseManifest,
13 ForkReleaseListing,
14 latestForkRelease,
15} from "@t3tools/shared/forkUpdateDirectory";
16
17const run = NodeUtil.promisify(NodeChildProcess.execFile);
18const decodeManifest = Schema.decodeUnknownSync(ForkReleaseManifest);
19const decodeListing = Schema.decodeUnknownSync(ForkReleaseListing);
20
21class FeedUnavailableError extends Error {
22 constructor(message = "The release share is unavailable. Starting the installed runtime.") {
23 super(message);
24 }
25}
26
27function rethrowBodyError(error: unknown): never {
28 if (
29 error instanceof TypeError ||
30 (error instanceof DOMException && ["AbortError", "TimeoutError"].includes(error.name))
31 ) {
32 throw new FeedUnavailableError();
33 }
34 throw error;
35}
36
37async function request(url: URL, timeout: number) {
38 const response = await fetch(url, {
39 signal: AbortSignal.timeout(timeout),
40 cache: "no-store",
41 }).catch(() => {
42 throw new FeedUnavailableError();
43 });
44 if (response.status >= 500) {
45 throw new FeedUnavailableError();
46 }
47 if (!response.ok) throw new Error(`Release share returned HTTP ${response.status}.`);
48 return response;
49}
50
51export async function prepareRuntime(input: {
52 feedUrl: string;
53 baseDirectory: string;
54 cacheDirectory: string;
55 activate?: boolean;
56}): Promise<string> {
57 const baseUrl = new URL(input.feedUrl.endsWith("/") ? input.feedUrl : `${input.feedUrl}/`);
58 if (baseUrl.protocol !== "https:" && baseUrl.hostname !== "127.0.0.1") {
59 throw new Error("KNIFE_UPDATE_URL must use HTTPS.");
60 }
61
62 const listingUrl = new URL(baseUrl);
63 listingUrl.searchParams.set("ls", "");
64 const listing = await request(listingUrl, 30_000);
65 const version = latestForkRelease(decodeListing(await listing.json().catch(rethrowBodyError)));
66 if (version === undefined) {
67 throw new FeedUnavailableError("No releases are published. Starting the installed runtime.");
68 }
69 const response = await request(new URL(`${version}.json`, baseUrl), 30_000);
70 const release = decodeManifest(await response.json().catch(rethrowBodyError));
71 if (release.version !== version)
72 throw new Error("The published release version does not match its filename.");
73 const runtime = (
74 await NodeFSP.readFile(NodePath.join(input.baseDirectory, "knife-server-runtime"), "utf8")
75 ).trim();
76 if (runtime !== release.server.runtime) {
77 throw new Error(
78 "The release needs a newer container image. Rebuild the image before restarting.",
79 );
80 }
81
82 const artifacts = [
83 ["server", release.server],
84 ["assets", release.assets],
85 ["ui", release.ui],
86 ] as const;
87 const identity = NodeCrypto.createHash("sha256")
88 .update(artifacts.map(([, { sha256 }]) => sha256).join("\0"))
89 .digest("hex");
90 const blobs = NodePath.join(input.cacheDirectory, "artifacts");
91 await NodeFSP.mkdir(blobs, { recursive: true });
92 const directory = NodePath.join(input.cacheDirectory, `release-${identity}`);
93 const entrypoint = NodePath.join(directory, "apps/server/dist/bin.mjs");
94 const ready = await NodeFSP.readFile(NodePath.join(directory, "version"), "utf8").catch(
95 () => null,
96 );
97 if (ready === null) {
98 const staging = await NodeFSP.mkdtemp(NodePath.join(input.cacheDirectory, ".download-"));
99 try {
100 for (const [kind, artifact] of artifacts) {
101 const archive = NodePath.join(blobs, `${artifact.sha256}.tar.gz`);
102 const cached = await NodeFSP.stat(archive).then(
103 (stat) => stat.isFile() && stat.size === artifact.size,
104 () => false,
105 );
106 if (!cached) {
107 const partial = NodePath.join(staging, `${kind}.tar.gz`);
108 const downloaded = await request(new URL(artifact.file, baseUrl), 180_000);
109 const body = downloaded.body;
110 if (!body) {
111 throw new Error(`Could not download the ${kind} archive (HTTP ${downloaded.status}).`);
112 }
113 const hash = NodeCrypto.createHash("sha256");
114 let size = 0;
115 await NodeStreamPromises.pipeline(
116 async function* () {
117 try {
118 for await (const chunk of body) {
119 size += chunk.length;
120 if (size > artifact.size)
121 throw new Error(`The ${kind} archive exceeds its published size.`);
122 hash.update(chunk);
123 yield chunk;
124 }
125 } catch (error) {
126 rethrowBodyError(error);
127 }
128 },
129 NodeFS.createWriteStream(partial, { flags: "wx" }),
130 );
131 if (size !== artifact.size || hash.digest("hex") !== artifact.sha256) {
132 throw new Error(`The ${kind} archive failed checksum verification.`);
133 }
134
135 await NodeFSP.rename(partial, archive);
136 }
137
138 const { stdout } = await run("tar", ["-tzf", archive]);
139 const members = stdout.split(/\r?\n/).filter(Boolean);
140 if (
141 members.some((member) => {
142 const normalized = member.replace(/^\.\//, "").replace(/\/$/, "");
143 return (
144 normalized.startsWith("/") ||
145 normalized.split("/").includes("..") ||
146 (kind === "server" &&
147 !["", ".", "apps", "apps/server", "apps/server/dist"].includes(normalized) &&
148 !normalized.startsWith("apps/server/dist/"))
149 );
150 })
151 ) {
152 throw new Error(`The ${kind} archive contains an unexpected path.`);
153 }
154 const destination =
155 kind === "server" ? staging : NodePath.join(staging, "apps/server/dist/client");
156 await NodeFSP.mkdir(destination, { recursive: true });
157 await run("tar", [
158 "-xzf",
159 archive,
160 "-C",
161 destination,
162 "--no-same-owner",
163 "--no-same-permissions",
164 ]);
165 }
166
167 await NodeFSP.access(NodePath.join(staging, "apps/server/dist/bin.mjs"));
168 await NodeFSP.access(NodePath.join(staging, "apps/server/dist/client/index.html"));
169 await NodeFSP.symlink(
170 NodePath.join(input.baseDirectory, "apps/server/node_modules"),
171 NodePath.join(staging, "node_modules"),
172 "dir",
173 );
174 await run(
175 process.execPath,
176 [NodePath.join(staging, "apps/server/dist/bin.mjs"), "--version"],
177 {
178 env: { ...process.env, T3CODE_VERSION_OVERRIDE: release.server.version },
179 timeout: 30_000,
180 },
181 );
182 await NodeFSP.writeFile(
183 NodePath.join(staging, "knife-server-runtime"),
184 release.server.runtime,
185 );
186 await NodeFSP.writeFile(NodePath.join(staging, "version"), release.server.version);
187 await NodeFSP.rm(directory, { recursive: true, force: true });
188 await NodeFSP.rename(staging, directory);
189 } finally {
190 await NodeFSP.rm(staging, { recursive: true, force: true });
191 }
192 }
193
194 const keepDirectories = new Set([NodePath.basename(directory)]);
195 if (input.activate !== false) {
196 await NodeFSP.writeFile(NodePath.join(directory, "version"), release.server.version);
197 const pointer = NodePath.join(input.cacheDirectory, ".current");
198 await NodeFSP.rm(pointer, { force: true });
199 await NodeFSP.symlink(directory, pointer, "dir");
200 await NodeFSP.rename(pointer, NodePath.join(input.cacheDirectory, "current"));
201 } else {
202 const active = await NodeFSP.realpath(NodePath.join(input.cacheDirectory, "current")).catch(
203 () => null,
204 );
205 if (active !== null) keepDirectories.add(NodePath.basename(active));
206 }
207 for (const name of await NodeFSP.readdir(input.cacheDirectory)) {
208 if (/^release-[a-f0-9]{64}$/.test(name) && !keepDirectories.has(name)) {
209 await NodeFSP.rm(NodePath.join(input.cacheDirectory, name), { recursive: true, force: true });
210 }
211 }
212 const keep = new Set(artifacts.map(([, { sha256 }]) => `${sha256}.tar.gz`));
213 for (const name of await NodeFSP.readdir(blobs)) {
214 if (/^[a-f0-9]{64}\.tar\.gz$/.test(name) && !keep.has(name)) {
215 await NodeFSP.rm(NodePath.join(blobs, name));
216 }
217 }
218 return entrypoint;
219}
220
221if (
222 process.argv[1] &&
223 NodeURL.fileURLToPath(import.meta.url) === NodePath.resolve(process.argv[1])
224) {
225 const feedUrl = process.env.KNIFE_UPDATE_URL;
226 if (feedUrl) {
227 await prepareRuntime({
228 feedUrl,
229 baseDirectory: "/opt/knife",
230 cacheDirectory: NodePath.join(process.env.T3CODE_HOME ?? "/data", "runtime/knife-container"),
231 }).catch((error: unknown) => {
232 console.error(
233 error instanceof Error ? error.message : "Could not prepare the container update.",
234 );
235 if (!(error instanceof FeedUnavailableError)) process.exitCode = 1;
236 });
237 }
238} else if (process.argv[2] === "serve" && process.env.KNIFE_UPDATE_URL) {
239 const feedUrl = process.env.KNIFE_UPDATE_URL;
240 let downloading = false;
241 setInterval(() => {
242 if (downloading) return;
243 downloading = true;
244 void prepareRuntime({
245 feedUrl,
246 baseDirectory: "/opt/knife",
247 cacheDirectory: NodePath.join(process.env.T3CODE_HOME ?? "/data", "runtime/knife-container"),
248 activate: false,
249 })
250 .catch((error: unknown) => {
251 console.error(
252 error instanceof Error ? error.message : "Could not download the container update.",
253 );
254 })
255 .finally(() => {
256 downloading = false;
257 });
258 }, 4 * 60_000).unref();
259}
scripts/lib/fork-runtime.test.ts created+187
...@@ -0,0 +1,187 @@
1// @effect-diagnostics nodeBuiltinImport:off - Exercises actual staged build inputs.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import { afterEach, beforeEach, expect, it } from "vite-plus/test";
6import { fingerprintForkRuntime, fingerprintForkServerRuntime } from "./fork-runtime.ts";
7
8let root: string;
9let signing: string;
10const fingerprint = () =>
11 fingerprintForkRuntime(root, { platform: "mac", arch: "arm64", signingFiles: [signing] });
12
13beforeEach(async () => {
14 root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "knife-runtime-"));
15 signing = NodePath.join(root, "sign.ts");
16 await NodeFSP.mkdir(NodePath.join(root, "apps/server/dist/client"), { recursive: true });
17 await NodeFSP.mkdir(NodePath.join(root, "apps/desktop/dist-electron"), { recursive: true });
18 await NodeFSP.mkdir(NodePath.join(root, "node_modules/native"), { recursive: true });
19 await NodeFSP.writeFile(signing, "signing policy");
20 await NodeFSP.writeFile(
21 NodePath.join(root, "package.json"),
22 JSON.stringify({
23 version: "0.0.1",
24 buildVersion: "0.0.1",
25 t3codeCommitHash: "one",
26 devDependencies: { electron: "44.4.5" },
27 build: { mac: { sign: signing } },
28 }),
29 );
30 await NodeFSP.writeFile(NodePath.join(root, "apps/server/dist/bin.mjs"), "server");
31 await NodeFSP.writeFile(NodePath.join(root, "apps/desktop/dist-electron/preload.cjs"), "preload");
32 await NodeFSP.writeFile(NodePath.join(root, "apps/desktop/dist-electron/boot.cjs"), "bootstrap");
33 await NodeFSP.writeFile(NodePath.join(root, "node_modules/native/binary.node"), "native");
34 for (const file of [
35 "scripts/container-runtime.ts",
36 "infra/container/knife",
37 "packages/shared/src/forkUpdateDirectory.ts",
38 ]) {
39 await NodeFSP.mkdir(NodePath.dirname(NodePath.join(root, file)), { recursive: true });
40 await NodeFSP.writeFile(NodePath.join(root, file), "stable bootstrap");
41 }
42});
43
44it("keeps the installed shell for backend, renderer, and runtime dependency changes", async () => {
45 const input = { platform: "mac", arch: "arm64", signingFiles: [signing], shellOnly: true };
46 const initial = await fingerprintForkRuntime(root, input);
47 for (const file of [
48 "apps/server/dist/bin.mjs",
49 "apps/desktop/dist-electron/preload.cjs",
50 "node_modules/native/binary.node",
51 ])
52 await NodeFSP.appendFile(NodePath.join(root, file), "new code");
53 expect(await fingerprintForkRuntime(root, input)).toBe(initial);
54 await NodeFSP.appendFile(
55 NodePath.join(root, "apps/desktop/dist-electron/boot.cjs"),
56 "new bootstrap",
57 );
58 expect(await fingerprintForkRuntime(root, input)).not.toBe(initial);
59});
60
61it("ignores package-manager launchers that are excluded from the packaged runtime", async () => {
62 const initial = await fingerprint();
63 await NodeFSP.mkdir(NodePath.join(root, "node_modules/.bin"));
64 await NodeFSP.writeFile(
65 NodePath.join(root, "node_modules/.bin/yaml"),
66 `#!/bin/sh\nexport NODE_PATH=${root}/node_modules`,
67 );
68 expect(await fingerprint()).toBe(initial);
69});
70
71it("fingerprints Windows server code without making renderer changes replace the shell", async () => {
72 const serverDirectory = NodePath.join(root, "server-sidecar");
73 for (const directory of ["apps/server/dist/client", "node_modules/native"]) {
74 await NodeFSP.mkdir(NodePath.join(serverDirectory, directory), { recursive: true });
75 }
76 await NodeFSP.writeFile(NodePath.join(serverDirectory, "apps/server/dist/bin.mjs"), "server");
77 await NodeFSP.writeFile(
78 NodePath.join(serverDirectory, "node_modules/native/binary.node"),
79 "native",
80 );
81 const sidecar = NodePath.join(root, "apps/desktop/prod-resources/windows-server/server.asar");
82 await NodeFSP.mkdir(NodePath.dirname(sidecar), { recursive: true });
83 await NodeFSP.writeFile(sidecar, "packed server and renderer");
84 const input = { platform: "win", arch: "x64", signingFiles: [], serverDirectory };
85 const initial = await fingerprintForkRuntime(root, input);
86 const shell = await fingerprintForkRuntime(root, { ...input, shellOnly: true });
87
88 await NodeFSP.writeFile(
89 NodePath.join(serverDirectory, "apps/server/dist/client/index.html"),
90 "new UI",
91 );
92 await NodeFSP.writeFile(sidecar, "repacked server and new renderer");
93 expect(await fingerprintForkRuntime(root, input)).toBe(initial);
94 expect(await fingerprintForkRuntime(root, { ...input, shellOnly: true })).toBe(shell);
95
96 await NodeFSP.appendFile(NodePath.join(serverDirectory, "apps/server/dist/bin.mjs"), "new code");
97 const changed = await fingerprintForkRuntime(root, input);
98 expect(changed).not.toBe(initial);
99 expect(await fingerprintForkRuntime(root, { ...input, shellOnly: true })).toBe(shell);
100 await NodeFSP.appendFile(
101 NodePath.join(serverDirectory, "node_modules/native/binary.node"),
102 "new native",
103 );
104 expect(await fingerprintForkRuntime(root, input)).not.toBe(changed);
105});
106
107it("matches the image to locked external dependencies, including their transitives", async () => {
108 const lock = `importers:
109 apps/server:
110 dependencies:
111 node-pty: {version: 1.0.0}
112 effect: {version: 4.0.0}
113packages:
114 node-pty@1.0.0: {resolution: {integrity: pty}}
115 native-helper@2.0.0: {resolution: {integrity: helper}}
116 effect@4.0.0: {resolution: {integrity: effect}}
117snapshots:
118 node-pty@1.0.0:
119 dependencies: {native-helper: 2.0.0}
120 native-helper@2.0.0: {}
121 effect@4.0.0: {}
122`;
123 const path = NodePath.join(root, "pnpm-lock.yaml");
124 await NodeFSP.writeFile(path, lock);
125 const initial = await fingerprintForkServerRuntime(root);
126 await NodeFSP.writeFile(path, lock.replaceAll("4.0.0", "4.0.1"));
127 expect(await fingerprintForkServerRuntime(root)).toBe(initial);
128 await NodeFSP.writeFile(path, lock.replaceAll("2.0.0", "2.0.1"));
129 expect(await fingerprintForkServerRuntime(root)).not.toBe(initial);
130});
131
132it("refuses an incomplete external dependency closure", async () => {
133 await NodeFSP.writeFile(
134 NodePath.join(root, "pnpm-lock.yaml"),
135 `importers:
136 apps/server:
137 dependencies:
138 node-pty: {version: 1.0.0}
139packages: {}
140snapshots: {}
141`,
142 );
143 await expect(fingerprintForkServerRuntime(root)).rejects.toThrow(
144 "Missing locked runtime dependency",
145 );
146});
147
148afterEach(async () => NodeFSP.rm(root, { recursive: true, force: true }));
149
150it("keeps the runtime identity across consecutive UI-only release versions", async () => {
151 const initial = await fingerprint();
152 const metadata = JSON.parse(await NodeFSP.readFile(NodePath.join(root, "package.json"), "utf8"));
153 metadata.version = metadata.buildVersion = "0.0.2";
154 metadata.t3codeCommitHash = "two";
155 await NodeFSP.writeFile(NodePath.join(root, "package.json"), JSON.stringify(metadata));
156 await NodeFSP.writeFile(NodePath.join(root, "apps/server/dist/client/index.html"), "new UI");
157 await NodeFSP.writeFile(
158 NodePath.join(root, "apps/desktop/dist-electron/main.cjs.map"),
159 "new sources",
160 );
161 expect(await fingerprint()).toBe(initial);
162});
163
164it.each([
165 "apps/server/dist/bin.mjs",
166 "apps/desktop/dist-electron/preload.cjs",
167 "node_modules/native/binary.node",
168 "sign.ts",
169])("requires a restart for changed runtime bytes in %s", async (file) => {
170 const initial = await fingerprint();
171 await NodeFSP.appendFile(NodePath.join(root, file), "changed");
172 expect(await fingerprint()).not.toBe(initial);
173});
174
175it("includes architecture, Electron and executable permissions", async () => {
176 const initial = await fingerprint();
177 expect(
178 await fingerprintForkRuntime(root, { platform: "mac", arch: "x64", signingFiles: [signing] }),
179 ).not.toBe(initial);
180 const metadata = JSON.parse(await NodeFSP.readFile(NodePath.join(root, "package.json"), "utf8"));
181 metadata.devDependencies.electron = "45.0.0";
182 await NodeFSP.writeFile(NodePath.join(root, "package.json"), JSON.stringify(metadata));
183 const newerElectron = await fingerprint();
184 expect(newerElectron).not.toBe(initial);
185 await NodeFSP.chmod(NodePath.join(root, "node_modules/native/binary.node"), 0o755);
186 expect(await fingerprint()).not.toBe(newerElectron);
187});
scripts/lib/fork-runtime.ts created+151
...@@ -0,0 +1,151 @@
1// @effect-diagnostics nodeBuiltinImport:off - Fingerprints unsigned release files at the packaging boundary.
2import * as NodeCrypto from "node:crypto";
3import * as NodeFSP from "node:fs/promises";
4import * as NodePath from "node:path";
5import * as Schema from "effect/Schema";
6
7import { fromYaml } from "@t3tools/shared/schemaYaml";
8
9import { isRuntimeExternalCliDependency } from "./cli-external-packages.ts";
10
11/** Release bookkeeping and renderer files cannot change the running backend's compatibility. */
12export async function fingerprintForkRuntime(
13 directory: string,
14 input: {
15 platform: string;
16 arch: string;
17 signingFiles: readonly string[];
18 shellOnly?: boolean;
19 serverDirectory?: string;
20 },
21): Promise<string> {
22 const hash = NodeCrypto.createHash("sha256").update(
23 JSON.stringify({ platform: input.platform, arch: input.arch }),
24 );
25 const visit = async (
26 root: string,
27 relative: string,
28 ancestors: ReadonlySet<string>,
29 ): Promise<void> => {
30 if (
31 relative === "apps/server/dist/client" ||
32 relative === "apps/desktop/prod-resources/windows-server" ||
33 /^apps\/(?:server\/dist|desktop\/dist-electron)\/.*\.(?:mjs|cjs|js)\.map$/.test(relative)
34 )
35 return;
36 if (
37 input.shellOnly &&
38 (relative === "node_modules" ||
39 relative === "apps/server" ||
40 (relative.startsWith("apps/desktop/dist-electron/") &&
41 !["boot.cjs", "compileCache.cjs"].includes(NodePath.basename(relative))))
42 )
43 return;
44 const file = NodePath.join(root, relative);
45 const real = await NodeFSP.realpath(file);
46 const stat = await NodeFSP.stat(real);
47 if (stat.isDirectory()) {
48 if (ancestors.has(real)) return;
49 const next = new Set([...ancestors, real]);
50 for (const name of (await NodeFSP.readdir(real)).sort()) {
51 if (name === ".bin" || name === ".modules.yaml" || name === ".pnpm-workspace-state-v1.json")
52 continue;
53 await visit(root, NodePath.posix.join(relative, name), next);
54 }
55 return;
56 }
57 hash
58 .update(relative)
59 .update("\0")
60 .update(String(stat.mode & 0o777))
61 .update("\0")
62 .update(await NodeFSP.readFile(real))
63 .update("\0");
64 };
65
66 const metadata: Record<string, unknown> = JSON.parse(
67 await NodeFSP.readFile(NodePath.join(directory, "package.json"), "utf8"),
68 );
69 delete metadata.version;
70 delete metadata.buildVersion;
71 delete metadata.t3codeCommitHash;
72 if (input.shellOnly) delete metadata.dependencies;
73 let normalized = JSON.stringify(metadata).replaceAll(directory, "<app>");
74 for (const [index, file] of input.signingFiles.entries()) {
75 normalized = normalized.replaceAll(file, `<signing-${index}>`);
76 hash.update(await NodeFSP.readFile(file));
77 }
78 hash.update(normalized);
79 for (const root of input.shellOnly ? ["apps"] : ["apps", "node_modules"])
80 await visit(directory, root, new Set());
81 if (!input.shellOnly && input.serverDirectory !== undefined) {
82 hash.update("server-sidecar\0");
83 for (const root of ["apps", "node_modules"])
84 await visit(input.serverDirectory, root, new Set());
85 }
86 return hash.digest("hex");
87}
88
89const LockedDependencies = Schema.Record(Schema.String, Schema.String);
90const RuntimeLock = fromYaml(
91 Schema.Struct({
92 importers: Schema.Record(
93 Schema.String,
94 Schema.Struct({
95 dependencies: Schema.optionalKey(
96 Schema.Record(Schema.String, Schema.Struct({ version: Schema.String })),
97 ),
98 }),
99 ),
100 packages: Schema.Record(Schema.String, Schema.Unknown),
101 snapshots: Schema.Record(
102 Schema.String,
103 Schema.Struct({
104 dependencies: Schema.optionalKey(LockedDependencies),
105 optionalDependencies: Schema.optionalKey(LockedDependencies),
106 }),
107 ),
108 }),
109);
110const decodeRuntimeLock = Schema.decodeSync(RuntimeLock);
111
112/** Matches portable server code to the image's complete locked external dependency closure. */
113export async function fingerprintForkServerRuntime(directory: string): Promise<string> {
114 const lock = decodeRuntimeLock(
115 await NodeFSP.readFile(NodePath.join(directory, "pnpm-lock.yaml"), "utf8"),
116 );
117 const roots = Object.entries(lock.importers["apps/server"]?.dependencies ?? {})
118 .filter(([name]) => isRuntimeExternalCliDependency(name))
119 .map(([name, { version }]) => `${name}@${version}`)
120 .sort();
121 if (roots.length === 0) throw new Error("The server runtime has no locked dependencies.");
122
123 const visited = new Set<string>();
124 const visit = (key: string): void => {
125 if (visited.has(key)) return;
126 visited.add(key);
127 const snapshot = lock.snapshots[key];
128 if (!snapshot) throw new Error(`Missing locked runtime dependency: ${key}.`);
129 for (const [name, version] of Object.entries({
130 ...snapshot.dependencies,
131 ...snapshot.optionalDependencies,
132 })) {
133 visit(`${name}@${version}`);
134 }
135 };
136 roots.forEach(visit);
137
138 const closure = [...visited].sort().map((key) => {
139 const packageKey = key.replace(/\(.*$/, "");
140 return [key, lock.packages[packageKey], lock.snapshots[key]];
141 });
142 const hash = NodeCrypto.createHash("sha256").update(JSON.stringify({ roots, closure }));
143 for (const file of [
144 "scripts/container-runtime.ts",
145 "infra/container/knife",
146 "packages/shared/src/forkUpdateDirectory.ts",
147 ]) {
148 hash.update(await NodeFSP.readFile(NodePath.join(directory, file)));
149 }
150 return hash.digest("hex");
151}
scripts/lib/update-manifest.ts+2
...@@ -113,6 +113,8 @@ export function parseUpdateManifest(...@@ -113,6 +113,8 @@ export function parseUpdateManifest(
113 continue;113 continue;
114 }114 }
115115
116 if (currentFile !== null && /^ blockMapSize:\s*\d+$/.test(line)) continue;
117
116 if (line === "files:") {118 if (line === "files:") {
117 inFiles = true;119 inFiles = true;
118 continue;120 continue;
scripts/release-fork.test.ts created+1210
...@@ -0,0 +1,1210 @@
1// @effect-diagnostics nodeBuiltinImport:off - Exercises publishing against a real HTTP fixture.
2import * as NodeCrypto from "node:crypto";
3import * as NodeChildProcess from "node:child_process";
4import * as NodeFSP from "node:fs/promises";
5import * as NodeHttp from "node:http";
6import * as NodeOS from "node:os";
7import * as NodePath from "node:path";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Schema from "effect/Schema";
12import { FetchHttpClient } from "effect/http";
13import { afterEach, beforeEach, expect } from "vite-plus/test";
14import { it } from "@effect/vitest";
15import { createPackage } from "@electron/asar";
16
17import { ForkReleaseManifest, latestForkRelease } from "@t3tools/shared/forkUpdateDirectory";
18
19import {
20 ingestForkRelease,
21 publishForkRelease,
22 resolveForkMacSigningType,
23} from "./release-fork.ts";
24import { parseUpdateManifest } from "./lib/update-manifest.ts";
25
26const decodeRelease = Schema.decodeEffect(Schema.fromJsonString(ForkReleaseManifest));
27
28it("selects the configured certificate fingerprint and requires notarization for Developer ID", () => {
29 const developmentIdentity = "A".repeat(40);
30 const distributionIdentity = "B".repeat(40);
31 const identities = [
32 ` 1) ${developmentIdentity} "Apple Development: Private Owner (TESTTEAM)"`,
33 ` 2) ${distributionIdentity} "Developer ID Application: Private Owner (TESTTEAM)"`,
34 " 2 valid identities found",
35 ].join("\n");
36
37 expect(resolveForkMacSigningType(developmentIdentity.toLowerCase(), identities, undefined)).toBe(
38 "development",
39 );
40 expect(resolveForkMacSigningType(distributionIdentity, identities, "knife-notary")).toBe(
41 "distribution",
42 );
43 expect(() => resolveForkMacSigningType(distributionIdentity, identities, undefined)).toThrow(
44 "Developer ID releases need notarization",
45 );
46 expect(() => resolveForkMacSigningType(developmentIdentity, identities, "knife-notary")).toThrow(
47 "Notarization requires a Developer ID Application certificate",
48 );
49 expect(() => resolveForkMacSigningType("C".repeat(40), identities, undefined)).toThrow(
50 "release signing identity is unavailable",
51 );
52 expect(() =>
53 resolveForkMacSigningType(
54 distributionIdentity,
55 ` 1) ${distributionIdentity} "Developer ID Installer: Private Owner (TESTTEAM)"`,
56 "knife-notary",
57 ),
58 ).toThrow("Notarization requires a Developer ID Application certificate");
59});
60
61let directory: string;
62let origin: string;
63let release: Awaited<ReturnType<typeof makeRelease>>;
64let deniedMethod: string | undefined;
65let deniedRequest: ((method: string, name: string) => boolean) | undefined;
66let beforeManifestPut: (() => void) | undefined;
67let beforePut: ((name: string) => void) | undefined;
68let beforeListing: (() => void) | undefined;
69let afterReadmePut: (() => void) | undefined;
70let beforeDelete: (() => void) | undefined;
71let downloadFailure: "rejected" | "corrupted" | undefined;
72let missingDeleteStatus = 400;
73const stored = new Map<string, Buffer>();
74const requests: string[] = [];
75const server = NodeHttp.createServer(async (request, response) => {
76 const url = new URL(request.url!, origin);
77 if (url.searchParams.has("ls")) {
78 beforeListing?.();
79 requests.push("GET listing");
80 response
81 .writeHead(200, { "Content-Type": "application/json" })
82 .end(JSON.stringify({ files: [...stored.keys()].map((href) => ({ href })) }));
83 return;
84 }
85 const name = decodeURIComponent(url.pathname.split("/").at(-1)!);
86 requests.push(`${request.method} ${name}`);
87 if (
88 request.method === deniedMethod ||
89 deniedRequest?.(request.method!, name) ||
90 (downloadFailure === "rejected" && request.method === "PUT" && name.endsWith(".PARTIAL")) ||
91 (["PUT", "DELETE", "MOVE"].includes(request.method!) && request.headers.pw !== "fixture-key")
92 ) {
93 response.writeHead(401).end();
94 return;
95 }
96 if (!["PUT", "DELETE", "MOVE"].includes(request.method!))
97 expect(request.headers.pw).toBeUndefined();
98 if (request.method === "DELETE") {
99 expect(url.pathname.startsWith("/write/")).toBe(true);
100 beforeDelete?.();
101 response.writeHead(stored.delete(name) ? 200 : missingDeleteStatus).end();
102 return;
103 }
104 if (request.method === "MOVE") {
105 expect(url.pathname.startsWith("/write/")).toBe(true);
106 const destination = new URL(request.headers.destination!);
107 expect(destination.pathname.startsWith("/write/")).toBe(true);
108 const target = decodeURIComponent(destination.pathname.split("/").at(-1)!);
109 const content = stored.get(name);
110 if (!content) {
111 response.writeHead(404).end();
112 return;
113 }
114 if (stored.has(target) && request.headers.overwrite === "F") {
115 response.writeHead(412).end();
116 return;
117 }
118 stored.set(target, content);
119 stored.delete(name);
120 response.writeHead(201).end();
121 return;
122 }
123 if (request.method === "PUT") {
124 const chunks: Buffer[] = [];
125 for await (const chunk of request) chunks.push(Buffer.from(chunk));
126 beforePut?.(name);
127 const target = stored.has(name) && !request.headers.replace ? `${name}.1` : name;
128 stored.set(
129 target,
130 downloadFailure === "corrupted" && name.endsWith(".PARTIAL")
131 ? Buffer.alloc(Buffer.concat(chunks).length, 120)
132 : Buffer.concat(chunks),
133 );
134 if (name === `${release.version}.json`) beforeManifestPut?.();
135 if (name === "preadme.md") afterReadmePut?.();
136 response.writeHead(201).end();
137 return;
138 }
139 const content = stored.get(name);
140 if (!content) response.writeHead(404).end();
141 else
142 response
143 .writeHead(200, { "Content-Length": content.length })
144 .end(request.method === "HEAD" ? undefined : content);
145});
146
147async function makeArtifact(name: string, content: string, filename?: string) {
148 const bytes = Buffer.from(content);
149 const sha256 = NodeCrypto.createHash("sha256").update(bytes).digest("hex");
150 const file = filename ?? `knife-${name}-${sha256}.tar.gz`;
151 await NodeFSP.writeFile(NodePath.join(directory, file), bytes);
152 return { file, sha256, size: bytes.length };
153}
154
155async function makeRelease(): Promise<typeof ForkReleaseManifest.Type> {
156 return {
157 version: "0.0.2000000000",
158 ui: await makeArtifact("ui", "UI fixture"),
159 assets: await makeArtifact("assets", "static asset fixture"),
160 server: {
161 ...(await makeArtifact("server", "backend fixture")),
162 version: "0.0.2000000000",
163 runtime: "a".repeat(64),
164 },
165 desktop: {},
166 };
167}
168
169async function makeDesktop(content = "new signed app bytes") {
170 const code = await makeArtifact("code", "new application code bytes");
171 const app = await makeArtifact("app", content, "The-Knife-darwin-arm64.zip");
172 const bytes = await NodeFSP.readFile(NodePath.join(directory, app.file));
173 const sha512 = NodeCrypto.createHash("sha512").update(bytes).digest("base64");
174 await NodeFSP.writeFile(NodePath.join(directory, `${app.file}.blockmap`), "blockmap fixture");
175 await NodeFSP.writeFile(
176 NodePath.join(directory, "latest-mac.yml"),
177 `version: ${release.version}\nfiles:\n - url: ${app.file}\n sha512: ${sha512}\n size: ${app.size}\nreleaseDate: '2026-10-16T00:00:00.000Z'\n`,
178 );
179 return {
180 version: release.version,
181 ui: release.ui,
182 assets: release.assets,
183 shell: "b".repeat(64),
184 runtime: "c".repeat(64),
185 code,
186 app: { ...app, version: release.version },
187 };
188}
189
190async function writeRelease() {
191 await NodeFSP.writeFile(NodePath.join(directory, "release.json"), JSON.stringify(release));
192}
193
194beforeEach(async () => {
195 directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-fork-release-test-"));
196 stored.clear();
197 requests.length = 0;
198 deniedMethod = undefined;
199 deniedRequest = undefined;
200 beforeManifestPut = undefined;
201 beforePut = undefined;
202 beforeListing = undefined;
203 afterReadmePut = undefined;
204 beforeDelete = undefined;
205 downloadFailure = undefined;
206 missingDeleteStatus = 400;
207 release = await makeRelease();
208 await writeRelease();
209 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
210 const address = server.address();
211 if (!address || typeof address === "string") throw new Error("Missing fixture address");
212 origin = `http://127.0.0.1:${address.port}`;
213});
214
215afterEach(async () => {
216 await new Promise<void>((resolve, reject) =>
217 server.close((error) => (error ? reject(error) : resolve())),
218 );
219 await NodeFSP.rm(directory, { recursive: true, force: true });
220});
221
222const publish = () =>
223 publishForkRelease(directory, {
224 feedUrl: `${origin}/read/`,
225 uploadUrl: `${origin}/write/`,
226 uploadApiKey: "fixture-key",
227 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)));
228
229it.effect("publishes verified payloads before the immutable release manifest", () =>
230 Effect.gen(function* () {
231 yield* publish();
232 expect(JSON.parse(stored.get(`${release.version}.json`)!.toString())).toEqual(release);
233 expect(stored.has("latest.json")).toBe(false);
234 expect(requests.indexOf(`HEAD ${release.server.file}`)).toBeLessThan(
235 requests.indexOf(`PUT ${release.version}.json`),
236 );
237 expect(requests.indexOf(`PUT ${release.ui.file}`)).toBeLessThan(
238 requests.indexOf(`PUT ${release.version}.json`),
239 );
240 }),
241);
242
243it.effect("a UI release reuses the published backend without uploading it again", () =>
244 Effect.gen(function* () {
245 stored.set(
246 "0.0.1999999999.json",
247 Buffer.from(JSON.stringify({ ...release, version: "0.0.1999999999" })),
248 );
249 stored.set(
250 release.server.file,
251 yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, release.server.file))),
252 );
253 yield* publish();
254 expect(requests).not.toContain(`PUT ${release.server.file}`);
255 expect(requests).toContain(`PUT ${release.ui.file}`);
256 expect(JSON.parse(stored.get(`${release.version}.json`)!.toString()).version).toBe(
257 release.version,
258 );
259 }),
260);
261
262it.effect("rejects a corrupt local payload before touching the share", () =>
263 Effect.gen(function* () {
264 yield* Effect.promise(() =>
265 NodeFSP.writeFile(NodePath.join(directory, release.ui.file), "corrupt"),
266 );
267 const error = yield* publish().pipe(Effect.flip);
268 expect(error.message).toContain("Artifact verification failed");
269 expect(requests).toEqual([]);
270 }),
271);
272
273it.effect("refuses to replace a newer published release", () =>
274 Effect.gen(function* () {
275 stored.set(
276 "0.0.2000000001.json",
277 Buffer.from(JSON.stringify({ ...release, version: "0.0.2000000001" })),
278 );
279 const error = yield* publish().pipe(Effect.flip);
280 expect(error.message).toContain("newer version");
281 expect(requests).toEqual(["GET listing", "GET 0.0.2000000001.json"]);
282 }),
283);
284
285it.effect("retries an identical completed publication without rewriting it", () =>
286 Effect.gen(function* () {
287 yield* publish();
288 requests.length = 0;
289 yield* publish();
290 expect(requests.filter((value) => value.startsWith("PUT "))).toEqual([]);
291 }),
292);
293
294it.effect(
295 "an equivalent completed manifest with different JSON formatting still refreshes the download page",
296 () =>
297 Effect.gen(function* () {
298 const desktop = yield* Effect.promise(() => makeDesktop());
299 release = { ...release, desktop: { "darwin-arm64": desktop } };
300 yield* Effect.promise(writeRelease);
301 const existing = Buffer.from(
302 JSON.stringify(Object.fromEntries(Object.entries(release).toReversed()), null, 2),
303 );
304 stored.set(`${release.version}.json`, existing);
305 for (const artifact of [release.ui, release.assets, release.server, desktop.code])
306 stored.set(
307 artifact.file,
308 yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))),
309 );
310 stored.set(`${desktop.app.file}.blockmap`, Buffer.from("blockmap fixture"));
311 stored.set(
312 desktop.app.file,
313 yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, desktop.app.file))),
314 );
315 yield* publish();
316 expect(stored.get(`${release.version}.json`)).toEqual(existing);
317 expect(requests).not.toContain(`PUT ${release.version}.json`);
318 expect(stored.get("preadme.md")!.toString()).toContain('href="The-Knife-darwin-arm64.zip"');
319 expect(requests.filter((entry) => entry.startsWith("PUT "))).toEqual(["PUT preadme.md"]);
320 expect(stored.get("The-Knife-darwin-arm64.zip")).toEqual(stored.get(desktop.app.file));
321 }),
322);
323
324it.effect("does not advertise a release whose payload upload is rejected", () =>
325 Effect.gen(function* () {
326 deniedMethod = "PUT";
327 const error = yield* publish().pipe(Effect.flip);
328 expect(error.message).toContain("HTTP 401");
329 expect(stored.has("latest.json")).toBe(false);
330 expect(stored.has(`${release.version}.json`)).toBe(false);
331 }),
332);
333
334it.effect("refuses conflicting contents at the same immutable version", () =>
335 Effect.gen(function* () {
336 const conflicting = Buffer.from(
337 JSON.stringify({ ...release, server: { ...release.server, runtime: "b".repeat(64) } }),
338 );
339 stored.set(`${release.version}.json`, conflicting);
340 const error = yield* publish().pipe(Effect.flip);
341 expect(error.message).toContain("different contents");
342 expect(stored.get(`${release.version}.json`)).toEqual(conflicting);
343 }),
344);
345
346it.effect("a concurrent newer publication remains newest after this release finishes", () =>
347 Effect.gen(function* () {
348 beforeManifestPut = () =>
349 stored.set(
350 "0.0.2000000001.json",
351 Buffer.from(JSON.stringify({ ...release, version: "0.0.2000000001" })),
352 );
353 yield* publish();
354 expect(stored.has(`${release.version}.json`)).toBe(true);
355 expect(stored.has("0.0.2000000001.json")).toBe(true);
356 expect(requests.filter((entry) => entry.startsWith("PUT "))).not.toContain(
357 "PUT 0.0.2000000002.json",
358 );
359 }),
360);
361
362it.effect("reconciles concurrent native targets and keeps the newest server tuple", () =>
363 Effect.gen(function* () {
364 const desktop = yield* Effect.promise(() => makeDesktop());
365 release = { ...release, desktop: { "darwin-arm64": desktop } };
366 yield* Effect.promise(writeRelease);
367 const windowsApp = yield* Effect.promise(() =>
368 makeArtifact("app", "Windows installer", "The-Knife-0.0.2000000001-x64.exe"),
369 );
370 const olderUi = yield* Effect.promise(() => makeArtifact("ui", "older native UI"));
371 const olderServer = yield* Effect.promise(() => makeArtifact("server", "older native backend"));
372 const concurrent = {
373 ...release,
374 version: "0.0.2000000001",
375 ui: olderUi,
376 server: { ...olderServer, version: "0.0.1999999999", runtime: release.server.runtime },
377 desktop: {
378 "win32-x64": {
379 ...desktop,
380 version: "0.0.2000000001",
381 ui: olderUi,
382 app: { ...windowsApp, version: "0.0.2000000001" },
383 },
384 },
385 };
386 beforeManifestPut = () => {
387 stored.set(`${concurrent.version}.json`, Buffer.from(JSON.stringify(concurrent)));
388 stored.set(windowsApp.file, Buffer.from("Windows installer"));
389 };
390 yield* publish();
391 const newest = latestForkRelease({ files: [...stored.keys()].map((href) => ({ href })) })!;
392 const completed = yield* decodeRelease(stored.get(`${newest}.json`)!.toString());
393 expect(newest).toBe("0.0.2000000002");
394 expect(completed.desktop["darwin-arm64"]).toEqual(desktop);
395 expect(completed.desktop["win32-x64"]).toEqual(concurrent.desktop["win32-x64"]);
396 expect(completed.server).toEqual(release.server);
397 expect(completed.ui).toEqual(release.ui);
398 expect(completed.assets).toEqual(release.assets);
399 expect(stored.get("preadme.md")!.toString()).toContain("The-Knife-win32-x64.exe");
400 expect(stored.get("preadme.md")!.toString()).toContain("The-Knife-darwin-arm64.zip");
401 expect(stored.has(`${release.version}.json`)).toBe(false);
402 }),
403);
404
405it.effect("retries a conflicting immutable repair without overwriting the other publisher", () =>
406 Effect.gen(function* () {
407 const desktop = yield* Effect.promise(() => makeDesktop());
408 release = { ...release, desktop: { "darwin-arm64": desktop } };
409 yield* Effect.promise(writeRelease);
410 const concurrent = { ...release, version: "0.0.2000000001", desktop: {} };
411 beforeManifestPut = () => {
412 stored.set(`${concurrent.version}.json`, Buffer.from(JSON.stringify(concurrent)));
413 };
414 const winner = {
415 ...concurrent,
416 version: "0.0.2000000002",
417 desktop: { "linux-x64": { ...desktop, version: "0.0.2000000002" } },
418 };
419 beforePut = (name) => {
420 if (name === `${winner.version}.json`) stored.set(name, Buffer.from(JSON.stringify(winner)));
421 };
422 yield* publish();
423 const newest = latestForkRelease({ files: [...stored.keys()].map((href) => ({ href })) })!;
424 const completed = yield* decodeRelease(stored.get(`${newest}.json`)!.toString());
425 expect(newest).toBe("0.0.2000000003");
426 expect(completed.desktop["darwin-arm64"]).toEqual(desktop);
427 expect(completed.desktop["linux-x64"]).toEqual(winner.desktop["linux-x64"]);
428 expect(JSON.parse(stored.get(`${winner.version}.json`)!.toString())).toEqual(winner);
429 expect(stored.has(`${winner.version}.json.1`)).toBe(true);
430 }),
431);
432
433it.effect.each(["unchanged", "changed"])(
434 "reuses the published shell while application code is %s",
435 (state) =>
436 Effect.gen(function* () {
437 const sameRuntime = state === "unchanged";
438 const desktop = yield* Effect.promise(() => makeDesktop());
439 release = { ...release, desktop: { "darwin-arm64": desktop } };
440 yield* Effect.promise(writeRelease);
441 const code = yield* Effect.promise(() =>
442 makeArtifact("old-code", "old code archive metadata"),
443 );
444 const app = yield* Effect.promise(() =>
445 makeArtifact("old-app", "old signed app archive", "The-Knife-0.0.1999999999-arm64.zip"),
446 );
447 const previous = {
448 ...release,
449 version: "0.0.1999999999",
450 desktop: {
451 "darwin-arm64": {
452 ...desktop,
453 runtime: sameRuntime ? desktop.runtime : "d".repeat(64),
454 code,
455 app: { ...app, file: desktop.app.file, version: "0.0.1999999999" },
456 },
457 },
458 };
459 stored.set(`${previous.version}.json`, Buffer.from(JSON.stringify(previous)));
460 for (const artifact of [code, app]) {
461 stored.set(
462 artifact === app ? desktop.app.file : artifact.file,
463 yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))),
464 );
465 yield* Effect.promise(() => NodeFSP.rm(NodePath.join(directory, artifact.file)));
466 }
467 yield* publish();
468 const published = JSON.parse(stored.get(`${release.version}.json`)!.toString());
469 expect(published.desktop["darwin-arm64"]).toEqual({
470 ...desktop,
471 code: sameRuntime ? code : desktop.code,
472 app: previous.desktop["darwin-arm64"].app,
473 });
474 expect(requests.includes(`PUT ${desktop.code.file}`)).toBe(!sameRuntime);
475 expect(requests).not.toContain(`PUT ${desktop.app.file}`);
476 expect(requests).not.toContain(`PUT ${desktop.app.file}.blockmap`);
477 expect(requests).not.toContain(`PUT ${release.version}-mac.yml`);
478 expect(requests).toContain(`PUT ${release.ui.file}`);
479 requests.length = 0;
480 yield* publish();
481 expect(requests.filter((value) => value.startsWith("PUT "))).toEqual([]);
482 }),
483);
484
485it.effect("uploads new shell blockmaps before publishing its updater manifest", () =>
486 Effect.gen(function* () {
487 const desktop = yield* Effect.promise(() => makeDesktop());
488 release = { ...release, desktop: { "darwin-arm64": desktop } };
489 yield* Effect.promise(writeRelease);
490 yield* publish();
491 expect(stored.get(desktop.app.file)).toEqual(Buffer.from("new signed app bytes"));
492 expect(stored.get(`${desktop.app.file}.blockmap`)).toEqual(Buffer.from("blockmap fixture"));
493 const moves = requests.filter((entry) => entry.startsWith("MOVE "));
494 expect(moves).toHaveLength(2);
495 expect(requests.indexOf(moves.at(-1)!)).toBeLessThan(
496 requests.indexOf(`PUT ${release.version}-mac.yml`),
497 );
498 expect(requests.indexOf(`PUT ${release.version}-mac.yml`)).toBeLessThan(
499 requests.indexOf(`PUT ${release.version}.json`),
500 );
501 }),
502);
503
504it.effect("rejects a missing local built payload before touching the share", () =>
505 Effect.gen(function* () {
506 yield* Effect.promise(() => NodeFSP.rm(NodePath.join(directory, release.server.file)));
507 const error = yield* publish().pipe(Effect.flip);
508 expect(error.message).toContain("Could not verify the local artifact");
509 expect(requests).toEqual([]);
510 }),
511);
512
513it.effect("publishes a root download page with only real platform installers", () =>
514 Effect.gen(function* () {
515 const desktop = yield* Effect.promise(() => makeDesktop());
516 release = { ...release, desktop: { "darwin-arm64": desktop } };
517 yield* Effect.promise(writeRelease);
518 stored.set("preadme.md", Buffer.from("An older download page"));
519 stored.set("The-Knife-darwin-arm64.html", Buffer.from("Old redirect page"));
520 yield* publish();
521 const page = stored.get("preadme.md")!.toString();
522 expect(page).toContain("macOS Apple silicon");
523 expect(page).toContain('href="The-Knife-darwin-arm64.zip"');
524 expect(page).not.toContain("Linux");
525 expect(page).not.toContain("Windows");
526 expect(stored.has("preadme.md.1")).toBe(false);
527 expect(stored.has("The-Knife-darwin-arm64.html")).toBe(false);
528 expect(requests.indexOf(`PUT ${release.version}.json`)).toBeLessThan(
529 requests.indexOf("PUT preadme.md"),
530 );
531 expect(page).not.toContain("fixture-key");
532 expect(page).not.toContain("/write/");
533 expect(stored.get("The-Knife-darwin-arm64.zip")).toEqual(stored.get(desktop.app.file));
534 expect(requests.findIndex((entry) => entry.startsWith("MOVE "))).toBeLessThan(
535 requests.indexOf(`PUT ${release.version}.json`),
536 );
537 expect([...stored.keys()].some((name) => name.endsWith(".PARTIAL"))).toBe(false);
538 const manifest = JSON.parse(stored.get(`${release.version}.json`)!.toString());
539 expect(manifest.desktop["darwin-arm64"].app.file).toBe(desktop.app.file);
540 requests.length = 0;
541 yield* publish();
542 expect(requests.some((request) => request.startsWith("PUT "))).toBe(false);
543 }),
544);
545
546it.effect.each(["rejected", "corrupted"] as const)(
547 "retries a stable installer whose upload was %s without advertising an incomplete release",
548 (failure) =>
549 Effect.gen(function* () {
550 const desktop = yield* Effect.promise(() => makeDesktop());
551 release = { ...release, desktop: { "darwin-arm64": desktop } };
552 yield* Effect.promise(writeRelease);
553 downloadFailure = failure;
554
555 const error = yield* publish().pipe(Effect.flip);
556 expect(error.message).toContain(
557 failure === "rejected" ? "HTTP 401" : "uploaded installer did not match its checksum",
558 );
559 expect(stored.has(`${release.version}.json`)).toBe(false);
560 expect(stored.has("The-Knife-darwin-arm64.zip")).toBe(false);
561 expect(stored.has("preadme.md")).toBe(false);
562 deniedMethod = undefined;
563 downloadFailure = undefined;
564 requests.length = 0;
565 yield* publish();
566 expect(stored.get("The-Knife-darwin-arm64.zip")).toEqual(stored.get(desktop.app.file));
567 expect(requests).toContain(`PUT ${release.version}.json`);
568 expect(stored.get("preadme.md")!.toString()).toContain("The-Knife-darwin-arm64.zip");
569 }),
570);
571
572it.effect("keeps a newer completion when it arrives during download page maintenance", () =>
573 Effect.gen(function* () {
574 const desktop = yield* Effect.promise(() => makeDesktop());
575 release = { ...release, desktop: { "darwin-arm64": desktop } };
576 yield* Effect.promise(writeRelease);
577 const newerApp = {
578 ...(yield* Effect.promise(() =>
579 makeArtifact("app", "newer signed app", "The-Knife-0.0.2000000001-arm64.zip"),
580 )),
581 file: "The-Knife-darwin-arm64.zip",
582 version: "0.0.2000000001",
583 };
584 afterReadmePut = () => {
585 afterReadmePut = undefined;
586 stored.set(newerApp.file, Buffer.from("newer signed app"));
587 stored.set(
588 "0.0.2000000001.json",
589 Buffer.from(
590 JSON.stringify({
591 ...release,
592 version: "0.0.2000000001",
593 desktop: { "darwin-arm64": { ...desktop, version: "0.0.2000000001", app: newerApp } },
594 }),
595 ),
596 );
597 };
598 yield* publish();
599 const page = stored.get("preadme.md")!.toString();
600 expect(page).toContain('href="The-Knife-darwin-arm64.zip"');
601 expect(stored.get("The-Knife-darwin-arm64.zip")).toEqual(Buffer.from("newer signed app"));
602 expect(requests.filter((entry) => entry === "PUT preadme.md")).toHaveLength(1);
603 expect(
604 requests.filter((entry) => entry.startsWith("DELETE ") && !entry.endsWith(".PARTIAL")),
605 ).toEqual([]);
606 }),
607);
608
609it.effect(
610 "retires conflicting installer manifests before replacement and retains matching releases",
611 () =>
612 Effect.gen(function* () {
613 const oldDesktop = yield* Effect.promise(() => makeDesktop());
614 release = { ...release, desktop: { "darwin-arm64": oldDesktop } };
615 yield* Effect.promise(writeRelease);
616 yield* publish();
617 const oldVersion = release.version;
618
619 release = { ...release, version: "0.0.2000000001" };
620 const desktop = {
621 ...(yield* Effect.promise(() => makeDesktop("new signed app byte!"))),
622 shell: "d".repeat(64),
623 };
624 expect(desktop.app.size).toBe(oldDesktop.app.size);
625 yield* Effect.promise(() =>
626 NodeFSP.writeFile(
627 NodePath.join(directory, `${desktop.app.file}.blockmap`),
628 "new blockmap fixture",
629 ),
630 );
631 release = { ...release, desktop: { "darwin-arm64": desktop } };
632 yield* Effect.promise(writeRelease);
633 requests.length = 0;
634 yield* publish();
635 const promoted = requests.findIndex((entry) => entry.startsWith("MOVE "));
636 expect(requests.indexOf(`DELETE ${oldVersion}.json`)).toBeLessThan(promoted);
637 expect(requests.indexOf(`DELETE ${oldVersion}-mac.yml`)).toBeLessThan(promoted);
638 expect(requests.filter((entry) => entry === `DELETE ${oldVersion}-mac.yml`)).toHaveLength(1);
639 expect(requests.filter((entry) => entry.startsWith("MOVE "))).toHaveLength(2);
640 expect(stored.has(`${oldVersion}.json`)).toBe(false);
641 expect(stored.has(`${oldVersion}-mac.yml`)).toBe(false);
642 expect(stored.get(desktop.app.file)).toEqual(Buffer.from("new signed app byte!"));
643 expect(stored.get(`${desktop.app.file}.blockmap`)?.toString()).toBe("new blockmap fixture");
644 const nativeManifest = parseUpdateManifest(
645 stored.get(`${release.version}-mac.yml`)!.toString(),
646 "native",
647 "macOS",
648 );
649 expect(nativeManifest.version).toBe(desktop.app.version);
650 expect(nativeManifest.files).toEqual([
651 {
652 url: desktop.app.file,
653 size: desktop.app.size,
654 sha512: NodeCrypto.createHash("sha512")
655 .update(stored.get(desktop.app.file)!)
656 .digest("base64"),
657 },
658 ]);
659
660 for (const version of ["0.0.2000000002", "0.0.2000000003"]) {
661 release = {
662 ...release,
663 version,
664 ui: yield* Effect.promise(() => makeArtifact("ui", version)),
665 };
666 yield* Effect.promise(writeRelease);
667 yield* publish();
668 }
669 const completions = [...stored.keys()].filter((name) => /^0\.0\.\d+\.json$/.test(name));
670 expect(completions).toEqual(["0.0.2000000002.json", "0.0.2000000003.json"]);
671 for (const name of completions) {
672 const completed = yield* decodeRelease(stored.get(name)!.toString());
673 expect(completed.desktop["darwin-arm64"]!.app.sha256).toBe(
674 NodeCrypto.createHash("sha256").update(stored.get(desktop.app.file)!).digest("hex"),
675 );
676 }
677 expect([...stored.keys()].filter((name) => name.endsWith(".zip"))).toEqual([
678 desktop.app.file,
679 ]);
680 }),
681);
682
683it.effect(
684 "repairs same-sized stable installer corruption without rewriting a completed manifest",
685 () =>
686 Effect.gen(function* () {
687 const desktop = yield* Effect.promise(() => makeDesktop());
688 release = { ...release, desktop: { "darwin-arm64": desktop } };
689 yield* Effect.promise(writeRelease);
690 yield* publish();
691 const completed = stored.get(`${release.version}.json`);
692 stored.set(desktop.app.file, Buffer.alloc(desktop.app.size, 120));
693 requests.length = 0;
694 yield* publish();
695 expect(stored.get(desktop.app.file)).toEqual(Buffer.from("new signed app bytes"));
696 expect(stored.get(`${release.version}.json`)).toBe(completed);
697 expect(requests).not.toContain(`PUT ${release.version}.json`);
698 expect(requests.filter((entry) => entry.startsWith("MOVE "))).toHaveLength(1);
699 }),
700);
701
702it.effect.each(["MOVE", "retired-YML", "YML", "completion", "superseded"] as const)(
703 "recovers a three-platform publication after retirement fails at %s",
704 (failure) =>
705 Effect.gen(function* () {
706 const oldDesktop = yield* Effect.promise(() => makeDesktop("old signed app bytes"));
707 const oldCode = yield* Effect.promise(() => makeArtifact("code", "retired Mac runtime"));
708 const windows = {
709 ...oldDesktop,
710 app: {
711 ...(yield* Effect.promise(() =>
712 makeArtifact("app", "Windows", "The-Knife-win32-x64.exe"),
713 )),
714 version: release.version,
715 },
716 };
717 const linux = {
718 ...oldDesktop,
719 app: {
720 ...(yield* Effect.promise(() =>
721 makeArtifact("app", "Linux", "The-Knife-linux-x64.AppImage"),
722 )),
723 version: release.version,
724 },
725 };
726 const ui = yield* Effect.promise(() => makeArtifact("ui", "newer portable UI"));
727 const assets = yield* Effect.promise(() => makeArtifact("assets", "newer portable assets"));
728 const server = {
729 ...(yield* Effect.promise(() => makeArtifact("server", "newer portable server"))),
730 version: "0.0.2000000002",
731 runtime: "e".repeat(64),
732 };
733 const previous = {
734 ...release,
735 ui,
736 assets,
737 server,
738 desktop: {
739 "darwin-arm64": { ...oldDesktop, runtime: "f".repeat(64), code: oldCode },
740 "win32-x64": windows,
741 "linux-x64": linux,
742 },
743 };
744 stored.set(`${previous.version}.json`, Buffer.from(JSON.stringify(previous)));
745 for (const artifact of [
746 ui,
747 assets,
748 server,
749 oldCode,
750 oldDesktop.app,
751 oldDesktop.code,
752 windows.app,
753 linux.app,
754 ])
755 stored.set(
756 artifact.file,
757 yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))),
758 );
759 stored.set(`${previous.version}-mac.yml`, Buffer.from("retired Mac YAML"));
760 stored.set(`${previous.version}.yml`, Buffer.from("retained Windows YAML"));
761 stored.set(`${previous.version}-linux.yml`, Buffer.from("retained Linux YAML"));
762 release = { ...release, version: "0.0.2000000003" };
763 const desktop = {
764 ...(yield* Effect.promise(() => makeDesktop("new signed app byte!"))),
765 shell: "d".repeat(64),
766 };
767 release = { ...release, desktop: { "darwin-arm64": desktop } };
768 yield* Effect.promise(writeRelease);
769 deniedRequest = (method, name) =>
770 failure === "retired-YML"
771 ? method === "DELETE" && name === `${previous.version}-mac.yml`
772 : failure === "YML"
773 ? method === "PUT" && name === `${release.version}-mac.yml`
774 : failure === "completion"
775 ? method === "PUT" && name === `${release.version}.json`
776 : method === "MOVE";
777 expect((yield* publish().pipe(Effect.flip)).message).toContain("HTTP 401");
778 expect(stored.has(`${previous.version}.json`)).toBe(false);
779 const intentPath = NodePath.join(directory, "publication.json");
780 const intent = JSON.parse(yield* Effect.promise(() => NodeFSP.readFile(intentPath, "utf8")));
781 expect(intent.release.desktop["win32-x64"]).toEqual(windows);
782 expect(intent.release.desktop["linux-x64"]).toEqual(linux);
783 expect(intent.release.server).toEqual(server);
784 expect(intent.retiredFiles).toContain(oldCode.file);
785 deniedRequest = undefined;
786 requests.length = 0;
787 if (failure === "superseded") {
788 stored.set(
789 "0.0.2000000004.json",
790 Buffer.from(JSON.stringify({ ...previous, version: "0.0.2000000004" })),
791 );
792 expect((yield* publish().pipe(Effect.flip)).message).toContain("newer version");
793 expect(requests.some((entry) => /^(PUT|MOVE|DELETE) /.test(entry))).toBe(false);
794 expect(yield* Effect.promise(() => NodeFSP.stat(intentPath))).toBeDefined();
795 return;
796 }
797 yield* publish();
798 if (failure === "retired-YML")
799 expect(requests.indexOf(`DELETE ${previous.version}-mac.yml`)).toBeLessThan(
800 requests.findIndex((entry) => entry.startsWith("MOVE ")),
801 );
802 const completed = yield* decodeRelease(stored.get(`${release.version}.json`)!.toString());
803 expect(completed.desktop["win32-x64"]).toEqual(windows);
804 expect(completed.desktop["linux-x64"]).toEqual(linux);
805 expect(completed.server).toEqual(server);
806 expect(completed.ui).toEqual(ui);
807 expect(completed.assets).toEqual(assets);
808 expect(stored.get(desktop.app.file)).toEqual(Buffer.from("new signed app byte!"));
809 expect(stored.has(oldCode.file)).toBe(false);
810 expect(stored.has(`${previous.version}-mac.yml`)).toBe(false);
811 expect(stored.has(`${previous.version}.yml`)).toBe(true);
812 expect(stored.has(`${previous.version}-linux.yml`)).toBe(true);
813 expect(
814 yield* Effect.promise(() =>
815 NodeFSP.stat(intentPath).then(
816 () => true,
817 () => false,
818 ),
819 ),
820 ).toBe(false);
821 }),
822);
823
824async function storeOldRelease(version: string, content: string) {
825 const ui = await makeArtifact("ui", content);
826 const old = { ...release, version, ui };
827 stored.set(`${version}.json`, Buffer.from(JSON.stringify(old)));
828 for (const entry of [old.ui, old.assets, old.server]) {
829 stored.set(entry.file, await NodeFSP.readFile(NodePath.join(directory, entry.file)));
830 }
831 return old;
832}
833
834it.effect(
835 "bounds completed releases while preserving referenced, unknown, and incomplete files",
836 () =>
837 Effect.gen(function* () {
838 const oldest = yield* Effect.promise(() => storeOldRelease("0.0.1999999997", "obsolete UI"));
839 const middle = yield* Effect.promise(() =>
840 storeOldRelease("0.0.1999999998", "other obsolete UI"),
841 );
842 const previous = yield* Effect.promise(() =>
843 storeOldRelease("0.0.1999999999", "previous UI"),
844 );
845 const orphan = `knife-code-${"d".repeat(64)}.tar.gz`;
846 stored.set(orphan, Buffer.from("An incomplete publication"));
847 stored.set("notes.txt", Buffer.from("Keep this unrelated file"));
848 stored.set("0.0.1999999996.json.PARTIAL", Buffer.from("Unfinished manifest"));
849 yield* publish();
850 for (const entry of [oldest, middle]) {
851 expect(stored.has(`${entry.version}.json`)).toBe(false);
852 expect(stored.has(entry.ui.file)).toBe(false);
853 }
854 expect(stored.has(`${previous.version}.json`)).toBe(true);
855 expect(stored.has(previous.ui.file)).toBe(true);
856 expect(stored.has(release.assets.file)).toBe(true);
857 expect(stored.has(release.server.file)).toBe(true);
858 expect(stored.has(orphan)).toBe(true);
859 expect(stored.has("notes.txt")).toBe(true);
860 expect(stored.has("0.0.1999999996.json.PARTIAL")).toBe(true);
861 expect(requests.indexOf(`DELETE ${oldest.ui.file}`)).toBeLessThan(
862 requests.indexOf(`DELETE ${oldest.version}.json`),
863 );
864 }),
865);
866
867it.effect("treats HTTP 404 for an already deleted obsolete artifact as successful cleanup", () =>
868 Effect.gen(function* () {
869 missingDeleteStatus = 404;
870 const oldest = yield* Effect.promise(() => storeOldRelease("0.0.1999999997", "obsolete UI"));
871 yield* Effect.promise(() => storeOldRelease("0.0.1999999998", "other obsolete UI"));
872 yield* Effect.promise(() => storeOldRelease("0.0.1999999999", "previous UI"));
873 beforeDelete = () => {
874 beforeDelete = undefined;
875 stored.delete(oldest.ui.file);
876 };
877 yield* publish();
878 expect(requests).toContain(`DELETE ${oldest.ui.file}`);
879 expect([...stored.keys()].filter((file) => /^0\.0\.\d+\.json$/.test(file))).toEqual([
880 "0.0.1999999999.json",
881 `${release.version}.json`,
882 ]);
883 }),
884);
885
886it.effect.each(["0.0.2000000001", "0.0.1999999996"])(
887 "stops pruning when publication %s completes during cleanup",
888 (version) =>
889 Effect.gen(function* () {
890 yield* Effect.promise(() => storeOldRelease("0.0.1999999997", "obsolete UI"));
891 yield* Effect.promise(() => storeOldRelease("0.0.1999999998", "other obsolete UI"));
892 yield* Effect.promise(() => storeOldRelease("0.0.1999999999", "previous UI"));
893 beforeDelete = () => {
894 beforeDelete = undefined;
895 stored.set(`${version}.json`, Buffer.from(JSON.stringify({ ...release, version })));
896 };
897 yield* publish();
898 const firstDelete = requests.findIndex((entry) => entry.startsWith("DELETE "));
899 const observed = requests.indexOf(`GET ${version}.json`);
900 expect(observed).toBeGreaterThan(firstDelete);
901 expect(
902 requests.slice(firstDelete + 1, observed).some((entry) => entry.startsWith("DELETE ")),
903 ).toBe(false);
904 expect(stored.has(`${version}.json`)).toBe(version === "0.0.2000000001");
905 }),
906);
907
908it.effect("reconciles a late older aggregate before its native target can be pruned", () =>
909 Effect.gen(function* () {
910 const desktop = yield* Effect.promise(() => makeDesktop());
911 release = { ...release, desktop: { "darwin-arm64": desktop } };
912 yield* Effect.promise(writeRelease);
913 yield* Effect.promise(() => storeOldRelease("0.0.1999999999", "previous UI"));
914 const linux = { ...desktop, version: "0.0.1999999998" };
915 let listings = 0;
916 beforeListing = () => {
917 listings++;
918 if (listings === 4)
919 stored.set(
920 "0.0.1999999998.json",
921 Buffer.from(
922 JSON.stringify({
923 ...release,
924 version: "0.0.1999999998",
925 desktop: { "linux-x64": linux },
926 }),
927 ),
928 );
929 };
930 yield* publish();
931 const newest = latestForkRelease({ files: [...stored.keys()].map((href) => ({ href })) })!;
932 const completed = yield* decodeRelease(stored.get(`${newest}.json`)!.toString());
933 expect(completed.desktop["linux-x64"]).toEqual(linux);
934 expect(completed.desktop["darwin-arm64"]).toEqual(desktop);
935 expect(requests.indexOf(`PUT ${newest}.json`)).toBeLessThan(
936 requests.indexOf("DELETE 0.0.1999999998.json"),
937 );
938 }),
939);
940
941it.effect("links the server bundle only after it is publicly available", () =>
942 Effect.gen(function* () {
943 yield* publish();
944 expect(stored.get("preadme.md")!.toString()).not.toContain("Docker bundle");
945 stored.set("knife-server-linux-amd64.tar.gz", Buffer.from("Docker image"));
946 stored.set("knife-server-linux-amd64.tar.gz.sha256", Buffer.from("checksum"));
947 yield* publish();
948 const page = stored.get("preadme.md")!.toString();
949 expect(page).toContain("[Docker bundle](knife-server-linux-amd64.tar.gz)");
950 expect(page).toContain("[SHA-256](knife-server-linux-amd64.tar.gz.sha256)");
951 }),
952);
953
954it.effect("preserves an older platform's UI and assets while pruning their original manifest", () =>
955 Effect.gen(function* () {
956 const oldest = yield* Effect.promise(() => storeOldRelease("0.0.1999999997", "old Linux UI"));
957 yield* Effect.promise(() => storeOldRelease("0.0.1999999998", "obsolete UI"));
958 const previous = yield* Effect.promise(() =>
959 storeOldRelease("0.0.1999999999", "previous root UI"),
960 );
961 const code = yield* Effect.promise(() => makeArtifact("code", "Linux application code"));
962 const assets = yield* Effect.promise(() => makeArtifact("assets", "old Linux assets"));
963 const app = yield* Effect.promise(() =>
964 makeArtifact("app", "Linux installer", "The-Knife-0.0.1999999997-x64.AppImage"),
965 );
966 const target = {
967 version: oldest.version,
968 ui: oldest.ui,
969 assets,
970 code,
971 app: { ...app, version: oldest.version },
972 shell: "b".repeat(64),
973 runtime: "c".repeat(64),
974 };
975 stored.set(
976 `${previous.version}.json`,
977 Buffer.from(JSON.stringify({ ...previous, desktop: { "linux-x64": target } })),
978 );
979 for (const artifact of [assets, code, app])
980 stored.set(
981 artifact.file,
982 yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))),
983 );
984 yield* publish();
985 expect(stored.has(`${oldest.version}.json`)).toBe(false);
986 for (const artifact of [oldest.ui, assets, code, app])
987 expect(stored.has(artifact.file)).toBe(true);
988 const published = JSON.parse(stored.get(`${release.version}.json`)!.toString());
989 expect(published.desktop["linux-x64"]).toEqual(target);
990 }),
991);
992
993it.effect("refuses to replace a platform with an older native build", () =>
994 Effect.gen(function* () {
995 const desktop = yield* Effect.promise(() => makeDesktop());
996 release = { ...release, desktop: { "darwin-arm64": desktop } };
997 yield* Effect.promise(writeRelease);
998 stored.set(
999 "0.0.1999999999.json",
1000 Buffer.from(
1001 JSON.stringify({
1002 ...release,
1003 version: "0.0.1999999999",
1004 desktop: { "darwin-arm64": { ...desktop, version: "0.0.2000000001" } },
1005 }),
1006 ),
1007 );
1008 const error = yield* publish().pipe(Effect.flip);
1009 expect(error.message).toContain("newer native build");
1010 expect(requests.some((entry) => entry.startsWith("PUT "))).toBe(false);
1011 }),
1012);
1013
1014it.effect("an older native build keeps the newest published container code and matching UI", () =>
1015 Effect.gen(function* () {
1016 const desktop = yield* Effect.promise(() => makeDesktop());
1017 release = { ...release, version: "0.0.2000000006", desktop: { "darwin-arm64": desktop } };
1018 yield* Effect.promise(writeRelease);
1019 const ui = yield* Effect.promise(() => makeArtifact("ui", "newer container UI"));
1020 const assets = yield* Effect.promise(() => makeArtifact("assets", "newer container assets"));
1021 const server = {
1022 ...(yield* Effect.promise(() => makeArtifact("server", "newer container code"))),
1023 runtime: "e".repeat(64),
1024 version: "0.0.2000000001",
1025 };
1026 const previous = { ...release, version: "0.0.2000000005", ui, assets, server, desktop: {} };
1027 stored.set(`${previous.version}.json`, Buffer.from(JSON.stringify(previous)));
1028 for (const artifact of [ui, assets, server])
1029 stored.set(
1030 artifact.file,
1031 yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))),
1032 );
1033 yield* publish();
1034 const published = JSON.parse(stored.get(`${release.version}.json`)!.toString());
1035 expect(published.ui).toEqual(ui);
1036 expect(published.assets).toEqual(assets);
1037 expect(published.server).toEqual(server);
1038 expect(published.desktop["darwin-arm64"]).toEqual(desktop);
1039 expect(requests).not.toContain(`PUT ${release.server.file}`);
1040 expect(requests).toContain(`PUT ${desktop.ui.file}`);
1041 }),
1042);
1043
1044it.effect("retains another platform's complete target when publishing a single native build", () =>
1045 Effect.gen(function* () {
1046 const desktop = yield* Effect.promise(() => makeDesktop());
1047 const prior = { ...desktop, version: "0.0.1999999999" };
1048 const previous = { ...release, version: "0.0.1999999999", desktop: { "linux-x64": prior } };
1049 stored.set(`${previous.version}.json`, Buffer.from(JSON.stringify(previous)));
1050 for (const entry of [prior.ui, prior.assets, prior.code, prior.app])
1051 stored.set(
1052 entry.file,
1053 yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, entry.file))),
1054 );
1055 release = { ...release, desktop: { "darwin-arm64": desktop } };
1056 yield* Effect.promise(writeRelease);
1057 yield* publish();
1058 const published = JSON.parse(stored.get(`${release.version}.json`)!.toString());
1059 expect(published.desktop["linux-x64"]).toEqual(prior);
1060 expect(published.desktop["darwin-arm64"]).toEqual(desktop);
1061 expect(stored.get("preadme.md")!.toString()).toContain("Linux x64");
1062 }),
1063);
1064
1065it.effect.each(["linux", "win32"] as const)(
1066 "ingests native %s resources without mixing desktop and server dependencies",
1067 (platform) =>
1068 Effect.gen(function* () {
1069 const source = NodePath.join(directory, "native");
1070 const resources = NodePath.join(directory, "resources");
1071 const app = NodePath.join(directory, "app-source");
1072 const backend = platform === "win32" ? NodePath.join(directory, "server-source") : app;
1073 const output = NodePath.join(directory, "ingested");
1074 const extension = platform === "linux" ? ".AppImage" : ".exe";
1075 const yaml = platform === "linux" ? "latest-linux.yml" : "latest.yml";
1076 const installer = `The-Knife-${release.version}-${platform === "linux" ? "x86_64" : "x64"}${extension}`;
1077 yield* Effect.promise(async () => {
1078 for (const path of [
1079 source,
1080 resources,
1081 NodePath.join(app, "apps/desktop/dist-electron"),
1082 NodePath.join(app, "node_modules/same"),
1083 NodePath.join(backend, "node_modules/same"),
1084 NodePath.join(backend, "apps/server/dist/client/.vite"),
1085 ])
1086 await NodeFSP.mkdir(path, { recursive: true });
1087 await NodeFSP.writeFile(
1088 NodePath.join(app, "knife-runtime.json"),
1089 JSON.stringify({
1090 version: release.version,
1091 sha256: "c".repeat(64),
1092 shell: "b".repeat(64),
1093 }),
1094 );
1095 await NodeFSP.writeFile(
1096 NodePath.join(app, "apps/desktop/dist-electron/main.cjs"),
1097 "desktop code",
1098 );
1099 await NodeFSP.writeFile(
1100 NodePath.join(app, "node_modules/same/index.js"),
1101 "desktop dependency",
1102 );
1103 await NodeFSP.writeFile(
1104 NodePath.join(backend, "node_modules/same/index.js"),
1105 "server dependency",
1106 );
1107 await NodeFSP.writeFile(NodePath.join(backend, "apps/server/dist/bin.mjs"), "server code");
1108 await NodeFSP.writeFile(
1109 NodePath.join(backend, "apps/server/dist/client/main.js"),
1110 "UI code",
1111 );
1112 await NodeFSP.writeFile(
1113 NodePath.join(backend, "apps/server/dist/client/image.png"),
1114 "static asset",
1115 );
1116 await NodeFSP.writeFile(
1117 NodePath.join(backend, "apps/server/dist/client/.vite/manifest.json"),
1118 "{}",
1119 );
1120 await createPackage(app, NodePath.join(resources, "app.asar"));
1121 if (platform === "win32")
1122 await createPackage(backend, NodePath.join(resources, "server.asar"));
1123 await NodeFSP.writeFile(NodePath.join(resources, "knife-server-runtime"), "e".repeat(64));
1124 NodeChildProcess.execFileSync("tar", [
1125 "-czf",
1126 NodePath.join(source, `knife-resources-${platform}-x64.tar.gz`),
1127 "-C",
1128 resources,
1129 "app.asar",
1130 ...(platform === "win32" ? ["server.asar"] : []),
1131 "knife-server-runtime",
1132 ]);
1133 const bytes = Buffer.from("native installer fixture");
1134 await NodeFSP.writeFile(NodePath.join(source, installer), bytes);
1135 const sha512 = NodeCrypto.createHash("sha512").update(bytes).digest("base64");
1136 const deb = `The-Knife-${release.version}-amd64.deb`;
1137 if (platform === "linux") await NodeFSP.writeFile(NodePath.join(source, deb), bytes);
1138 await NodeFSP.writeFile(
1139 NodePath.join(source, yaml),
1140 `version: ${release.version}\nfiles:\n - url: ${installer}\n sha512: ${sha512}\n size: ${bytes.length}\n${platform === "linux" ? ` blockMapSize: 178033\n - url: ${deb}\n sha512: ${sha512}\n size: ${bytes.length}\n` : ""}releaseDate: '2026-10-16T00:00:00.000Z'\n`,
1141 );
1142 });
1143 yield* ingestForkRelease(`${platform}-x64`, source, output, "0.0.2000000001").pipe(
1144 Effect.provide(NodeServices.layer),
1145 );
1146 const ingested = yield* Effect.promise(() =>
1147 NodeFSP.readFile(NodePath.join(output, "release.json"), "utf8"),
1148 ).pipe(Effect.flatMap(decodeRelease));
1149 const target = ingested.desktop[`${platform}-x64`]!;
1150 expect(ingested.version).toBe("0.0.2000000001");
1151 expect(target.version).toBe(release.version);
1152 expect(target.app.version).toBe(release.version);
1153 expect(ingested.server.runtime).toBe("e".repeat(64));
1154 expect(ingested.server.version).toBe(release.version);
1155 yield* Effect.promise(async () => {
1156 const extracted = NodePath.join(directory, "extracted");
1157 await NodeFSP.mkdir(extracted);
1158 NodeChildProcess.execFileSync("tar", [
1159 "-xzf",
1160 NodePath.join(output, target.code.file),
1161 "-C",
1162 extracted,
1163 ]);
1164 expect(
1165 await NodeFSP.readFile(
1166 NodePath.join(
1167 extracted,
1168 platform === "win32" ? "server/apps/server/dist/bin.mjs" : "apps/server/dist/bin.mjs",
1169 ),
1170 "utf8",
1171 ),
1172 ).toBe("server code");
1173 if (platform === "win32") {
1174 expect(
1175 await NodeFSP.readFile(NodePath.join(extracted, "node_modules/same/index.js"), "utf8"),
1176 ).toBe("desktop dependency");
1177 expect(
1178 await NodeFSP.readFile(
1179 NodePath.join(extracted, "server/node_modules/same/index.js"),
1180 "utf8",
1181 ),
1182 ).toBe("server dependency");
1183 }
1184 });
1185 yield* publishForkRelease(output, {
1186 feedUrl: `${origin}/read/`,
1187 uploadUrl: `${origin}/write/`,
1188 uploadApiKey: "fixture-key",
1189 }).pipe(
1190 Effect.scoped,
1191 Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)),
1192 );
1193 expect(stored.get("preadme.md")!.toString()).toContain(
1194 `href="The-Knife-${platform}-x64${extension}"`,
1195 );
1196 expect(stored.has(`${target.app.version}${platform === "linux" ? "-linux" : ""}.yml`)).toBe(
1197 true,
1198 );
1199 const publicManifest = parseUpdateManifest(
1200 stored.get(`${target.app.version}${platform === "linux" ? "-linux" : ""}.yml`)!.toString(),
1201 yaml,
1202 platform,
1203 );
1204 expect(publicManifest.files.map((file) => file.url)).toEqual([
1205 `The-Knife-${platform}-x64${extension}`,
1206 ]);
1207 expect(stored.has(installer)).toBe(false);
1208 expect(publicManifest.files.every((file) => stored.has(file.url))).toBe(true);
1209 }),
1210);
scripts/release-fork.ts created+1215
...@@ -0,0 +1,1215 @@
1// @effect-diagnostics nodeBuiltinImport:off - Release validation reads local archives at the CLI boundary.
2import * as NodeCrypto from "node:crypto";
3import * as NodeFS from "node:fs";
4import * as NodeFSP from "node:fs/promises";
5import * as NodeOS from "node:os";
6import * as NodePath from "node:path";
7import * as NodeURL from "node:url";
8import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
9import * as NodeServices from "@effect/platform-node/NodeServices";
10import * as Clock from "effect/Clock";
11import * as Effect from "effect/Effect";
12import * as Data from "effect/Data";
13import * as Layer from "effect/Layer";
14import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";
16import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
17import { ChildProcess, ChildProcessSpawner } from "effect/process";
18import type { HttpMethod } from "effect/http/HttpMethod";
19import { extractAll } from "@electron/asar";
20
21import {
22 ForkReleaseManifest,
23 ForkReleaseListing,
24 ForkRuntimeManifest,
25 latestForkRelease,
26} from "@t3tools/shared/forkUpdateDirectory";
27
28import { parseUpdateManifest, serializeUpdateManifest } from "./lib/update-manifest.ts";
29import { fingerprintForkServerRuntime } from "./lib/fork-runtime.ts";
30import { loadRepoEnv } from "./lib/public-config.ts";
31import * as HostProcess from "@t3tools/shared/HostProcess";
32
33const ReleaseConfig = Schema.Struct({
34 feedUrl: Schema.String.check(Schema.isPattern(/^https:\/\//)),
35 uploadUrl: Schema.String.check(Schema.isPattern(/^https:\/\//)),
36 signingIdentity: Schema.String.check(Schema.isPattern(/^[A-Fa-f0-9]{40}$/)),
37 signingKeychain: Schema.NonEmptyString,
38 notarizationKeychainProfile: Schema.optionalKey(Schema.NonEmptyString),
39 uploadApiKey: Schema.optionalKey(Schema.NonEmptyString),
40});
41type ReleaseConfig = typeof ReleaseConfig.Type;
42
43class ForkReleaseError extends Data.TaggedError("ForkReleaseError")<{
44 readonly message: string;
45}> {}
46
47class ForkReleaseCollision extends Data.TaggedError("ForkReleaseCollision")<{
48 readonly message: string;
49}> {}
50
51const repoRoot = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)));
52
53const run = Effect.fn("fork.release.run")(function* (
54 command: string,
55 args: string[],
56 options: { env?: NodeJS.ProcessEnv; capture?: boolean } = {},
57) {
58 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
59 const child = yield* spawner.spawn(
60 ChildProcess.make(command, args, {
61 cwd: repoRoot,
62 env: options.env,
63 stdout: options.capture ? "pipe" : "inherit",
64 stderr: "inherit",
65 stdin: "ignore",
66 }),
67 );
68 const [chunks, code] = yield* Effect.all([Stream.runCollect(child.stdout), child.exitCode], {
69 concurrency: "unbounded",
70 });
71 if (Number(code) !== 0)
72 return yield* new ForkReleaseError({ message: `${command} exited with code ${code}.` });
73 const decoder = new TextDecoder();
74 return chunks.map((chunk) => decoder.decode(chunk, { stream: true })).join("") + decoder.decode();
75});
76
77const request = Effect.fn("fork.release.request")(function* (
78 input: HttpClientRequest.HttpClientRequest,
79) {
80 const http = HttpClient.withScope(yield* HttpClient.HttpClient);
81 const response = yield* http.execute(input).pipe(
82 Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }),
83 Effect.mapError(() => new ForkReleaseError({ message: "Could not reach the release share." })),
84 );
85 if (
86 (response.status < 200 || response.status >= 300) &&
87 !(input.method === "DELETE" && response.status === 404)
88 ) {
89 return yield* new ForkReleaseError({
90 message: `Release share returned HTTP ${response.status}.`,
91 });
92 }
93 return response;
94});
95
96const desktopTargets = {
97 darwin: { manifest: "latest-mac.yml", channel: "-mac", label: "macOS", extension: ".zip" },
98 linux: {
99 manifest: "latest-linux.yml",
100 channel: "-linux",
101 label: "Linux",
102 extension: ".AppImage",
103 },
104 win32: { manifest: "latest.yml", channel: "", label: "Windows", extension: ".exe" },
105};
106
107const installerSuffix = (platform: keyof typeof desktopTargets, arch: string) =>
108 `-${platform === "linux" && arch === "x64" ? "x86_64" : arch}${desktopTargets[platform].extension}`;
109
110const verifyDesktopRelease = (
111 directory: string,
112 platform: keyof typeof desktopTargets = "darwin",
113) =>
114 Effect.tryPromise({
115 try: async () => {
116 const target = desktopTargets[platform];
117 const raw = await NodeFSP.readFile(NodePath.join(directory, target.manifest), "utf8");
118 const manifest = parseUpdateManifest(raw, target.manifest, target.label);
119 if (!/^0\.0\.\d+$/.test(manifest.version))
120 throw new ForkReleaseError({ message: "Not a private fork release version." });
121 if (!manifest.files.some((file) => file.url.endsWith(target.extension))) {
122 throw new ForkReleaseError({ message: `The release has no ${target.label} installer.` });
123 }
124 const files: string[] = [];
125 for (const file of manifest.files) {
126 if (
127 NodePath.basename(file.url) !== file.url ||
128 (!file.url.startsWith(`The-Knife-${manifest.version}-`) &&
129 !["arm64", "x64"].some(
130 (arch) => file.url === `The-Knife-${platform}-${arch}${target.extension}`,
131 ))
132 ) {
133 throw new ForkReleaseError({
134 message: "The release manifest contains an unexpected artifact path.",
135 });
136 }
137 const artifact = NodePath.join(directory, file.url);
138 const stat = await NodeFSP.stat(artifact);
139 const hash = NodeCrypto.createHash("sha512");
140 for await (const chunk of NodeFS.createReadStream(artifact)) hash.update(chunk);
141 if (stat.size !== file.size || hash.digest("base64") !== file.sha512) {
142 throw new ForkReleaseError({ message: `Artifact verification failed: ${file.url}.` });
143 }
144 files.push(file.url);
145 if (
146 await NodeFSP.stat(`${artifact}.blockmap`).then(
147 () => true,
148 () => false,
149 )
150 )
151 files.push(`${file.url}.blockmap`);
152 }
153 return { manifest, raw, files };
154 },
155 catch: (cause) =>
156 cause instanceof ForkReleaseError
157 ? cause
158 : new ForkReleaseError({ message: "Could not verify the release artifacts." }),
159 });
160
161const decodeRelease = Schema.decodeUnknownEffect(Schema.fromJsonString(ForkReleaseManifest));
162const encodeRelease = Schema.encodeEffect(Schema.fromJsonString(ForkReleaseManifest));
163const PublicationIntentJson = Schema.fromJsonString(
164 Schema.Struct({
165 release: ForkReleaseManifest,
166 retiredFiles: Schema.Array(Schema.String),
167 }),
168);
169const decodePublicationIntent = Schema.decodeUnknownEffect(PublicationIntentJson);
170const encodePublicationIntent = Schema.encodeEffect(PublicationIntentJson);
171const decodeListing = Schema.decodeUnknownEffect(ForkReleaseListing);
172
173const completedVersions = (listing: typeof ForkReleaseListing.Type) =>
174 listing.files
175 .flatMap((file) => {
176 const version = latestForkRelease({ files: [file] });
177 return version ? [version] : [];
178 })
179 .sort((a, b) => (BigInt(a.slice(4)) > BigInt(b.slice(4)) ? -1 : 1));
180
181const readReleaseListing = (feed: URL) =>
182 Effect.gen(function* () {
183 const response = yield* request(HttpClientRequest.get(new URL("?ls", feed).href));
184 return yield* decodeListing(yield* response.json);
185 });
186
187const readPublishedRelease = (feed: URL, version: string) =>
188 Effect.gen(function* () {
189 const manifest = yield* request(HttpClientRequest.get(new URL(`${version}.json`, feed).href));
190 const release = yield* decodeRelease(yield* manifest.text);
191 if (release.version !== version)
192 return yield* new ForkReleaseError({
193 message: "The published manifest has the wrong version.",
194 });
195 return release;
196 });
197
198const previousRelease = (feed: URL) =>
199 Effect.scoped(
200 Effect.gen(function* () {
201 const version = latestForkRelease(yield* readReleaseListing(feed));
202 return version ? yield* readPublishedRelease(feed, version) : undefined;
203 }),
204 );
205
206const artifact = (directory: string, file: string) =>
207 Effect.tryPromise(async () => {
208 const source = NodePath.join(directory, file);
209 const hash = NodeCrypto.createHash("sha256");
210 for await (const chunk of NodeFS.createReadStream(source)) hash.update(chunk);
211 return { file, sha256: hash.digest("hex"), size: (await NodeFSP.stat(source)).size };
212 });
213
214const publicReadme = (release: typeof ForkReleaseManifest.Type, serverAvailable: boolean) => {
215 const instructions = new Set<string>();
216 const downloads = Object.keys(release.desktop).flatMap((key) => {
217 const [platform, arch] = key.split("-");
218 if (platform !== "darwin" && platform !== "linux" && platform !== "win32") return [];
219 const architecture =
220 arch === "arm64"
221 ? platform === "darwin"
222 ? "Apple silicon"
223 : "ARM64"
224 : platform === "darwin"
225 ? "Intel"
226 : "x64";
227 const install =
228 platform === "darwin"
229 ? "Unzip, then move The Knife to Applications. If macOS blocks the first launch, see [Apple’s opening instructions](https://support.apple.com/102445)."
230 : platform === "linux"
231 ? "Make the AppImage executable, then open it."
232 : "Run the installer.";
233 instructions.add(`**${desktopTargets[platform].label}:** ${install}`);
234 return [
235 `<a href="The-Knife-${key}${desktopTargets[platform].extension}">${desktopTargets[platform].label} ${architecture}</a>`,
236 ];
237 });
238 return [
239 "## The Knife",
240 "",
241 "A focused workspace for coding agents, based on [T3 Code](https://t3.codes). Bring your own agent subscriptions and keep your work on your machines.",
242 "",
243 ...(downloads.length > 0
244 ? [
245 `<p align="center">${downloads.join(" &nbsp; · &nbsp; ")}</p>`,
246 "",
247 [...instructions].join(" "),
248 "",
249 ]
250 : []),
251 "Updates download automatically. The app asks before restarting; UI updates apply when your work can be preserved.",
252 "",
253 ...(serverAvailable
254 ? [
255 "**Server:** [Docker bundle](knife-server-linux-amd64.tar.gz) · [SHA-256](knife-server-linux-amd64.tar.gz.sha256). Includes the image and configuration for a Linux x64 host.",
256 "",
257 ]
258 : []),
259 ].join("\n");
260};
261
262const releaseArtifacts = (release: typeof ForkReleaseManifest.Type) => [
263 release.ui,
264 release.assets,
265 release.server,
266 ...Object.values(release.desktop).flatMap((desktop) => [
267 desktop.ui,
268 desktop.assets,
269 desktop.code,
270 desktop.app,
271 ]),
272];
273
274const releaseFiles = (release: typeof ForkReleaseManifest.Type) => {
275 const files = [
276 ...releaseArtifacts(release).map((artifact) => artifact.file),
277 `${release.version}.json`,
278 ];
279 for (const [key, desktop] of Object.entries(release.desktop)) {
280 files.push(`${desktop.app.file}.blockmap`);
281 const platform = key.slice(0, key.lastIndexOf("-"));
282 if (platform === "darwin" || platform === "linux" || platform === "win32")
283 files.push(`${desktop.app.version}${desktopTargets[platform].channel}.yml`);
284 }
285 return files;
286};
287
288const maintainPublicFolder = Effect.fn("fork.release.maintainPublicFolder")(function* (
289 release: typeof ForkReleaseManifest.Type,
290 feedBase: URL,
291 uploadBase: URL,
292 apiKey: string | undefined,
293 completed: ReadonlyArray<string>,
294 retiredFiles: ReadonlySet<string>,
295) {
296 const authenticated = (input: HttpClientRequest.HttpClientRequest) =>
297 input.pipe(HttpClientRequest.setHeaders(apiKey ? { PW: apiKey } : {}));
298 const http = HttpClient.withScope(yield* HttpClient.HttpClient);
299 const initialVersions = completedVersions(yield* readReleaseListing(feedBase));
300 if (initialVersions.join("\n") !== completed.join("\n")) return false;
301 for (const key of Object.keys(release.desktop)) {
302 const oldPage = `The-Knife-${key}.html`;
303 const page = yield* http.head(new URL(oldPage, feedBase).href);
304 if (page.status !== 200 && page.status !== 404)
305 return yield* new ForkReleaseError({
306 message: `Release share returned HTTP ${page.status}.`,
307 });
308 if (page.status === 200) {
309 const removed = yield* request(
310 authenticated(HttpClientRequest.make("DELETE")(new URL(oldPage, uploadBase).href)),
311 );
312 yield* removed.arrayBuffer;
313 }
314 }
315
316 const server = yield* http.head(new URL("knife-server-linux-amd64.tar.gz", feedBase).href);
317 if (server.status !== 200 && server.status !== 404)
318 return yield* new ForkReleaseError({
319 message: `Release share returned HTTP ${server.status}.`,
320 });
321 const readme = publicReadme(release, server.status === 200);
322 const existing = yield* http.get(new URL("preadme.md", feedBase).href);
323 if (existing.status !== 200 || (yield* existing.text) !== readme) {
324 if (existing.status !== 200 && existing.status !== 404)
325 return yield* new ForkReleaseError({
326 message: `Release share returned HTTP ${existing.status}.`,
327 });
328 const uploaded = yield* request(
329 authenticated(
330 HttpClientRequest.put(new URL("preadme.md", uploadBase).href).pipe(
331 HttpClientRequest.setHeader("Replace", "1"),
332 HttpClientRequest.bodyText(readme, "text/markdown; charset=utf-8"),
333 ),
334 ),
335 );
336 yield* uploaded.arrayBuffer;
337 const readable = yield* request(HttpClientRequest.get(new URL("preadme.md", feedBase).href));
338 if ((yield* readable.text) !== readme)
339 return yield* new ForkReleaseError({
340 message: "The download page could not be updated. Retry publication.",
341 });
342 }
343 const listing = yield* readReleaseListing(feedBase);
344 let versions = completedVersions(listing);
345 if (versions.join("\n") !== completed.join("\n")) return false;
346
347 const retained = new Set(releaseFiles(release));
348 for (const version of versions.slice(0, 2)) {
349 for (const file of releaseFiles(yield* readPublishedRelease(feedBase, version)))
350 retained.add(file);
351 }
352 const obsolete = new Set([...retiredFiles].filter((file) => !retained.has(file)));
353 for (const version of versions.slice(2)) {
354 for (const file of releaseFiles(yield* readPublishedRelease(feedBase, version))) {
355 if (
356 !retained.has(file) &&
357 (/^knife-(?:ui|assets|server|code)-[a-f0-9]{64}\.tar\.gz$/.test(file) ||
358 /^The-Knife-0\.0\.\d+-[\w.-]+\.(?:zip|exe|AppImage)(?:\.blockmap)?$/.test(file) ||
359 /^0\.0\.\d+(?:-mac|-linux)?\.(?:json|yml)$/.test(file))
360 )
361 obsolete.add(file);
362 }
363 }
364 const visible = new Set(listing.files.map((file) => file.href));
365 const deletions = [...obsolete].sort(
366 (a, b) => Number(a.endsWith(".json")) - Number(b.endsWith(".json")),
367 );
368 for (const file of deletions) {
369 if (!visible.has(file)) continue;
370 const currentVersions = completedVersions(yield* readReleaseListing(feedBase));
371 if (currentVersions.join("\n") !== versions.join("\n")) return false;
372 const deleted = yield* request(
373 authenticated(
374 HttpClientRequest.make("DELETE")(new URL(encodeURIComponent(file), uploadBase).href),
375 ),
376 );
377 yield* deleted.arrayBuffer;
378 if (file.endsWith(".json")) versions = versions.filter((version) => `${version}.json` !== file);
379 }
380 return true;
381});
382
383export const publishForkRelease = Effect.fn("fork.release.publish")(function* (
384 directory: string,
385 config: Pick<ReleaseConfig, "feedUrl" | "uploadUrl" | "uploadApiKey">,
386) {
387 const raw = yield* Effect.tryPromise(() =>
388 NodeFSP.readFile(NodePath.join(directory, "release.json"), "utf8"),
389 );
390 const built = yield* decodeRelease(raw);
391 for (const expected of releaseArtifacts(built)) {
392 const actual = yield* artifact(directory, expected.file).pipe(
393 Effect.mapError(
394 () =>
395 new ForkReleaseError({
396 message: `Could not verify the local artifact: ${expected.file}.`,
397 }),
398 ),
399 );
400 if (actual.sha256 !== expected.sha256 || actual.size !== expected.size)
401 return yield* new ForkReleaseError({
402 message: `Artifact verification failed: ${expected.file}.`,
403 });
404 }
405 const desktopReleases = new Map<
406 keyof typeof desktopTargets,
407 {
408 manifest: ReturnType<typeof parseUpdateManifest>;
409 raw: string;
410 files: string[];
411 }
412 >();
413 for (const [key, desktop] of Object.entries(built.desktop)) {
414 const platform = key.slice(0, key.lastIndexOf("-"));
415 if (platform !== "darwin" && platform !== "linux" && platform !== "win32")
416 return yield* new ForkReleaseError({ message: `Unsupported desktop platform: ${key}.` });
417 let desktopRelease = desktopReleases.get(platform);
418 if (!desktopRelease) {
419 desktopRelease = yield* verifyDesktopRelease(directory, platform);
420 desktopReleases.set(platform, desktopRelease);
421 }
422 if (
423 desktopRelease.manifest.version !== desktop.version ||
424 desktop.app.version !== desktop.version ||
425 !desktopRelease.files.includes(desktop.app.file)
426 )
427 return yield* new ForkReleaseError({
428 message: "The local app does not match its updater manifest.",
429 });
430 }
431
432 const intentPath = NodePath.join(directory, "publication.json");
433 const intent = NodeFS.existsSync(intentPath)
434 ? yield* Effect.tryPromise(() => NodeFSP.readFile(intentPath, "utf8")).pipe(
435 Effect.flatMap(decodePublicationIntent),
436 )
437 : undefined;
438 if (intent !== undefined && intent.release.version !== built.version)
439 return yield* new ForkReleaseError({
440 message: "The publication intent belongs to a different release.",
441 });
442 const uploadBase = new URL(
443 config.uploadUrl.endsWith("/") ? config.uploadUrl : `${config.uploadUrl}/`,
444 );
445 const feedBase = new URL(config.feedUrl.endsWith("/") ? config.feedUrl : `${config.feedUrl}/`);
446 const previous = yield* previousRelease(feedBase);
447 if (previous && BigInt(previous.version.slice(4)) > BigInt(built.version.slice(4)))
448 return yield* new ForkReleaseError({
449 message: "This release or a newer version is already published. Build a new version.",
450 });
451 for (const [platform, desktop] of Object.entries(built.desktop)) {
452 const prior = previous?.desktop[platform];
453 if (prior && BigInt(prior.version.slice(4)) > BigInt(desktop.version.slice(4)))
454 return yield* new ForkReleaseError({
455 message: `A newer native build is already published for ${platform}.`,
456 });
457 }
458
459 const serverRelease =
460 previous && BigInt(previous.server.version.slice(4)) > BigInt(built.server.version.slice(4))
461 ? previous
462 : built;
463 const release = intent?.release ?? {
464 ...built,
465 ui: serverRelease.ui,
466 assets: serverRelease.assets,
467 server: serverRelease.server,
468 desktop: {
469 ...previous?.desktop,
470 ...Object.fromEntries(
471 Object.entries(built.desktop).map(([platform, desktop]) => {
472 const prior = previous?.desktop[platform];
473 const nativePlatform = platform.slice(0, platform.lastIndexOf("-"));
474 if (
475 nativePlatform !== "darwin" &&
476 nativePlatform !== "linux" &&
477 nativePlatform !== "win32"
478 )
479 throw new ForkReleaseError({ message: `Unsupported desktop platform: ${platform}.` });
480 const file = `The-Knife-${platform}${desktopTargets[nativePlatform].extension}`;
481 return [
482 platform,
483 {
484 ...desktop,
485 code: prior?.runtime === desktop.runtime ? prior.code : desktop.code,
486 app: {
487 ...(prior?.shell === desktop.shell && prior.app.file === file
488 ? prior.app
489 : desktop.app),
490 file,
491 },
492 },
493 ];
494 }),
495 ),
496 },
497 };
498 const encoded = yield* encodeRelease(release);
499 if (previous?.version === release.version) {
500 if ((yield* encodeRelease(previous)) !== encoded)
501 return yield* new ForkReleaseError({
502 message: `Published manifest has different contents: ${release.version}.json.`,
503 });
504 }
505 const sidecars = [];
506 const sources = new Map<string, string>();
507 for (const [key, desktop] of Object.entries(built.desktop)) {
508 const target = release.desktop[key]!;
509 if (target.app.sha256 !== desktop.app.sha256) continue;
510 sources.set(target.app.file, desktop.app.file);
511 const file = `${desktop.app.file}.blockmap`;
512 if (
513 yield* Effect.tryPromise(() => NodeFSP.stat(NodePath.join(directory, file))).pipe(
514 Effect.map((stat) => stat.isFile()),
515 Effect.orElseSucceed(() => false),
516 )
517 ) {
518 const sidecar = yield* artifact(directory, file);
519 const name = `${target.app.file}.blockmap`;
520 sidecars.push({ ...sidecar, file: name });
521 sources.set(name, file);
522 }
523 }
524 const files = new Map(
525 [...releaseArtifacts(release), ...sidecars].map((entry) => [entry.file, entry]),
526 );
527 const mutableFiles = new Set([
528 ...Object.values(release.desktop).map((desktop) => desktop.app.file),
529 ...sidecars.map((sidecar) => sidecar.file),
530 ]);
531 const retiredFiles = new Set(intent?.retiredFiles);
532
533 const upload = (name: string, body: HttpClientRequest.HttpClientRequest) =>
534 request(
535 body.pipe(
536 HttpClientRequest.setUrl(new URL(encodeURIComponent(name), uploadBase).href),
537 HttpClientRequest.setHeaders(config.uploadApiKey ? { PW: config.uploadApiKey } : {}),
538 ),
539 ).pipe(
540 Effect.flatMap((response) => response.arrayBuffer),
541 Effect.asVoid,
542 );
543
544 const http = HttpClient.withScope(yield* HttpClient.HttpClient);
545 const publishManifest = (name: string, contents: string, contentType: string) =>
546 Effect.gen(function* () {
547 const existing = yield* http.get(new URL(name, feedBase).href);
548 if (existing.status === 200) {
549 if ((yield* existing.text) === contents) return;
550 return yield* new ForkReleaseCollision({
551 message: `Published manifest has different contents: ${name}.`,
552 });
553 }
554 if (existing.status !== 404)
555 return yield* new ForkReleaseError({
556 message: `Release share returned HTTP ${existing.status}.`,
557 });
558 yield* upload(
559 name,
560 HttpClientRequest.put("").pipe(HttpClientRequest.bodyText(contents, contentType)),
561 );
562 const readable = yield* request(HttpClientRequest.get(new URL(name, feedBase).href));
563 if ((yield* readable.text) !== contents)
564 return yield* new ForkReleaseCollision({
565 message: `Published manifest has different contents: ${name}.`,
566 });
567 });
568 const matchesDownload = Effect.fn("fork.release.matchesDownload")(function* (
569 name: string,
570 expected: { readonly size: number; readonly sha256: string },
571 ) {
572 const response = yield* http.get(new URL(name, feedBase).href);
573 if (response.status === 404) return false;
574 if (response.status !== 200)
575 return yield* new ForkReleaseError({
576 message: `Release share returned HTTP ${response.status}.`,
577 });
578 const hash = NodeCrypto.createHash("sha256");
579 let size = 0;
580 yield* Stream.runForEach(response.stream, (bytes) =>
581 Effect.sync(() => {
582 hash.update(bytes);
583 size += bytes.length;
584 }),
585 );
586 return size === expected.size && hash.digest("hex") === expected.sha256;
587 });
588 const remove = (name: string) => upload(name, HttpClientRequest.make("DELETE")(""));
589 const retireConflictingReleases = Effect.fn("fork.release.retireConflictingReleases")(
590 function* () {
591 const listing = yield* readReleaseListing(feedBase);
592 const visible = new Set(listing.files.map((file) => file.href));
593 const completed = [];
594 for (const version of completedVersions(listing))
595 completed.push(yield* readPublishedRelease(feedBase, version));
596 if (
597 completed.some(
598 (completed) => BigInt(completed.version.slice(4)) > BigInt(release.version.slice(4)),
599 )
600 )
601 return yield* new ForkReleaseError({
602 message:
603 "A newer release completed during installer publication. Retry with a new version.",
604 });
605 const retained = completed.filter((completed) =>
606 Object.entries(completed.desktop).every(([key, target]) => {
607 const current = release.desktop[key];
608 return (
609 current === undefined ||
610 (current.app.file === target.app.file &&
611 current.app.sha256 === target.app.sha256 &&
612 current.app.size === target.app.size)
613 );
614 }),
615 );
616 const protectedFiles = new Set([...releaseFiles(release), ...retained.flatMap(releaseFiles)]);
617 const conflicting = completed.filter((completed) => !retained.includes(completed));
618 if (conflicting.length > 0) {
619 const retired = new Set([...retiredFiles, ...conflicting.flatMap(releaseFiles)]);
620 const encodedIntent = yield* encodePublicationIntent({
621 release,
622 retiredFiles: [...retired],
623 });
624 yield* Effect.tryPromise(async () => {
625 await NodeFSP.writeFile(`${intentPath}.tmp`, encodedIntent + "\n");
626 await NodeFSP.rename(`${intentPath}.tmp`, intentPath);
627 });
628 for (const file of retired) retiredFiles.add(file);
629 }
630 for (const completedRelease of conflicting) yield* remove(`${completedRelease.version}.json`);
631 for (const file of retiredFiles) {
632 if (file.endsWith(".yml") && !protectedFiles.has(file) && visible.has(file))
633 yield* remove(file);
634 }
635 },
636 );
637 const publicationOrder = [...files.values()].toSorted(
638 (left, right) => Number(mutableFiles.has(left.file)) - Number(mutableFiles.has(right.file)),
639 );
640 for (const expected of publicationOrder) {
641 const mutable = mutableFiles.has(expected.file);
642 if (mutable) {
643 if (yield* matchesDownload(expected.file, expected)) continue;
644 const source = sources.get(expected.file);
645 if (source === undefined)
646 return yield* new ForkReleaseError({
647 message: `The installer is unavailable: ${expected.file}.`,
648 });
649 const actual = yield* artifact(directory, source);
650 if (actual.sha256 !== expected.sha256 || actual.size !== expected.size)
651 return yield* new ForkReleaseError({ message: `Artifact verification failed: ${source}.` });
652 const temporary = `${expected.file}.${NodeCrypto.randomUUID()}.PARTIAL`;
653 yield* Effect.gen(function* () {
654 const put = yield* HttpClientRequest.put("").pipe(
655 HttpClientRequest.bodyFile(NodePath.join(directory, source)),
656 );
657 yield* upload(temporary, put);
658 if (!(yield* matchesDownload(temporary, expected)))
659 return yield* new ForkReleaseError({
660 message: `The uploaded installer did not match its checksum: ${expected.file}.`,
661 });
662 yield* retireConflictingReleases();
663 // Effect's method union omits the WebDAV MOVE supported by the share.
664 yield* upload(
665 temporary,
666 HttpClientRequest.make("MOVE" as HttpMethod)("").pipe(
667 HttpClientRequest.setHeaders({
668 Destination: new URL(expected.file, uploadBase).href,
669 Overwrite: "T",
670 }),
671 ),
672 );
673 if (!(yield* matchesDownload(expected.file, expected)))
674 return yield* new ForkReleaseError({
675 message: `The promoted installer did not match its checksum: ${expected.file}.`,
676 });
677 }).pipe(Effect.ensuring(remove(temporary).pipe(Effect.ignore)));
678 continue;
679 }
680 const visible = yield* http.head(new URL(expected.file, feedBase).href);
681 if (visible.status === 200 && Number(visible.headers["content-length"]) === expected.size)
682 continue;
683 if (visible.status !== 404)
684 return yield* new ForkReleaseError({
685 message: `Published artifact does not match: ${expected.file}.`,
686 });
687 const put = yield* HttpClientRequest.put("").pipe(
688 HttpClientRequest.bodyFile(NodePath.join(directory, expected.file)),
689 );
690 yield* upload(expected.file, put);
691 const readable = yield* request(HttpClientRequest.head(new URL(expected.file, feedBase).href));
692 if (Number(readable.headers["content-length"]) !== expected.size)
693 return yield* new ForkReleaseError({
694 message: `Published artifact is not readable: ${expected.file}.`,
695 });
696 yield* Effect.log(`Published ${expected.file} (${expected.size} bytes)`);
697 }
698 if (previous?.version !== release.version) {
699 yield* retireConflictingReleases();
700 for (const [platform, desktopRelease] of desktopReleases) {
701 const [key, desktop] = Object.entries(built.desktop).find(([key]) =>
702 key.startsWith(`${platform}-`),
703 )!;
704 const target = release.desktop[key]!;
705 if (target.app.version !== desktopRelease.manifest.version) continue;
706 const name = `${desktopRelease.manifest.version}${desktopTargets[platform].channel}.yml`;
707 const manifest = {
708 ...desktopRelease.manifest,
709 files: desktopRelease.manifest.files
710 .filter((file) => file.url === desktop.app.file)
711 .map((file) => ({ ...file, url: target.app.file })),
712 };
713 yield* publishManifest(
714 name,
715 serializeUpdateManifest(manifest, {
716 platformLabel: desktopTargets[platform].label,
717 }),
718 "text/yaml",
719 );
720 }
721 yield* publishManifest(`${release.version}.json`, encoded + "\n", "application/json");
722 }
723
724 for (let attempt = 0; attempt < 8; attempt++) {
725 const versions = completedVersions(yield* readReleaseListing(feedBase));
726 const newest = versions[0];
727 if (!newest || BigInt(newest.slice(4)) < BigInt(release.version.slice(4)))
728 return yield* new ForkReleaseError({
729 message: "The release is missing from the feed. Retry publication.",
730 });
731 const latest = yield* readPublishedRelease(feedBase, newest);
732 let serverRelease = latest;
733 const desktop = { ...latest.desktop };
734 for (const version of versions.slice(1)) {
735 const completed = yield* readPublishedRelease(feedBase, version);
736 if (BigInt(completed.server.version.slice(4)) > BigInt(serverRelease.server.version.slice(4)))
737 serverRelease = completed;
738 for (const [platform, target] of Object.entries(completed.desktop)) {
739 const current = desktop[platform];
740 if (!current || BigInt(target.version.slice(4)) > BigInt(current.version.slice(4)))
741 desktop[platform] = target;
742 }
743 }
744 const merged = {
745 ...latest,
746 ui: serverRelease.ui,
747 assets: serverRelease.assets,
748 server: serverRelease.server,
749 desktop,
750 };
751 if ((yield* encodeRelease(merged)) === (yield* encodeRelease(latest))) {
752 const currentVersions = completedVersions(yield* readReleaseListing(feedBase));
753 if (currentVersions.join("\n") !== versions.join("\n")) continue;
754 const maintained = yield* maintainPublicFolder(
755 latest,
756 feedBase,
757 uploadBase,
758 config.uploadApiKey,
759 versions,
760 retiredFiles,
761 );
762 if (!maintained) continue;
763 if (NodeFS.existsSync(intentPath)) {
764 const completed = yield* readPublishedRelease(feedBase, release.version);
765 if ((yield* encodeRelease(completed)) !== encoded)
766 return yield* new ForkReleaseError({
767 message: "The completed release differs from the publication intent.",
768 });
769 yield* Effect.tryPromise(() => NodeFSP.rm(intentPath));
770 }
771 yield* Effect.log(`Fork release ${latest.version} is available to all machines.`);
772 return;
773 }
774 const timestamp = BigInt(Math.floor((yield* Clock.currentTimeMillis) / 1000));
775 const next = BigInt(newest.slice(4)) + 1n;
776 merged.version = `0.0.${timestamp > next ? timestamp : next}`;
777 yield* publishManifest(
778 `${merged.version}.json`,
779 (yield* encodeRelease(merged)) + "\n",
780 "application/json",
781 ).pipe(Effect.catchTags({ ForkReleaseCollision: () => Effect.void }));
782 }
783 return yield* new ForkReleaseError({
784 message: "Concurrent releases are still completing. Retry publication.",
785 });
786});
787
788const archiveComponent = Effect.fn("fork.release.archive")(function* (
789 directory: string,
790 source: string,
791 name: string,
792) {
793 const entries = yield* Effect.tryPromise(async () => {
794 const files: string[] = [];
795 const visit = async (relative: string): Promise<void> => {
796 const file = NodePath.join(source, relative);
797 const stat = await NodeFSP.lstat(file);
798 if (stat.isDirectory()) {
799 for (const child of (await NodeFSP.readdir(file)).sort())
800 await visit(NodePath.join(relative, child));
801 }
802 if (!stat.isSymbolicLink()) await NodeFSP.utimes(file, 0, 0);
803 files.push(relative);
804 };
805 await visit(".");
806 return files;
807 });
808 const list = NodePath.join(directory, `${name}.files`);
809 yield* Effect.tryPromise(() => NodeFSP.writeFile(list, entries.join("\n") + "\n"));
810 const temporary = `${name}.tar.gz`;
811 const platform = yield* HostProcess.Platform;
812 const owner =
813 platform === "darwin"
814 ? [
815 "--uid",
816 "0",
817 "--gid",
818 "0",
819 "--uname",
820 "root",
821 "--gname",
822 "root",
823 "--options",
824 "gzip:!timestamp",
825 ]
826 : ["--owner=0", "--group=0", "--numeric-owner"];
827 yield* run("tar", [
828 "--format=gnutar",
829 ...owner,
830 "--no-recursion",
831 "-czf",
832 NodePath.join(directory, temporary),
833 "-C",
834 source,
835 "-T",
836 list,
837 ]);
838 yield* Effect.tryPromise(() => NodeFSP.rm(list));
839 const result = yield* artifact(directory, temporary);
840 const file = `knife-${name}-${result.sha256}.tar.gz`;
841 yield* Effect.tryPromise(() =>
842 NodeFSP.rename(NodePath.join(directory, temporary), NodePath.join(directory, file)),
843 );
844 return { ...result, file };
845});
846
847const prepareForkComponents = Effect.fn("fork.release.prepareComponents")(function* (
848 directory: string,
849 resources: string,
850 platform: keyof typeof desktopTargets,
851 arch: string,
852 version: string,
853 serverRuntime: string,
854) {
855 const desktopRelease = yield* verifyDesktopRelease(directory, platform);
856 const components = NodePath.join(directory, "components");
857 const codeRoot = NodePath.join(components, "code");
858 yield* Effect.tryPromise(async () => {
859 await NodeFSP.mkdir(components);
860 extractAll(NodePath.join(resources, "app.asar"), codeRoot);
861 if (platform === "win32")
862 extractAll(NodePath.join(resources, "server.asar"), NodePath.join(codeRoot, "server"));
863 });
864 const runtime = yield* Effect.tryPromise(() =>
865 NodeFSP.readFile(NodePath.join(codeRoot, "knife-runtime.json"), "utf8"),
866 ).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ForkRuntimeManifest))));
867 if (runtime.version !== desktopRelease.manifest.version)
868 return yield* new ForkReleaseError({
869 message: "The packaged app version does not match its installer.",
870 });
871 const serverCodeRoot = platform === "win32" ? NodePath.join(codeRoot, "server") : codeRoot;
872 const archive = desktopRelease.files.find((name) =>
873 name.endsWith(installerSuffix(platform, arch)),
874 );
875 if (!archive)
876 return yield* new ForkReleaseError({
877 message: "The release has no installer for this architecture.",
878 });
879 const uiRoot = NodePath.join(components, "ui");
880 const assetsRoot = NodePath.join(components, "assets");
881 const serverRoot = NodePath.join(components, "server");
882 yield* Effect.tryPromise(async () => {
883 await NodeFSP.rename(NodePath.join(serverCodeRoot, "apps/server/dist/client"), uiRoot);
884 const splitAssets = async (relative: string): Promise<void> => {
885 const file = NodePath.join(uiRoot, relative);
886 if ((await NodeFSP.stat(file)).isDirectory()) {
887 for (const child of await NodeFSP.readdir(file))
888 await splitAssets(NodePath.join(relative, child));
889 } else if (relative !== ".vite/manifest.json" && !/\.(?:html|m?js|css)$/.test(relative)) {
890 const target = NodePath.join(assetsRoot, relative);
891 await NodeFSP.mkdir(NodePath.dirname(target), { recursive: true });
892 await NodeFSP.rename(file, target);
893 }
894 };
895 await splitAssets("");
896 await NodeFSP.mkdir(NodePath.join(serverRoot, "apps/server"), { recursive: true });
897 await NodeFSP.cp(
898 NodePath.join(serverCodeRoot, "apps/server/dist"),
899 NodePath.join(serverRoot, "apps/server/dist"),
900 { recursive: true },
901 );
902 for (const dist of [
903 NodePath.join(serverCodeRoot, "apps/server/dist"),
904 NodePath.join(codeRoot, "apps/desktop/dist-electron"),
905 NodePath.join(serverRoot, "apps/server/dist"),
906 ]) {
907 for (const name of await NodeFSP.readdir(dist)) {
908 if (name.endsWith(".map")) await NodeFSP.rm(NodePath.join(dist, name));
909 }
910 }
911 });
912 const code = yield* archiveComponent(directory, codeRoot, "code");
913 const app = { ...(yield* artifact(directory, archive)), version: runtime.version };
914 const ui = yield* archiveComponent(directory, uiRoot, "ui");
915 const assets = yield* archiveComponent(directory, assetsRoot, "assets");
916 const manifest = {
917 version,
918 ui,
919 assets,
920 server: {
921 ...(yield* archiveComponent(directory, serverRoot, "server")),
922 version: runtime.version,
923 runtime: serverRuntime,
924 },
925 desktop: {
926 [`${platform}-${arch}`]: {
927 version: runtime.version,
928 ui,
929 assets,
930 shell: runtime.shell,
931 runtime: runtime.sha256,
932 code,
933 app,
934 },
935 },
936 };
937 const encoded = yield* encodeRelease(manifest);
938 yield* Effect.tryPromise(() =>
939 NodeFSP.writeFile(NodePath.join(directory, "release.json"), encoded + "\n"),
940 );
941 yield* Effect.tryPromise(() => NodeFSP.rm(components, { recursive: true }));
942 yield* Effect.log(
943 `Built ${version}: UI ${manifest.ui.size} bytes, assets ${manifest.assets.size} bytes, server ${manifest.server.size} bytes.`,
944 );
945 return directory;
946});
947
948export function resolveForkMacSigningType(
949 identity: string,
950 identities: string,
951 notarizationKeychainProfile: string | undefined,
952): "development" | "distribution" {
953 const certificate = identities
954 .split("\n")
955 .map((line) => /^\s*\d+\) ([A-Fa-f0-9]{40}) "([^"]+)"/.exec(line))
956 .find((match) => match?.[1]?.toUpperCase() === identity.toUpperCase())?.[2];
957 if (!certificate)
958 throw new ForkReleaseError({
959 message:
960 "The release signing identity is unavailable. Import its certificate and private key into the signing keychain.",
961 });
962 if (certificate.startsWith("Developer ID Application:")) {
963 if (!notarizationKeychainProfile)
964 throw new ForkReleaseError({
965 message:
966 "Developer ID releases need notarization. Set notarizationKeychainProfile in the private release config.",
967 });
968 return "distribution";
969 }
970 if (certificate.startsWith("Apple Development:") && !notarizationKeychainProfile)
971 return "development";
972 throw new ForkReleaseError({
973 message:
974 "Notarization requires a Developer ID Application certificate. Update signingIdentity in the private release config.",
975 });
976}
977
978const buildForkRelease = Effect.fn("fork.release.build")(function* (config: ReleaseConfig) {
979 const keychainPassword = yield* Effect.tryPromise(() =>
980 NodeFSP.readFile(`${config.signingKeychain}.password`, "utf8"),
981 );
982 yield* run("security", [
983 "unlock-keychain",
984 "-p",
985 keychainPassword.replace(/[\r\n]+$/, ""),
986 config.signingKeychain,
987 ]);
988 const identities = yield* run(
989 "security",
990 ["find-identity", "-v", "-p", "codesigning", config.signingKeychain],
991 { capture: true },
992 );
993 const signingType = yield* Effect.try({
994 try: () =>
995 resolveForkMacSigningType(
996 config.signingIdentity,
997 identities,
998 config.notarizationKeychainProfile,
999 ),
1000 catch: (cause) =>
1001 cause instanceof ForkReleaseError
1002 ? cause
1003 : new ForkReleaseError({ message: "Could not verify the release signing identity." }),
1004 });
1005 if (config.notarizationKeychainProfile) {
1006 yield* run(
1007 "xcrun",
1008 [
1009 "notarytool",
1010 "history",
1011 "--keychain-profile",
1012 config.notarizationKeychainProfile,
1013 "--keychain",
1014 config.signingKeychain,
1015 ],
1016 { capture: true },
1017 );
1018 } else {
1019 yield* Effect.logWarning(
1020 "Apple Development signing does not clear Gatekeeper. Configure Developer ID Application signing and notarization for direct downloads.",
1021 );
1022 }
1023 const version = `0.0.${Math.floor((yield* Clock.currentTimeMillis) / 1000)}`;
1024 const directory = NodePath.join(repoRoot, "release", "fork", version);
1025 yield* Effect.tryPromise(() => NodeFSP.mkdir(NodePath.dirname(directory), { recursive: true }));
1026 yield* Effect.tryPromise(() => NodeFSP.mkdir(directory));
1027 const commit = (yield* run("jj", ["log", "-r", "@", "--no-graph", "-T", "commit_id.short(12)"], {
1028 capture: true,
1029 })).trim();
1030 if (!/^[a-f0-9]{12}$/.test(commit))
1031 return yield* new ForkReleaseError({ message: "Could not read the current Jujutsu revision." });
1032 const env = { ...loadRepoEnv(), ...process.env };
1033 env.T3CODE_COMMIT_HASH = commit;
1034 env.APP_VERSION = version;
1035 env.T3CODE_DESKTOP_UPDATE_URL = config.feedUrl;
1036 env.T3CODE_DESKTOP_MAC_SIGN_IDENTITY = config.signingIdentity;
1037 env.T3CODE_DESKTOP_MAC_SIGN_TYPE = signingType;
1038 env.CSC_KEYCHAIN = config.signingKeychain;
1039 if (config.notarizationKeychainProfile) {
1040 env.APPLE_KEYCHAIN_PROFILE = config.notarizationKeychainProfile;
1041 env.APPLE_KEYCHAIN = config.signingKeychain;
1042 delete env.APPLE_ID;
1043 delete env.APPLE_APP_SPECIFIC_PASSWORD;
1044 delete env.APPLE_TEAM_ID;
1045 }
1046 env.T3CODE_DESKTOP_SIGNED = "false";
1047 env.T3CODE_DESKTOP_SKIP_BUILD = "false";
1048 delete env.T3CODE_DISABLE_AUTO_UPDATE;
1049 delete env.T3CODE_DESKTOP_MOCK_UPDATES;
1050 yield* run(
1051 process.execPath,
1052 [
1053 "scripts/build-desktop-artifact.ts",
1054 "--platform",
1055 "mac",
1056 "--target",
1057 "zip",
1058 "--arch",
1059 "arm64",
1060 "--build-version",
1061 version,
1062 "--output-dir",
1063 directory,
1064 "--verbose",
1065 ],
1066 { env },
1067 );
1068 const release = yield* verifyDesktopRelease(directory);
1069 const archive = release.files.find((name) => name.endsWith(".zip"))!;
1070 yield* run("ditto", ["-x", "-k", NodePath.join(directory, archive), directory]);
1071 const entries = yield* Effect.tryPromise(() => NodeFSP.readdir(directory));
1072 const appName = entries.find((name) => name.endsWith(".app"));
1073 if (!appName)
1074 return yield* new ForkReleaseError({ message: "The update ZIP did not contain an app." });
1075 yield* run("codesign", ["--verify", "--deep", "--strict", NodePath.join(directory, appName)]);
1076 if (signingType === "distribution") {
1077 yield* run("xcrun", ["stapler", "validate", NodePath.join(directory, appName)]);
1078 yield* run("spctl", ["--assess", "--type", "execute", NodePath.join(directory, appName)]);
1079 }
1080 yield* run(
1081 NodePath.join(directory, appName, "Contents", "MacOS", appName.slice(0, -4)),
1082 ["-p", "process.versions.node"],
1083 { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } },
1084 );
1085 return yield* prepareForkComponents(
1086 directory,
1087 NodePath.join(directory, appName, "Contents/Resources"),
1088 "darwin",
1089 "arm64",
1090 version,
1091 yield* Effect.tryPromise(() => fingerprintForkServerRuntime(repoRoot)),
1092 );
1093});
1094
1095export const ingestForkRelease = Effect.fn("fork.release.ingest")(function* (
1096 platformKey: string,
1097 source: string,
1098 directory: string,
1099 version: string,
1100) {
1101 const match = /^(linux|win32)-(x64|arm64)$/.exec(platformKey);
1102 if (!match)
1103 return yield* new ForkReleaseError({
1104 message: "Choose linux-x64, linux-arm64, win32-x64, or win32-arm64.",
1105 });
1106 const platform = match[1] === "linux" ? "linux" : "win32";
1107 const arch = match[2]!;
1108 const verified = yield* verifyDesktopRelease(source, platform);
1109 const installer = verified.manifest.files.find((file) =>
1110 file.url.endsWith(installerSuffix(platform, arch)),
1111 );
1112 if (!installer)
1113 return yield* new ForkReleaseError({
1114 message: "The release has no installer for this architecture.",
1115 });
1116 const archive = NodePath.join(source, `knife-resources-${platformKey}.tar.gz`);
1117 const entries = (yield* run("tar", ["-tzf", archive], { capture: true })).trim().split(/\r?\n/);
1118 if (
1119 entries.some((entry) => {
1120 const path = entry.replace(/^\.\//, "");
1121 return (
1122 path.startsWith("/") ||
1123 path.includes("\\") ||
1124 path.split("/").includes("..") ||
1125 !/^(?:app\.asar(?:\.unpacked(?:\/.*)?)?|server\.asar(?:\.unpacked(?:\/.*)?)?|knife-server-runtime)\/?$/.test(
1126 path,
1127 )
1128 );
1129 })
1130 )
1131 return yield* new ForkReleaseError({
1132 message: "The native resource archive contains an unexpected path.",
1133 });
1134 yield* Effect.tryPromise(() => NodeFSP.mkdir(directory, { recursive: true }));
1135 const resources = yield* Effect.tryPromise(() =>
1136 NodeFSP.mkdtemp(NodePath.join(directory, "native-resources-")),
1137 );
1138 yield* run("tar", ["-xzf", archive, "-C", resources]);
1139 const serverRuntime = (yield* Effect.tryPromise(() =>
1140 NodeFSP.readFile(NodePath.join(resources, "knife-server-runtime"), "utf8"),
1141 )).trim();
1142 yield* Effect.tryPromise(async () => {
1143 for (const name of verified.files.filter(
1144 (name) => name === installer.url || name === `${installer.url}.blockmap`,
1145 ))
1146 await NodeFSP.copyFile(NodePath.join(source, name), NodePath.join(directory, name));
1147 await NodeFSP.writeFile(
1148 NodePath.join(directory, desktopTargets[platform].manifest),
1149 serializeUpdateManifest(
1150 { ...verified.manifest, files: [installer] },
1151 { platformLabel: desktopTargets[platform].label },
1152 ),
1153 );
1154 });
1155 yield* prepareForkComponents(directory, resources, platform, arch, version, serverRuntime);
1156 yield* Effect.tryPromise(() => NodeFSP.rm(resources, { recursive: true }));
1157 return directory;
1158});
1159
1160if (import.meta.main) {
1161 Effect.gen(function* () {
1162 const mode = process.argv[2] ?? "release";
1163 if (!["build", "publish", "release", "ingest"].includes(mode))
1164 return yield* new ForkReleaseError({
1165 message:
1166 "Use build, publish <release-directory>, release, or ingest <platform-arch> <native-build-directory>.",
1167 });
1168 const config = yield* Effect.tryPromise(() =>
1169 NodeFSP.readFile(NodePath.join(NodeOS.homedir(), ".config/t3-fork-release.json"), "utf8"),
1170 ).pipe(
1171 Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ReleaseConfig))),
1172 Effect.mapError(
1173 () =>
1174 new ForkReleaseError({
1175 message:
1176 "Configure ~/.config/t3-fork-release.json with feedUrl, uploadUrl, signingIdentity, and signingKeychain.",
1177 }),
1178 ),
1179 );
1180 let directory: string | undefined;
1181 if (mode === "ingest") {
1182 const platform = process.argv[3];
1183 const source = process.argv[4];
1184 if (!platform || !source)
1185 return yield* new ForkReleaseError({
1186 message: "Pass the platform and native build directory to ingest.",
1187 });
1188 const version = `0.0.${Math.floor((yield* Clock.currentTimeMillis) / 1000)}`;
1189 directory = yield* ingestForkRelease(
1190 platform,
1191 NodePath.resolve(source),
1192 NodePath.join(repoRoot, "release/fork", version),
1193 version,
1194 );
1195 } else {
1196 directory = mode === "publish" ? process.argv[3] : yield* buildForkRelease(config);
1197 }
1198 if (!directory)
1199 return yield* new ForkReleaseError({ message: "Pass the release directory to publish." });
1200 if (mode !== "build") yield* publishForkRelease(NodePath.resolve(directory), config);
1201 }).pipe(
1202 Effect.scoped,
1203 Effect.catch((error) =>
1204 Effect.logError(error instanceof Error ? error.message : "Fork release failed.").pipe(
1205 Effect.andThen(
1206 Effect.sync(() => {
1207 process.exitCode = 1;
1208 }),
1209 ),
1210 ),
1211 ),
1212 Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)),
1213 NodeRuntime.runMain,
1214 );
1215}