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));`,
113113it("loads the emitted packaged boot entry and backend cache preload", async () => {
114114 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-boot-"));
115115 try {
116 const entries = ["src/boot.ts", "src/compileCache.ts"];
117 await NodeFSP.mkdir(NodePath.join(directory, "src"));
118 await Promise.all(
119 entries.map((entry) =>
120 NodeFSP.copyFile(new URL(`../${entry}`, import.meta.url), NodePath.join(directory, entry)),
121 ),
122 );
116 const entries = new Set(["src/boot.ts", "src/compileCache.ts"]);
117 const outputDirectory = NodePath.join(directory, "bundle/apps/desktop/dist-electron");
123118 assert.ok(Array.isArray(desktopConfig.pack));
124119 for (const packConfig of desktopConfig.pack) {
125120 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;
127122 await build({
128123 ...packConfig,
129124 config: false,
130 cwd: directory,
125 cwd: NodePath.resolve(import.meta.dirname, ".."),
126 outDir: outputDirectory,
131127 tsconfig: false,
132128 sourcemap: false,
133129 onSuccess: undefined,
134130 logLevel: "silent",
135131 });
136132 }
137 const outputDirectory = NodePath.join(directory, "dist-electron");
138133 const fixture = `console.log(require('node:module').getCompileCacheDir() ? 'cached' : 'uncached');`;
139134 await NodeFSP.writeFile(NodePath.join(outputDirectory, "main.cjs"), fixture);
140135 await NodeFSP.writeFile(
......@@ -167,6 +162,59 @@ it("loads the emitted packaged boot entry and backend cache preload", async () =
167162 assert.equal(child.stdout.trim(), disabled ? "uncached" : "cached");
168163 }
169164 }
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");
170218 } finally {
171219 await NodeFSP.rm(directory, { recursive: true, force: true });
172220 }
apps/desktop/src/app/DesktopApp.ts+3-3
......@@ -32,6 +32,7 @@ import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
3232import * as DesktopState from "./DesktopState.ts";
3333import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts";
3434import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
35import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts";
3536import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts";
3637import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";
3738import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
......@@ -171,14 +172,13 @@ const bootstrap = Effect.gen(function* () {
171172 yield* logBootstrapInfo("bootstrap start");
172173
173174 const settings = yield* desktopSettings.get;
174 // The renderer is served from the bundled client (or Vite in development)
175 // rather than through the local backend, so the window can open without one.
175 // The renderer loads independently of the chat server.
176176 const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
177177 yield* electronProtocol.registerDesktopProtocol({
178178 scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment),
179179 ...(environment.isDevelopment
180180 ? { targetOrigin: Option.getOrThrow(environment.devServerUrl) }
181 : { assetDirectory: environment.clientAssetsDir }),
181 : { assetDirectories: (yield* DesktopLocalUi.DesktopLocalUi).assetDirectories }),
182182 clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname,
183183 });
184184 yield* installDesktopIpcHandlers();
apps/desktop/src/app/DesktopEnvironment.test.ts+26
......@@ -1,6 +1,7 @@
11import * as NodePath from "@effect/platform-node/NodePath";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import { assert, describe, it } from "@effect/vitest";
4import { vi } from "vite-plus/test";
45import * as Effect from "effect/Effect";
56import * as Layer from "effect/Layer";
67import * as Option from "effect/Option";
......@@ -141,6 +142,31 @@ describe("DesktopEnvironment", () => {
141142 }),
142143 );
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
144170 it.effect("uses the stable desktop entry as the packaged Linux portal identity", () =>
145171 Effect.gen(function* () {
146172 const environment = yield* makeEnvironment({
apps/desktop/src/app/DesktopEnvironment.ts+6-7
......@@ -11,6 +11,8 @@ import * as Layer from "effect/Layer";
1111import * as Option from "effect/Option";
1212import * as Path from "effect/Path";
1313
14import { activeForkRuntime } from "../updates/forkRuntime.ts";
15
1416import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
1517import * as DesktopConfig from "./DesktopConfig.ts";
1618import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts";
......@@ -54,12 +56,7 @@ export class DesktopEnvironment extends Context.Service<
5456 readonly browserArtifactsDir: string;
5557 readonly rootDir: string;
5658 readonly appRoot: string;
57 // Root of the tree containing apps/server/dist and node_modules for the
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).
59 // Windows keeps separate desktop and server dependency trees.
6360 readonly serverRoot: string;
6461 readonly backendEntryPath: string;
6562 // Built web client the packaged renderer is served from over t3code://app.
......@@ -173,7 +170,9 @@ const make = Effect.fn("desktop.environment.make")(function* (
173170 const appRoot = input.isPackaged ? input.appPath : rootDir;
174171 const serverRoot =
175172 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")
177176 : appRoot;
178177 const branding = resolveDesktopAppBranding({
179178 isDevelopment,
apps/desktop/src/app/DesktopPreReadyPlatform.test.ts+1-1
......@@ -175,7 +175,7 @@ describe("DesktopPreReadyPlatform", () => {
175175 const identity = yield* Effect.promise(() => portalIdentity);
176176 assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop");
177177 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");
179179 assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;");
180180 assert.include(
181181 identity.desktopEntry ?? "",
apps/desktop/src/app/DesktopPreReadyPlatform.ts+3-2
......@@ -1,3 +1,4 @@
1import { activeForkRuntime } from "../updates/forkRuntime.ts";
12// @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.
23import * as NodeChildProcess from "node:child_process";
34import * as NodeFS from "node:fs";
......@@ -84,7 +85,7 @@ export const make = Effect.gen(function* () {
8485 NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true });
8586 NodeFS.copyFileSync(
8687 NodePath.posix.join(
87 Electron.app.getAppPath(),
88 activeForkRuntime()?.directory ?? Electron.app.getAppPath(),
8889 "apps/desktop/prod-resources/icon.png",
8990 ),
9091 iconPath,
......@@ -98,7 +99,7 @@ export const make = Effect.gen(function* () {
9899 renderUrlHandlerDesktopEntry({
99100 displayName: resolveDesktopAppBranding({
100101 isDevelopment: linux.isDevelopment,
101 appVersion: Electron.app.getVersion(),
102 appVersion: activeForkRuntime()?.version ?? Electron.app.getVersion(),
102103 }).displayName,
103104 execTarget: process.env.APPIMAGE?.trim() || process.execPath,
104105 scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment),
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+2
......@@ -246,6 +246,7 @@ describe("DesktopBackendConfiguration", () => {
246246 assert.equal(first.cwd, environment.backendCwd);
247247 assert.equal(first.captureOutput, true);
248248 assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1");
249 assert.equal(first.env.T3CODE_VERSION_OVERRIDE, environment.appVersion);
249250 assert.isUndefined(first.env.T3CODE_PORT);
250251 assert.isUndefined(first.env.T3CODE_MODE);
251252 assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST);
......@@ -964,6 +965,7 @@ describe("DesktopBackendConfiguration", () => {
964965 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
965966 const config = yield* configuration.resolvePrimary;
966967 assert.equal(config.captureOutput, true);
968 assert.isUndefined(config.env.T3CODE_VERSION_OVERRIDE);
967969 // Dev never shares the prod compile cache.
968970 assert.notInclude(config.args, "--require");
969971 }).pipe(
apps/desktop/src/backend/DesktopBackendConfiguration.ts+1
......@@ -603,6 +603,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
603603 env: {
604604 ...backendChildEnvPatch(),
605605 ELECTRON_RUN_AS_NODE: "1",
606 T3CODE_VERSION_OVERRIDE: environment.isPackaged ? environment.appVersion : undefined,
606607 // The server names this launcher in commands it asks a person to run.
607608 T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath),
608609 },
apps/desktop/src/boot.ts+34-4
......@@ -1,4 +1,34 @@
1// Packaged app entry. Enables the compile cache before the main bundle loads,
2// so the cache also covers main.cjs itself.
3require("./compileCache.cjs");
4require("./main.cjs");
1// @effect-diagnostics nodeBuiltinImport:off - The synchronous signed bootstrap runs before Effect services exist.
2import * as NodeOS from "node:os";
3import * as NodePath from "node:path";
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";
8484
8585describe("ElectronApp", () => {
8686 beforeEach(() => {
87 vi.unstubAllEnvs();
8788 appendSwitchMock.mockClear();
8889 autoUpdaterOnMock.mockClear();
8990 autoUpdaterRemoveListenerMock.mockClear();
......@@ -111,6 +112,25 @@ describe("ElectronApp", () => {
111112 }).pipe(Effect.provide(ElectronApp.layer)),
112113 );
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
114134 it.effect("reads the OS locale through the service", () =>
115135 Effect.gen(function* () {
116136 const electronApp = yield* ElectronApp.ElectronApp;
apps/desktop/src/electron/ElectronApp.ts+3-2
......@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";
55import * as Scope from "effect/Scope";
66
77import * as Electron from "electron";
8import { activeForkRuntime } from "../updates/forkRuntime.ts";
89
910export interface ElectronAppMetadata {
1011 readonly appVersion: string;
......@@ -101,7 +102,7 @@ const addScopedAppListener = <Args extends ReadonlyArray<unknown>>(
101102export const make = ElectronApp.of({
102103 metadata: Effect.gen(function* () {
103104 const appVersion = yield* Effect.try({
104 try: () => Electron.app.getVersion(),
105 try: () => activeForkRuntime()?.version ?? Electron.app.getVersion(),
105106 catch: (cause) =>
106107 new ElectronAppMetadataReadError({
107108 property: "app-version",
......@@ -109,7 +110,7 @@ export const make = ElectronApp.of({
109110 }),
110111 });
111112 const appPath = yield* Effect.try({
112 try: () => Electron.app.getAppPath(),
113 try: () => activeForkRuntime()?.directory ?? Electron.app.getAppPath(),
113114 catch: (cause) =>
114115 new ElectronAppMetadataReadError({
115116 property: "app-path",
apps/desktop/src/electron/ElectronProtocol.test.ts+1-1
......@@ -43,7 +43,7 @@ describe("ElectronProtocol", () => {
4343 const protocol = yield* ElectronProtocol.ElectronProtocol;
4444 yield* protocol.registerDesktopProtocol({
4545 scheme: "t3code",
46 assetDirectory: directory,
46 assetDirectories: Effect.succeed([directory]),
4747 clerkFrontendApiHostname: undefined,
4848 });
4949 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
5353}
5454
5555// The scheme either proxies to a dev server (`targetOrigin`) or serves the
56// built client from disk (`assetDirectory`).
56// built client from disk (`assetDirectories`).
5757export type DesktopProtocolRegistrationInput = {
5858 readonly scheme: string;
5959 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
6265export class ElectronProtocol extends Context.Service<
6366 ElectronProtocol,
......@@ -213,12 +216,9 @@ const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* (
213216 return withContentSecurityPolicy(response, contentSecurityPolicy);
214217});
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.
219219const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (
220220 request: Request,
221 assetDirectory: string,
221 assetDirectories: readonly [string, ...string[]],
222222) {
223223 const fileSystem = yield* FileSystem.FileSystem;
224224 const path = yield* Path.Path;
......@@ -231,13 +231,21 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (
231231 Effect.orElseSucceed(() => null),
232232 );
233233 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]);
235235 const assetPath = path.resolve(root, `.${pathname}`);
236236 if (assetPath !== root && !assetPath.startsWith(root + path.sep)) {
237237 return new Response(null, { status: 404 });
238238 }
239 const stat = yield* fileSystem.stat(assetPath).pipe(Effect.orElseSucceed(() => null));
240239 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 }
241249 if (stat?.type !== "File") {
242250 const wantsHtml = request.headers.get("accept")?.includes("text/html") ?? false;
243251 if (path.extname(assetPath) !== "" && !wantsHtml) {
......@@ -250,6 +258,7 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (
250258 return new Response(request.method === "HEAD" ? null : new Uint8Array(contents), {
251259 headers: {
252260 "content-type": Option.getOrElse(Mime.getType(filePath), () => "application/octet-stream"),
261 ...(path.basename(filePath) === "index.html" ? { "cache-control": "no-store" } : {}),
253262 },
254263 });
255264});
......@@ -270,9 +279,13 @@ export const make = Effect.gen(function* () {
270279 Effect.try({
271280 try: () => {
272281 Electron.protocol.handle(input.scheme, async (request) => {
273 if ("assetDirectory" in input) {
282 if ("assetDirectories" in input) {
274283 return withContentSecurityPolicy(
275 await runPromise(serveDesktopAsset(request, input.assetDirectory)),
284 await runPromise(
285 Effect.flatMap(input.assetDirectories, (directories) =>
286 serveDesktopAsset(request, directories),
287 ),
288 ),
276289 contentSecurityPolicy,
277290 );
278291 }
apps/desktop/src/electron/ElectronUpdater.ts+5-2
......@@ -68,7 +68,10 @@ export class ElectronUpdater extends Context.Service<
6868 readonly setFullChangelog: (value: boolean) => Effect.Effect<void>;
6969 readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect<void>;
7070 readonly checkForUpdates: Effect.Effect<void, ElectronUpdaterCheckForUpdatesError>;
71 readonly downloadUpdate: Effect.Effect<void, ElectronUpdaterDownloadUpdateError>;
71 readonly downloadUpdate: Effect.Effect<
72 ReadonlyArray<string>,
73 ElectronUpdaterDownloadUpdateError
74 >;
7275 readonly quitAndInstall: (options: {
7376 readonly isSilent: boolean;
7477 readonly isForceRunAfter: boolean;
......@@ -135,7 +138,7 @@ export const make = ElectronUpdater.of({
135138 return Effect.tryPromise({
136139 try: () => autoUpdater.downloadUpdate(),
137140 catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }),
138 }).pipe(Effect.asVoid);
141 });
139142 }),
140143 quitAndInstall: ({ isSilent, isForceRunAfter }) =>
141144 Effect.suspend(() => {
apps/desktop/src/main.ts+9-1
......@@ -68,6 +68,9 @@ import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts";
6868import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
6969import * as DesktopRendererHistory from "./telemetry/DesktopRendererHistory.ts";
7070import * 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";
7174import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
7275import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";
7376import * as BrowserSession from "./preview/BrowserSession.ts";
......@@ -81,7 +84,10 @@ import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts";
8184
8285if (process.argv.includes("--version")) {
8386 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 );
8591 } catch (error) {
8692 if (!(error instanceof Error) || !("code" in error) || error.code !== "EPIPE") throw error;
8793 }
......@@ -228,6 +234,8 @@ const layerDesktopApplication = Layer.mergeAll(
228234).pipe(
229235 Layer.provideMerge(layerDesktopSnapShot),
230236 Layer.provideMerge(DesktopUpdates.layer),
237 Layer.provideMerge(DesktopForkUpdates.layer),
238 Layer.provideMerge(DesktopLocalUi.layer),
231239 Layer.provideMerge(layerDesktopWslBackend),
232240 Layer.provideMerge(layerDesktopLocalEnvironmentAuth),
233241);
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
8484}
8585
8686describe("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
87104 it.effect("drives check, download, and install with no local confirmation", () => {
88105 const harness = makeHarness();
89106
apps/desktop/src/updates/DesktopRemoteUpdates.ts+4
......@@ -228,6 +228,10 @@ export const listen: Effect.Effect<
228228 );
229229 return false;
230230 case "install": {
231 if (state.installKind === "ui") {
232 yield* publishReport(state, { outcome: "up-to-date" }, request.requestId);
233 return true;
234 }
231235 // The download event fires before its action releases the
232236 // updater reservation. Wait until the prepared install can
233237 // 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";
1818import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1919import * as DesktopState from "../app/DesktopState.ts";
2020import * as DesktopUpdates from "./DesktopUpdates.ts";
21import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts";
21import { flushCallbacks, makeHarness, forkReleaseFixture } from "./updatesTestHarness.ts";
2222
2323describe("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
24180 it("preserves complete causes for update poller and event failures", () => {
25181 const cause = Cause.combine(
26182 Cause.fail(new Error("updater failed")),
apps/desktop/src/updates/DesktopUpdates.ts+167-12
......@@ -7,7 +7,9 @@ import {
77 type DesktopUpdateCheckResult,
88 type DesktopUpdateState,
99} from "@t3tools/contracts";
10import { compareSemverVersions } from "@t3tools/shared/semver";
1011import { errorTag } from "@t3tools/shared/observability";
12import { ForkReleaseManifest } from "@t3tools/shared/forkUpdateDirectory";
1113import * as Cause from "effect/Cause";
1214import * as Context from "effect/Context";
1315import * as DateTime from "effect/DateTime";
......@@ -30,6 +32,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
3032import * as DesktopObservability from "../app/DesktopObservability.ts";
3133import * as DesktopState from "../app/DesktopState.ts";
3234import * 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";
3338import * as ElectronWindow from "../electron/ElectronWindow.ts";
3439import * as IpcChannels from "../ipc/channels.ts";
3540import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
......@@ -350,20 +355,36 @@ export const make = Effect.gen(function* () {
350355 const environment = yield* DesktopEnvironment.DesktopEnvironment;
351356 const fileSystem = yield* FileSystem.FileSystem;
352357 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
354366 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;
355375 const activeUpdateActionRef = yield* Ref.make<Option.Option<UpdateAction>>(Option.none());
356376 const finishedUpdateActions = yield* PubSub.unbounded<UpdateAction>();
357377 const updaterConfiguredRef = yield* Ref.make(false);
358378 const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);
359379 const notAppliedVersionRef = yield* Ref.make(Option.none<string>());
360 const updateStateRef = yield* Ref.make<DesktopUpdateState>(
361 createInitialDesktopUpdateState(
380 const updateStateRef = yield* Ref.make<DesktopUpdateState>({
381 ...createInitialDesktopUpdateState(
362382 environment.appVersion,
363383 environment.runtimeInfo,
364384 environment.defaultDesktopSettings.updateChannel,
365385 ),
366 );
386 uiVersion: yield* localUi.currentVersion,
387 });
367388
368389 const stateChanges = yield* PubSub.sliding<DesktopUpdateState>(16);
369390 // Makes ref writes + publishes atomic against subscribe, so a snapshot
......@@ -377,7 +398,11 @@ export const make = Effect.gen(function* () {
377398 const setState = (state: DesktopUpdateState): Effect.Effect<void> =>
378399 stateMutex
379400 .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 }),
381406 )
382407 .pipe(Effect.andThen(emitState));
383408
......@@ -498,9 +523,50 @@ export const make = Effect.gen(function* () {
498523 yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt));
499524 yield* logUpdaterInfo("checking for updates", { reason });
500525
501 return yield* electronUpdater.checkForUpdates.pipe(
502 Effect.as(true),
526 return yield* Effect.gen(function* () {
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 ),
503565 Effect.catchTags({
566 DesktopForkUpdateError: (error) =>
567 updateState((current) =>
568 reduceDesktopUpdateStateOnCheckFailure(current, error.message, checkedAt),
569 ).pipe(Effect.as(true)),
504570 ElectronUpdaterCheckForUpdatesError: Effect.fn(
505571 "desktop.updates.handleCheckForUpdatesFailure",
506572 )(function* (error) {
......@@ -543,10 +609,27 @@ export const make = Effect.gen(function* () {
543609 isArm64HostRunningIntelBuild(environment.runtimeInfo),
544610 );
545611 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 }));
547626 return { accepted: true, completed: true };
548627 }).pipe(
549628 Effect.catchTags({
629 DesktopForkUpdateError: (error) =>
630 updateState((current) =>
631 reduceDesktopUpdateStateOnDownloadFailure(current, error.message),
632 ).pipe(Effect.as({ accepted: true, completed: false })),
550633 ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")(
551634 function* (error) {
552635 yield* updateState((current) =>
......@@ -744,6 +827,37 @@ export const make = Effect.gen(function* () {
744827 return { accepted: false, completed: false, failed: false };
745828 }
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
747861 yield* Ref.set(desktopState.quitting, true);
748862
749863 return yield* Effect.gen(function* () {
......@@ -763,13 +877,26 @@ export const make = Effect.gen(function* () {
763877 (instance) => instance.stop({ timeout: Duration.seconds(5) }),
764878 { concurrency: "unbounded" },
765879 );
766 yield* electronUpdater.quitAndInstall({
767 isSilent: true,
768 isForceRunAfter: true,
769 });
880 const candidate = yield* Ref.get(forkRelease);
881 if (candidate?.kind === "code" && downloadedVersion !== null) {
882 if (!(yield* fork.activateCode(downloadedVersion))) {
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 }
770893 return { accepted: true, completed: false, failed: false };
771894 }).pipe(
772895 Effect.catchTags({
896 DesktopForkUpdateError: (error) =>
897 recoverFailedInstall(error.message).pipe(
898 Effect.as({ accepted: true, completed: false, failed: true }),
899 ),
773900 ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")(
774901 function* (error) {
775902 yield* recoverFailedInstall(error.message);
......@@ -827,6 +954,11 @@ export const make = Effect.gen(function* () {
827954 const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {
828955 yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe(
829956 Effect.andThen(checkForUpdates("startup")),
957 Effect.andThen(
958 Effect.gen(function* () {
959 if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate;
960 }),
961 ),
830962 Effect.catchCause((cause) => {
831963 if (Cause.hasInterruptsOnly(cause)) {
832964 return Effect.void;
......@@ -842,6 +974,11 @@ export const make = Effect.gen(function* () {
842974 );
843975 yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe(
844976 Effect.andThen(checkForUpdates("poll")),
977 Effect.andThen(
978 Effect.gen(function* () {
979 if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate;
980 }),
981 ),
845982 Effect.forever,
846983 Effect.catchCause((cause) => {
847984 if (Cause.hasInterruptsOnly(cause)) {
......@@ -865,7 +1002,10 @@ export const make = Effect.gen(function* () {
8651002 Effect.flatMap(
8661003 Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) {
8671004 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 ) {
8691009 yield* logUpdaterInfo("ignoring update that does not match selected channel", {
8701010 version: info.version,
8711011 channel: state.channel,
......@@ -877,6 +1017,14 @@ export const make = Effect.gen(function* () {
8771017 }
8781018
8791019 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 }
8801028 const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes(
8811029 info.releaseNotes,
8821030 info.version,
......@@ -1002,6 +1150,12 @@ export const make = Effect.gen(function* () {
10021150 Effect.flatMap(
10031151 Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) {
10041152 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;
10051159 yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version));
10061160 yield* logUpdaterInfo("update downloaded", { version: info.version });
10071161 }),
......@@ -1157,6 +1311,7 @@ export const make = Effect.gen(function* () {
11571311 };
11581312 }
11591313 const checked = yield* checkForUpdates(reason);
1314 if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate;
11601315 return {
11611316 checked,
11621317 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(
9999 ...state,
100100 status: isDownloadedVersion ? "downloaded" : "available",
101101 availableVersion: version,
102 installKind: isDownloadedVersion ? (state.installKind ?? "app") : "app",
102103 downloadedVersion: isDownloadedVersion ? version : null,
103104 releaseNotes: preserveReleaseNotes ? state.releaseNotes : releaseNotes,
104105 omittedReleaseCount: preserveReleaseNotes ? state.omittedReleaseCount : omittedReleaseCount,
apps/desktop/src/updates/updatesTestHarness.ts+105-2
......@@ -5,15 +5,20 @@ import * as FileSystem from "effect/FileSystem";
55import * as PlatformError from "effect/PlatformError";
66import * as Layer from "effect/Layer";
77import * as Option from "effect/Option";
8import { HttpClient, HttpClientResponse } from "effect/http";
89
910import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
1011import * as DesktopConfig from "../app/DesktopConfig.ts";
1112import * 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";
1216import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
1317import * as ElectronWindow from "../electron/ElectronWindow.ts";
1418import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
1519import * as DesktopState from "../app/DesktopState.ts";
1620import * as DesktopUpdates from "./DesktopUpdates.ts";
21import * as DesktopLocalUi from "./DesktopLocalUi.ts";
1722
1823/** Shared DesktopUpdates test harness: a fully stubbed updater layer whose
1924 electron-updater events are driven by hand via `emit`. Used by
......@@ -30,6 +35,8 @@ export interface UpdatesHarnessOptions {
3035 readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError;
3136 readonly setDisableDifferentialDownload?: Effect.Effect<void>;
3237 readonly downloadUpdate?: Effect.Effect<void>;
38 readonly uiOnly?: boolean;
39 readonly uiVersion?: string;
3340 readonly quitAndInstall?: Effect.Effect<void, ElectronUpdater.ElectronUpdaterQuitAndInstallError>;
3441 readonly stopBackend?: Effect.Effect<void>;
3542 readonly startBackend?: Effect.Effect<void>;
......@@ -39,6 +46,9 @@ export interface UpdatesHarnessOptions {
3946 readonly packageType?: string | undefined;
4047 /** Contents of the install record a previous run left behind. */
4148 readonly installRecord?: string | undefined;
49 readonly appUpdateYml?: string;
50 readonly releaseManifest?: () => typeof ForkReleaseManifest.Type;
51 readonly forkKind?: "ui" | "code" | "shell";
4252}
4353
4454export function makeHarness(options: UpdatesHarnessOptions = {}) {
......@@ -48,6 +58,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
4858 let allowDowngrade = false;
4959 let fullChangelog = false;
5060 const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = [];
61 const channels: string[] = [];
5162 const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>();
5263 const sentStates: DesktopUpdateState[] = [];
5364 const installSteps: string[] = [];
......@@ -76,7 +87,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
7687 }),
7788 setAutoDownload: () => Effect.void,
7889 setAutoInstallOnAppQuit: () => Effect.void,
79 setChannel: () => Effect.void,
90 setChannel: (channel) =>
91 Effect.sync(() => {
92 channels.push(channel);
93 }),
8094 setAllowPrerelease: () => Effect.void,
8195 allowDowngrade: Effect.sync(() => allowDowngrade),
8296 setAllowDowngrade: (value) =>
......@@ -93,7 +107,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
93107 }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)),
94108 downloadUpdate: Effect.sync(() => {
95109 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 ),
97114 quitAndInstall: () =>
98115 Effect.sync(() => {
99116 quitAndInstallCount += 1;
......@@ -224,6 +241,14 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
224241 ? (installRecords.get(path) ?? previousInstallRecord)
225242 : undefined;
226243 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 );
227252 return path === "/missing/resources/package-type" && options.packageType !== undefined
228253 ? Effect.succeed(options.packageType)
229254 : Effect.fail(
......@@ -249,7 +274,62 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
249274 }),
250275 });
251276
277 const uiInstalls: string[] = [];
278 let uiVersion = options.uiVersion ?? "1.2.3";
252279 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 ),
253333 Layer.provide(layerFileSystem),
254334 Layer.provideMerge(layerUpdater),
255335 Layer.provideMerge(layerWindow),
......@@ -273,12 +353,14 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
273353 checkCount: () => checkCount,
274354 quitAndInstalls: () => quitAndInstallCount,
275355 installSteps,
356 uiInstalls,
276357 updateRestartMarkers,
277358 installRecords,
278359 /** The record the test started with, until the app removes it. */
279360 previousInstallRecord: () => previousInstallRecord,
280361 downloadCount: () => downloadCount,
281362 feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls,
363 channels,
282364 fullChangelog: () => fullChangelog,
283365 listenerCount: () =>
284366 Array.from(listeners.values()).reduce(
......@@ -293,3 +375,24 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
293375 },
294376 };
295377}
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";
1313import * as DesktopConfig from "../app/DesktopConfig.ts";
1414import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1515import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
16import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts";
1617import * as DesktopWindow from "./DesktopWindow.ts";
1718
1819const environmentInput = {
......@@ -106,6 +107,12 @@ const configureMenu = (
106107 selectedAction: Deferred.Deferred<string>,
107108 applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>,
108109 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 },
109116) =>
110117 Effect.gen(function* () {
111118 const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
......@@ -116,6 +123,7 @@ const configureMenu = (
116123 Layer.provideMerge(layerElectronMenu(applicationMenuTemplate)),
117124 Layer.provideMerge(layerDesktopWindow(selectedAction)),
118125 Layer.provideMerge(layerDesktopUpdates),
126 Layer.provideMerge(Layer.succeed(DesktopLocalUi.DesktopLocalUi, localUi)),
119127 Layer.provideMerge(layerElectronDialog),
120128 Layer.provideMerge(layerElectronApp),
121129 Layer.provideMerge(
......@@ -128,6 +136,37 @@ const configureMenu = (
128136 );
129137
130138describe("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 );
131170 it.effect("keeps display branding in the macOS application menu", () =>
132171 Effect.gen(function* () {
133172 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";
1111import * as ElectronMenu from "../electron/ElectronMenu.ts";
1212import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1313import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
14import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts";
1415import * as DesktopWindow from "./DesktopWindow.ts";
1516
1617export class DesktopApplicationMenuActionError extends Schema.TaggedError<DesktopApplicationMenuActionError>()(
......@@ -34,6 +35,7 @@ export class DesktopApplicationMenu extends Context.Service<
3435
3536type DesktopApplicationMenuRuntimeServices =
3637 | DesktopUpdates.DesktopUpdates
38 | DesktopLocalUi.DesktopLocalUi
3739 | DesktopWindow.DesktopWindow
3840 | ElectronDialog.ElectronDialog;
3941
......@@ -74,7 +76,7 @@ const checkForUpdatesFromMenu = Effect.gen(function* () {
7476 yield* electronDialog.showMessageBox({
7577 type: "info",
7678 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.`,
7880 buttons: ["OK"],
7981 });
8082 } else if (updateState.status === "error") {
......@@ -160,6 +162,29 @@ export const make = Effect.gen(function* () {
160162 const mainContentsClick = (command: DesktopWindow.MainWindowContentsCommand) => () => {
161163 runMenuEffect(command, runMainContentsCommand(command));
162164 };
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 };
163188 const template: Electron.MenuItemConstructorOptions[] = [];
164189
165190 if (environment.platform === "darwin") {
......@@ -244,6 +269,11 @@ export const make = Effect.gen(function* () {
244269 the main window (see DesktopWindow.zoomMain).
245270 */
246271 { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") },
272 {
273 label: "Use Bundled UI",
274 enabled: !environment.isDevelopment,
275 click: localUiClick(),
276 },
247277 {
248278 label: "Force Reload",
249279 accelerator: "Shift+CmdOrCtrl+R",
apps/desktop/vite.config.ts+3-1
......@@ -95,7 +95,9 @@ export default defineConfig({
9595 entry: ["src/boot.ts", "src/compileCache.ts"],
9696 clean: false,
9797 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",
99101 },
100102 },
101103 {
apps/server/src/binCli.ts+2-2
......@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "./version.ts";
12import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
23import * as NodeServices from "@effect/platform-node/NodeServices";
34import * as Effect from "effect/Effect";
......@@ -6,7 +7,6 @@ import { Argument, Command } from "effect/cli";
67import * as CliError from "effect/cli/CliError";
78
89import * as NetService from "@t3tools/shared/Net";
9import packageJson from "../package.json" with { type: "json" };
1010import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts";
1111import { authCommand } from "./cli/auth.ts";
1212import { appCommand } from "./cli/app.ts";
......@@ -92,7 +92,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
9292export const cli = makeCli();
9393
9494export function runCli() {
95 Command.run(cli, { version: packageJson.version }).pipe(
95 Command.run(cli, { version: SERVER_VERSION }).pipe(
9696 Effect.scoped,
9797 Effect.provide(layerCliRuntime),
9898 NodeRuntime.runMain,
apps/server/src/cli/invocation.ts+2-3
......@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
12import * as Effect from "effect/Effect";
23import * as FileSystem from "effect/FileSystem";
34import * as Option from "effect/Option";
......@@ -8,8 +9,6 @@ import type { ServerInstallation } from "@t3tools/contracts";
89import * as HostProcess from "@t3tools/shared/HostProcess";
910import { isCommandAvailable } from "@t3tools/shared/shell";
1011
11import packageJson from "../../package.json" with { type: "json" };
12
1312export type CliRunner = "npx" | "pnpm dlx" | "bunx";
1413
1514/**
......@@ -139,7 +138,7 @@ export const resolveCliCommand = (subcommand: string) =>
139138 formatCliCommand({
140139 subcommand,
141140 entryPath: processArguments[1] ?? "",
142 version: packageJson.version,
141 version: SERVER_VERSION,
143142 }),
144143 );
145144
apps/server/src/cli/service.ts+10-12
......@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
12import * as HostProcess from "@t3tools/shared/HostProcess";
23import * as Console from "effect/Console";
34import * as Effect from "effect/Effect";
......@@ -6,7 +7,6 @@ import * as Terminal from "effect/Terminal";
67import { Command, Flag, GlobalFlag, Prompt } from "effect/cli";
78import { FetchHttpClient } from "effect/http";
89
9import packageJson from "../../package.json" with { type: "json" };
1010import * as BootService from "../cloud/bootService.ts";
1111import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts";
1212import type * as ServerConfig from "../config.ts";
......@@ -17,7 +17,7 @@ export const layer = (config: ServerConfig.ServerConfig["Service"]) =>
1717 BootService.layer({
1818 baseDir: config.baseDir,
1919 logsDir: config.logsDir,
20 cliVersion: packageJson.version,
20 cliVersion: SERVER_VERSION,
2121 }).pipe(
2222 Layer.provide(ProcessRunner.layer),
2323 // Archive-distributed versions download the release archive here.
......@@ -48,11 +48,11 @@ export const reconcileService = Effect.fn("cli.service.reconcile")(function* (op
4848 if (
4949 status.installedVersion !== undefined &&
5050 options?.allowDowngrade !== true &&
51 compareExactServiceVersions(packageJson.version, status.installedVersion) < 0
51 compareExactServiceVersions(SERVER_VERSION, status.installedVersion) < 0
5252 ) {
5353 return yield* new BootService.BootServiceDowngradeRefusedError({
5454 installedVersion: status.installedVersion,
55 targetVersion: packageJson.version,
55 targetVersion: SERVER_VERSION,
5656 });
5757 }
5858 const plan = yield* service.install(options);
......@@ -126,13 +126,11 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip
126126 Effect.gen(function* () {
127127 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
128128 if (!result.changed) {
129 yield* Console.log(
130 `T3 Code service is already installed with t3@${packageJson.version}.`,
131 );
129 yield* Console.log(`T3 Code service is already installed with t3@${SERVER_VERSION}.`);
132130 return;
133131 }
134132 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}`,
136134 );
137135 }),
138136 ),
......@@ -153,11 +151,11 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(
153151 );
154152 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
155153 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}.`);
157155 return;
158156 }
159157 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}`,
161159 );
162160 }),
163161 ),
......@@ -208,7 +206,7 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe(
208206 flags,
209207 Effect.gen(function* () {
210208 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));
212210 }),
213211 ),
214212 ),
......@@ -231,7 +229,7 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () {
231229 if (
232230 installed &&
233231 status.installedVersion !== undefined &&
234 compareExactServiceVersions(status.installedVersion, packageJson.version) > 0
232 compareExactServiceVersions(status.installedVersion, SERVER_VERSION) > 0
235233 ) {
236234 yield* Console.log(
237235 `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`,
apps/server/src/cli/triage.ts+2-2
......@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
12/**
23 * `t3 triage` - hand a misbehaving install to the user's own coding agent.
34 *
......@@ -26,7 +27,6 @@ import * as Path from "effect/Path";
2627import * as Schema from "effect/Schema";
2728import { Command, Flag } from "effect/cli";
2829
29import packageJson from "../../package.json" with { type: "json" };
3030import * as BootService from "../cloud/bootService.ts";
3131import * as ServerConfig from "../config.ts";
3232import { resolveBaseDir } from "../os-jank.ts";
......@@ -195,7 +195,7 @@ export const triageCommand = Command.make("triage", {
195195 contextFilePath,
196196 buildTriageContext({
197197 generatedAt: DateTime.formatIso(now),
198 cliVersion: packageJson.version,
198 cliVersion: SERVER_VERSION,
199199 os: `${yield* HostProcess.Platform} ${yield* HostProcess.Architecture} (${NodeOS.release()})`,
200200 nodeVersion: process.version,
201201 launchedAs: yield* resolveCliCommand("triage"),
apps/server/src/cli/update.ts+2-2
......@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
12import * as HostProcess from "@t3tools/shared/HostProcess";
23import {
34 CLI_RELEASE_BASE_URL_ENV,
......@@ -18,7 +19,6 @@ import * as Schema from "effect/Schema";
1819import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/cli";
1920import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
2021
21import packageJson from "../../package.json" with { type: "json" };
2222import * as BootService from "../cloud/bootService.ts";
2323import {
2424 ensurePinnedRuntimeInstalled,
......@@ -339,7 +339,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
339339 const httpClient = yield* HttpClient.HttpClient;
340340 const service = yield* BootService.BootService;
341341
342 const currentVersion = packageJson.version;
342 const currentVersion = SERVER_VERSION;
343343 const channel = input.channel ?? cliReleaseChannelOf(currentVersion);
344344 if (input.requestedVersion !== undefined && !isExactServiceVersion(input.requestedVersion)) {
345345 return yield* new CliUpdateError({
apps/server/src/cloud/serviceLauncherClient.ts+2-2
......@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
12import type { ServerSelfUpdateOutcome } from "@t3tools/contracts";
23import * as HostProcess from "@t3tools/shared/HostProcess";
34import * as Context from "effect/Context";
......@@ -5,7 +6,6 @@ import * as Effect from "effect/Effect";
56import * as Layer from "effect/Layer";
67import * as Schema from "effect/Schema";
78
8import packageJson from "../../package.json" with { type: "json" };
99import {
1010 decodeServiceLauncherContext,
1111 decodeServiceLauncherParentMessage,
......@@ -115,7 +115,7 @@ const resolveStartup = Effect.fn("cloud.service_launcher_client.resolve_startup"
115115 function* (options?: { readonly currentVersion?: string }) {
116116 const host = yield* ServiceLauncherHostProcess;
117117 const environment = yield* HostProcess.Environment;
118 const currentVersion = options?.currentVersion ?? packageJson.version;
118 const currentVersion = options?.currentVersion ?? SERVER_VERSION;
119119 const rawContext = environment[SERVICE_LAUNCHER_CONTEXT_ENV];
120120 const context = rawContext === undefined ? undefined : decodeServiceLauncherContext(rawContext);
121121
apps/server/src/cloud/servicePreflight.ts+2-2
......@@ -1,4 +1,4 @@
1import packageJson from "../../package.json" with { type: "json" };
1import { SERVER_VERSION } from "../version.ts";
22import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
33
44export type ServicePreflightResult =
......@@ -19,7 +19,7 @@ export function runServicePreflight(input: {
1919 readonly launcherProtocol: number;
2020 readonly version?: string;
2121}): ServicePreflightResult {
22 const version = input.version ?? packageJson.version;
22 const version = input.version ?? SERVER_VERSION;
2323 if (input.launcherProtocol !== SERVICE_LAUNCHER_PROTOCOL) {
2424 return {
2525 status: "blocked",
apps/server/src/environment/ServerEnvironment.ts+2-2
......@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
12import {
23 EnvironmentId,
34 ORCHESTRATION_PROTOCOL_VERSION,
......@@ -13,7 +14,6 @@ import * as Layer from "effect/Layer";
1314import * as Path from "effect/Path";
1415import * as Schema from "effect/Schema";
1516
16import packageJson from "../../package.json" with { type: "json" };
1717import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
1818import { resolveServerInstallation } from "../cli/invocation.ts";
1919import { readAgentActivityPublishingActive } from "../cloud/config.ts";
......@@ -213,7 +213,7 @@ export const make = Effect.gen(function* () {
213213 arch: platformArch(hostArchitecture),
214214 ...(machine === null ? {} : { machine }),
215215 },
216 serverVersion: packageJson.version,
216 serverVersion: SERVER_VERSION,
217217 orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION,
218218 capabilities: {
219219 repositoryIdentity: true,
apps/server/src/mcp/McpHttpServer.ts+2-2
......@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
12import * as Cause from "effect/Cause";
23import * as Clock from "effect/Clock";
34import * as Context from "effect/Context";
......@@ -15,7 +16,6 @@ import { AiError, McpProtocol, McpSchema, McpServer, Tool, type Toolkit } from "
1516import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
1617import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts";
1718
18import packageJson from "../../package.json" with { type: "json" };
1919import * as ServerConfig from "../config.ts";
2020import * as DeviceService from "../device/DeviceService.ts";
2121import * as HtmlRender from "../htmlRender/HtmlRender.ts";
......@@ -860,7 +860,7 @@ export const layerDeviceToolkit = Layer.mergeAll(
860860
861861export const layerMcpTransport = McpServer.layerHttp({
862862 name: "T3 Code",
863 version: packageJson.version,
863 version: SERVER_VERSION,
864864 path: "/mcp",
865865 protocols: [McpProtocol.v2025_06_18],
866866 allowSessionTermination: true,
apps/server/src/provider/CodexProvider.ts+2-2
......@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
12import * as DateTime from "effect/DateTime";
23import * as Duration from "effect/Duration";
34import * as Effect from "effect/Effect";
......@@ -51,7 +52,6 @@ import {
5152 type CodexResetCreditsSummary,
5253} from "./codexUsageLimits.ts";
5354import * as HostProcess from "@t3tools/shared/HostProcess";
54import packageJson from "../../package.json" with { type: "json" };
5555const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);
5656const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000;
5757
......@@ -410,7 +410,7 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
410410 clientInfo: {
411411 name: CODEX_CLIENT_NAME,
412412 title: CODEX_CLIENT_NAME,
413 version: packageJson.version,
413 version: SERVER_VERSION,
414414 },
415415 capabilities: {
416416 experimentalApi: true,
apps/server/src/provider/providerCompatibility.ts+2-2
......@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
12import {
23 TrimmedNonEmptyString,
34 ServerProviderCompatibilityStatus,
......@@ -7,7 +8,6 @@ import {
78} from "@t3tools/contracts";
89import { satisfiesSemverRange } from "@t3tools/shared/semver";
910import * as Schema from "effect/Schema";
10import packageJson from "../../package.json" with { type: "json" };
1111
1212// Deliberately uses the shared CLI gate syntax: comparator groups joined by ||.
1313// Prereleases and unrecognized release tags remain unknown.
......@@ -60,7 +60,7 @@ export function resolveProviderCompatibility(
6060 policies: ReadonlyArray<ProviderCompatibilityPolicy> | undefined,
6161 driver: ProviderDriverKind,
6262 version: string | null,
63 t3CodeVersion = packageJson.version,
63 t3CodeVersion = SERVER_VERSION,
6464): ServerProviderCompatibilityAdvisory | undefined {
6565 const policy = policies?.find(
6666 (entry) => entry.driver === driver && satisfiesSemverRange(t3CodeVersion, entry.t3CodeRange),
apps/server/src/serverRuntimeState.ts+2-2
......@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "./version.ts";
12import * as DateTime from "effect/DateTime";
23import * as Effect from "effect/Effect";
34import * as FileSystem from "effect/FileSystem";
......@@ -5,7 +6,6 @@ import * as Option from "effect/Option";
56import * as Schema from "effect/Schema";
67
78import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
8import packageJson from "../package.json" with { type: "json" };
99import type * as ServerConfig from "./config.ts";
1010import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts";
1111
......@@ -76,7 +76,7 @@ export const makePersistedServerRuntimeState = (input: {
7676 ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}),
7777 startedAt: DateTime.formatIso(now),
7878 ...(input.serviceManaged ? { serviceManaged: true } : {}),
79 serverVersion: packageJson.version,
79 serverVersion: SERVER_VERSION,
8080 }));
8181
8282export const persistServerRuntimeState = (input: {
apps/server/src/telemetry/AnalyticsService.ts+3-5
......@@ -1,3 +1,4 @@
1import { SERVER_VERSION } from "../version.ts";
12/**
23 * Anonymous PostHog telemetry service.
34 *
......@@ -27,7 +28,6 @@ import * as HttpClient from "effect/http/HttpClient";
2728import * as HttpClientRequest from "effect/http/HttpClientRequest";
2829import * as HttpClientResponse from "effect/http/HttpClientResponse";
2930
30import packageJson from "../../package.json" with { type: "json" };
3131import * as ServerConfig from "../config.ts";
3232import { getTelemetryIdentifier } from "./Identify.ts";
3333
......@@ -188,12 +188,12 @@ export const make = Effect.gen(function* () {
188188 platform: hostPlatform,
189189 wsl: Option.getOrUndefined(telemetryConfig.wslDistroName),
190190 arch: hostArchitecture,
191 t3CodeVersion: packageJson.version,
191 t3CodeVersion: SERVER_VERSION,
192192 clientType,
193193 serverOs: serverOsFromNodePlatform(hostPlatform),
194194 serverArch: hostArchitecture,
195195 serverWslDistro: Option.getOrUndefined(telemetryConfig.wslDistroName),
196 serverAppVersion: packageJson.version,
196 serverAppVersion: SERVER_VERSION,
197197 serverMode: serverConfig.mode,
198198 },
199199 timestamp: event.capturedAt,
......@@ -286,5 +286,3 @@ export const make = Effect.gen(function* () {
286286});
287287
288288export 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 @@
11// @vitest-environment jsdom
22
3import { act, createRef, useLayoutEffect, useState } from "react";
3import { act, createRef, StrictMode, useEffect, useLayoutEffect, useState } from "react";
44import { createRoot, type Root } from "react-dom/client";
55import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
66
......@@ -17,6 +17,8 @@ import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
1717import { ComposerContextActionsContext } from "./composerContextPresentation";
1818
1919import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
20import { EditorView } from "@codemirror/view";
21import { PREPARE_UI_REFRESH_EVENT } from "~/uiRefresh";
2022
2123let root: Root;
2224let container: HTMLDivElement;
......@@ -27,16 +29,28 @@ const openMention = vi.fn();
2729const commandKey = vi.fn((_key: string, _event: KeyboardEvent) => false);
2830const 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}) {
3139 const [richText, setRichText] = useState(false);
3240 const [draft, setDraft] = useState({
33 value: "before :dizzy after",
34 cursor: "before :dizzy".length,
41 value: initiallyEmpty ? "" : "before :dizzy after",
42 cursor: initiallyEmpty ? 0 : "before :dizzy".length,
3543 });
3644 useLayoutEffect(() => {
3745 replace = (value, cursor) => setDraft({ value, cursor });
3846 enableRichText = setRichText;
3947 });
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]);
4054 return (
4155 <ComposerContextActionsContext
4256 value={{
......@@ -49,6 +63,7 @@ function ComposerProbe() {
4963 }}
5064 >
5165 <ComposerPromptEditor
66 persistenceKey="refresh-test"
5267 {...draft}
5368 richTextEnabled={richText}
5469 editorRef={handle}
......@@ -90,9 +105,46 @@ beforeEach(async () => {
90105afterEach(async () => {
91106 await act(() => root.unmount());
92107 container.remove();
108 sessionStorage.clear();
109 vi.restoreAllMocks();
93110 vi.unstubAllGlobals();
94111});
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
96148describe("emoji completion in the real composer editor", () => {
97149 it("keeps the caret and undo history when a controlled completion replaces the shortcode", async () => {
98150 await act(() => handle.current!.focus());
apps/web/src/components/ComposerPromptEditor.tsx+109-6
......@@ -10,14 +10,16 @@ import {
1010import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
1111import { isolateHistory } from "@codemirror/commands";
1212import { Transaction } from "@codemirror/state";
13import * as Schema from "effect/Schema";
1314import { createPortal } from "react-dom";
14import { use, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
15import { use, useCallback, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
1516
1617import { expandCollapsedComposerCursor } from "~/composer-logic";
1718import { collectInlineContextIds } from "~/lib/composerContextReferences";
1819import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
1920import { basenameOfPath } from "~/pierre-icons";
2021import { cn } from "~/lib/utils";
22import { PREPARE_UI_REFRESH_EVENT } from "~/uiRefresh";
2123import {
2224 ComposerContextActionsContext,
2325 ComposerContextRecordsContext,
......@@ -66,6 +68,7 @@ export interface ComposerPromptEditorHandle {
6668}
6769
6870export interface ComposerPromptEditorProps {
71 persistenceKey?: string | undefined;
6972 value: string;
7073 cursor: number;
7174 /**
......@@ -129,20 +132,45 @@ type CitationComment = {
129132 insertedSpaces?: CitationInsertedSpaces | undefined;
130133};
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
132148export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
133149 const host = useRef<HTMLDivElement>(null);
134150 const currentProps = useRef(props);
135151 const editor = useRef<ReturnType<typeof createComposerMarkdownEditor> | null>(null);
152 const restoringRefreshSelection = useRef(false);
136153 const pendingCitation = useRef<ComposerCitationCommentRequest | null>(null);
137154 const citationDraft = useRef<{ start: number; comment: string } | null>(null);
138155 const [comment, setComment] = useState<CitationComment | null>(null);
139156 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
141168 useLayoutEffect(() => {
142169 currentProps.current = props;
143170 });
144171 useLayoutEffect(() => {
145172 let alive = true;
173 restoringRefreshSelection.current = false;
146174 const instance = createComposerMarkdownEditor({
147175 props: () => currentProps.current,
148176 mountChip: (chip) => {
......@@ -154,12 +182,18 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
154182 });
155183 editor.current = instance;
156184 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);
157188 return () => {
158189 alive = false;
159190 editor.current = null;
191 instance.dom.removeEventListener("pointerdown", discardRefreshSelection, true);
192 instance.dom.removeEventListener("keydown", discardRefreshSelection, true);
193 instance.dom.removeEventListener("beforeinput", discardRefreshSelection, true);
160194 instance.destroy();
161195 };
162 }, []);
196 }, [discardRefreshSelection]);
163197
164198 // oxlint-disable react/exhaustive-effect-dependencies -- Editor callbacks read the ref; DOM configuration follows these props.
165199 useLayoutEffect(() => {
......@@ -180,6 +214,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
180214 if (!instance) return;
181215 const snapshot = instance.snapshot();
182216 if (snapshot.value !== props.value) {
217 if (restoringRefreshSelection.current) discardRefreshSelection();
183218 const draft = citationDraft.current;
184219 citationDraft.current = null;
185220 const saveDraft = currentProps.current.onCitationDraftSave;
......@@ -229,7 +264,11 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
229264 },
230265 annotations: [isolateHistory.of("full"), Transaction.userEvent.of("t3.controlled")],
231266 });
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 ) {
233272 instance.view.dispatch({
234273 selection: {
235274 anchor: expandCollapsedComposerCursor(props.value, props.cursor, props.literalText),
......@@ -251,7 +290,66 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
251290 insertedSpaces: request.insertedSpaces,
252291 });
253292 }
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
256354 useLayoutEffect(() => {
257355 editor.current?.view.requestMeasure();
......@@ -264,6 +362,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
264362 focusAt: (cursor) => {
265363 const instance = editor.current;
266364 if (!instance) return;
365 discardRefreshSelection();
267366 const snapshot = instance.snapshot();
268367 instance.view.dispatch({
269368 selection: {
......@@ -280,6 +379,10 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
280379 focusAtEnd: () => {
281380 const instance = editor.current;
282381 if (!instance) return;
382 if (restoringRefreshSelection.current) {
383 instance.focus();
384 return;
385 }
283386 instance.view.dispatch({
284387 selection: { anchor: instance.view.state.doc.length },
285388 scrollIntoView: true,
......@@ -337,7 +440,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
337440 );
338441 },
339442 }),
340 [props.editorRef, props.value, props.cursor, props.literalText],
443 [props.editorRef, props.value, props.cursor, props.literalText, discardRefreshSelection],
341444 );
342445
343446 const copy = (event: React.ClipboardEvent<HTMLElement>, cut: boolean) => {
......@@ -381,7 +484,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
381484 onKeyUp={(event) => props.onPageScrollKeyUp?.(event.key)}
382485 onBlur={props.onPageScrollRelease}
383486 >
384 <div ref={host} />
487 <div ref={host} data-ui-refresh-composer={props.persistenceKey} />
385488 {props.value.length === 0 && props.contextRecords.size === 0 && props.placeholder ? (
386489 <div
387490 className={cn(
apps/web/src/components/chat/ChatComposer.tsx+12
......@@ -1,6 +1,7 @@
11import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
22import { useComposerTypingGuard } from "./useComposerTypingGuard";
33import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";
4import { PREPARE_UI_REFRESH_EVENT } from "../../uiRefresh";
45import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig";
56import { isLocalEnvironmentDisabled } from "../../localEnvironment";
67import { usePrimaryEnvironmentId } from "../../state/environments";
......@@ -2568,6 +2569,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25682569 const onBlur = () => {
25692570 pasteAsTextShortcutUntilRef.current = 0;
25702571 };
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 };
25712580 const onDesktopPasteAsText = () => {
25722581 const activeElement = document.activeElement;
25732582 const blocksPasteToFocus =
......@@ -2585,10 +2594,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25852594 window.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText);
25862595 window.addEventListener("keydown", onKeyDown, true);
25872596 window.addEventListener("blur", onBlur);
2597 window.addEventListener(PREPARE_UI_REFRESH_EVENT, onPrepareUiRefresh);
25882598 return () => {
25892599 window.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText);
25902600 window.removeEventListener("keydown", onKeyDown, true);
25912601 window.removeEventListener("blur", onBlur);
2602 window.removeEventListener(PREPARE_UI_REFRESH_EVENT, onPrepareUiRefresh);
25922603 };
25932604 }, []);
25942605
......@@ -7691,6 +7702,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
76917702 ) : null}
76927703 <ComposerContextActionsContext value={composerContextActions}>
76937704 <ComposerPromptEditor
7705 persistenceKey={activePendingProgress ? undefined : composerDraftTargetKey}
76947706 key={`${composerSuggestionListId}:${activePendingProgress?.activeQuestion?.id ?? "thread"}`}
76957707 ariaLabel="Message"
76967708 suggestionListId={composerSuggestionListId}
apps/web/src/components/chat/MessagesTimeline.test.tsx+70-2
......@@ -896,6 +896,76 @@ describe("MessagesTimeline", () => {
896896 }
897897 });
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
899969 it("keeps a following thread's intent while its timeline is held for the next thread", async () => {
900970 const { readTimelinePosition } = await import("./timelineScrollAnchoring");
901971 const timeline = stubFollowTimeline("environment-local:held-departure");
......@@ -2647,8 +2717,6 @@ describe("MessagesTimeline", () => {
26472717 />,
26482718 );
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"');
26522720 expect(markup).toContain("Read a T3 thread");
26532721 expect(markup).not.toContain("mcp__t3-code__t3_thread_read");
26542722 });
apps/web/src/components/chat/MessagesTimeline.tsx+22-11
......@@ -1003,6 +1003,26 @@ const ConversationTimeline = memo(function ConversationTimeline({
10031003 const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1;
10041004 if (position?.atEnd === false) onManualNavigation();
10051005 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 }
10061026 const scrolling =
10071027 position?.atEnd === false
10081028 ? index >= 0
......@@ -1055,23 +1075,14 @@ const ConversationTimeline = memo(function ConversationTimeline({
10551075 };
10561076 settleFrame = requestAnimationFrame(reconcile);
10571077 });
1058 return () => {
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 };
1078 return cleanup;
10691079 }, [
10701080 citationRequest,
10711081 cancelPositionRestoreRef,
10721082 listIdentityKey,
10731083 listRef,
10741084 onManualNavigation,
1085 historyControls,
10751086 rememberedPosition,
10761087 restoringThreadPosition,
10771088 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 @@
1import type { MessageId, RunId } from "@t3tools/contracts";
1import { RunId, RunAttemptId, type MessageId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
23
34export interface TimelineRunObservation {
45 readonly threadKey: string | null;
......@@ -33,8 +34,6 @@ export function observeTimelineRun(
3334 }
3435 return { observation, anchorMessageId: input.messageId };
3536}
36import type { RunAttemptId } from "@t3tools/contracts";
37
3837// Match the titlebar fade inset so draft promotion preserves the first row's position.
3938export const CHAT_TIMELINE_ANCHOR_OFFSET = 24;
4039
......@@ -161,6 +160,91 @@ export interface RememberedTimelinePosition {
161160// Scoped thread keys keep separate environments independent. Bound the session cache.
162161const 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
164248export function readTimelinePosition(threadKey: string) {
165249 return rememberedTimelinePositions.get(threadKey);
166250}
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 {
99} from "./desktopUpdate.logic";
1010import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";
1111import type { ReportLocalUpdateError } from "./sidebar/useUpdateEverything";
12import { prepareUiRefresh } from "~/uiRefresh";
1213
1314export const toastUpdateError: ReportLocalUpdateError = (title, description) => {
1415 toastManager.add(stackedThreadToast({ type: "error", title, description }));
1516};
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. */
1819export async function downloadLocalUpdate(
19 bridge: DesktopBridge,
20 bridge: Pick<DesktopBridge, "downloadUpdate" | "openExternal">,
2021 {
2122 notifyDownloaded,
2223 reportError = toastUpdateError,
......@@ -24,9 +25,6 @@ export async function downloadLocalUpdate(
2425): Promise<boolean> {
2526 try {
2627 const result = await bridge.downloadUpdate();
27 if (result.completed && result.state.installKind === "ui") {
28 return await installLocalUpdate(bridge, reportError);
29 }
3028 if (result.completed && notifyDownloaded) {
3129 showDesktopUpdateDownloadedToast(bridge, result.state);
3230 }
......@@ -46,10 +44,12 @@ export async function downloadLocalUpdate(
4644
4745/** Applies a prepared UI in place or starts an app relaunch after the caller confirms. */
4846export async function installLocalUpdate(
49 bridge: DesktopBridge,
47 bridge: Pick<DesktopBridge, "installUpdate" | "openExternal">,
5048 reportError: ReportLocalUpdateError = toastUpdateError,
49 options: { readonly automatic?: boolean } = {},
5150): Promise<boolean> {
5251 try {
52 if (options.automatic && !prepareUiRefresh()) return false;
5353 const result = await bridge.installUpdate();
5454 if (!result.accepted) {
5555 showDesktopUpdateDownloadedToast(bridge, result.state);
......@@ -59,7 +59,10 @@ export async function installLocalUpdate(
5959 ? getDesktopUpdateActionError(result)
6060 : null;
6161 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 }
6366 return true;
6467 }
6568 reportError("Could not install update", actionError);
apps/web/src/composerDraftStore.ts+32
......@@ -130,6 +130,38 @@ const composerPersistStorage: PersistStorage<ComposerPersistState> = {
130130 removeItem: (name) => composerDebouncedStorage.removeItem(name),
131131};
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
133165// Flush pending composer draft writes before page unload to prevent data loss.
134166if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
135167 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
3232import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive";
3333import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
3434import { DesktopUpdateLaunchNotice } from "../components/sidebar/updateEverythingRun";
35import { AutomaticUiRefresh } from "../components/AutomaticUiRefresh";
3536import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";
3637import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast";
3738import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";
......@@ -261,6 +262,7 @@ function RootRouteView() {
261262 ) : null}
262263 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
263264 {isElectron ? <DesktopUpdateLaunchNotice /> : null}
265 {isElectron ? <AutomaticUiRefresh /> : null}
264266 {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */}
265267 <NightlyMobileBetaNotice />
266268 {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
214214build workspace before building with these fonts. The web and desktop builds
215215bundle the local files automatically; source checkouts without them use system
216216fallback 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 @@
3838 "fmt": "vp fmt",
3939 "fmt:check": "vp fmt --check",
4040 "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",
4144 "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg",
4245 "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64",
4346 "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 {
290290 * not install it again; the user can retry it from the update button.
291291 */
292292 notAppliedVersion?: string;
293 installKind?: "ui" | "app";
294 uiVersion?: string;
293295}
294296
295297export interface DesktopUpdateReleaseNote {
......@@ -322,6 +324,8 @@ export const DesktopUpdateStateSchema = Schema.Struct({
322324 errorContext: Schema.NullOr(Schema.Literals(["check", "download", "install"])),
323325 canRetry: Schema.Boolean,
324326 notAppliedVersion: Schema.optionalKey(Schema.String),
327 installKind: Schema.optionalKey(Schema.Literals(["ui", "app"])),
328 uiVersion: Schema.optionalKey(Schema.String),
325329});
326330
327331/** The desktop app's `t3` command on PATH, managed from Settings. */
packages/shared/package.json+4
......@@ -3,6 +3,10 @@
33 "private": true,
44 "type": "module",
55 "exports": {
6 "./forkUpdateDirectory": {
7 "types": "./src/forkUpdateDirectory.ts",
8 "import": "./src/forkUpdateDirectory.ts"
9 },
610 "./githubMarkdownLinks": {
711 "types": "./src/githubMarkdownLinks.ts",
812 "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 {
2424 createStagePatchedDependencies,
2525 readWorkspaceConfig,
2626 createBuildConfig,
27 exportForkResources,
2728 DESKTOP_ELECTRON_LANGUAGES,
2829 DESKTOP_FILE_EXCLUSIONS,
2930 DESKTOP_EXTRA_RESOURCES,
......@@ -98,6 +99,54 @@ import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
9899import * as HostProcess from "@t3tools/shared/HostProcess";
99100import { 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
101150// Keeps pnpm from auto-installing TypeScript, a types-only peer, into the app.
102151const stagePackageExtensions = {
103152 "electron-webauthn": { peerDependenciesMeta: { typescript: { optional: true } } },
......@@ -267,6 +316,77 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu
267316});
268317
269318it.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
270390 it("resolves the dedicated nightly updater channel from nightly versions", () => {
271391 assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly");
272392 assert.equal(resolveDesktopUpdateChannel("0.0.17"), "latest");
......@@ -1608,6 +1728,53 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
16081728 );
16091729 });
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
16111778 it.effect("refuses a Linux build on a host that cannot build the browser secret helper", () => {
16121779 const commands: Array<{ readonly command: string }> = [];
16131780 const spawnerLayer = Layer.succeed(
......@@ -1670,7 +1837,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
16701837 });
16711838
16721839 assert.isFalse(
1673 commands.some((command) => command.options.env?.ELECTRON_RUN_AS_NODE === "1"),
1840 commands.some((command) => command.command.endsWith(fixture.appExecutableName)),
16741841 );
16751842 assert.isTrue(
16761843 commands.some(
scripts/build-desktop-artifact.ts+144-24
......@@ -1,4 +1,6 @@
11#!/usr/bin/env node
2import { fingerprintForkRuntime, fingerprintForkServerRuntime } from "./lib/fork-runtime.ts";
3import { FORK_RUNTIME_MANIFEST_FILE } from "@t3tools/shared/forkUpdateDirectory";
24// @effect-diagnostics nodeBuiltinImport:off - Node's typed junction API avoids Windows symlink privileges while keeping the probe isolated.
35
46import * as NodeFSP from "node:fs/promises";
......@@ -845,23 +847,19 @@ const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* (
845847 return { stdout, stderr, exitCode } as const;
846848});
847849
848const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRoot: string) {
849 const result = yield* spawnAndCollectOutput(
850 ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], {
851 cwd: repoRoot,
852 }),
853 ).pipe(
854 Effect.orElseSucceed(() => ({
855 stdout: "",
856 stderr: "",
857 exitCode: 1,
858 })),
859 );
860
861 if (result.exitCode !== 0) {
862 return "unknown";
863 }
864 const hash = result.stdout.trim();
850const resolveCommitHash = Effect.fn("resolveCommitHash")(function* (repoRoot: string) {
851 const configuredHash = yield* Config.String("T3CODE_COMMIT_HASH").pipe(Config.option);
852 const output = yield* Option.match(configuredHash, {
853 onSome: Effect.succeed,
854 onNone: () =>
855 spawnAndCollectOutput(
856 ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], { cwd: repoRoot }),
857 ).pipe(
858 Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")),
859 Effect.orElseSucceed(() => ""),
860 ),
861 });
862 const hash = output.trim();
865863 if (!/^[0-9a-f]{7,40}$/i.test(hash)) {
866864 return "unknown";
867865 }
......@@ -2158,7 +2156,11 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin
21582156);
21592157
21602158const 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 }) {
21622164 const fs = yield* FileSystem.FileSystem;
21632165 const path = yield* Path.Path;
21642166
......@@ -2206,14 +2208,23 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel
22062208 // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node
22072209 // and the bun adapters are covered by the shared runtime-external closure
22082210 // 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("/", "\\")}`;
22092215 yield* runCommand(
22102216 ChildProcess.make(
2211 process.execPath,
2217 wine ?? process.execPath,
22122218 // --no-global-search-paths because clearing NODE_PATH is not enough:
22132219 // CommonJS resolution still falls back to $HOME/.node_modules,
22142220 // $HOME/.node_libraries and the install prefix, so a globally installed
22152221 // 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 ],
22172228 {
22182229 cwd: probeApp,
22192230 stdout: "pipe",
......@@ -2221,7 +2232,7 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel
22212232 // NODE_PATH would let a createRequire call inside the bundle resolve
22222233 // a missing external from outside the packaged tree, which is the
22232234 // 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: "" },
22252236 },
22262237 ),
22272238 {
......@@ -2800,8 +2811,12 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
28002811 };
28012812 const updateChannel = resolveDesktopUpdateChannel(version);
28022813 if (!isDesktopPreviewVersion(version)) {
2814 const updateUrl = yield* Config.URL("T3CODE_DESKTOP_UPDATE_URL").pipe(Config.option);
28032815 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) {
28052820 buildConfig.publish = [publishConfig];
28062821 } else if (mockUpdates) {
28072822 buildConfig.publish = [
......@@ -2816,6 +2831,14 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
28162831 if (platform === "mac") {
28172832 const path = yield* Path.Path;
28182833 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"));
28192842 buildConfig.mac = {
28202843 target: target === "dmg" ? [target, "zip"] : [target],
28212844 icon: "icon.icns",
......@@ -2851,7 +2874,15 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
28512874 role: "Viewer",
28522875 },
28532876 ],
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 : {}),
28552886 ...(macPasskeySigning
28562887 ? {
28572888 entitlements: macPasskeySigning.entitlementsPath,
......@@ -2859,6 +2890,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
28592890 }
28602891 : {}),
28612892 };
2893 if (identity) buildConfig.forceCodeSigning = true;
28622894 }
28632895
28642896 if (platform === "mac" && target === "dmg") {
......@@ -3524,6 +3556,7 @@ export const validateWindowsPackagedPayload = Effect.fn(
35243556
35253557 yield* verifyPackagedBundleIsSelfContained({
35263558 asarPath,
3559 executablePath: path.join(packagedAppDir, input.appExecutableName),
35273560 verbose: input.verbose ?? false,
35283561 });
35293562
......@@ -3533,6 +3566,53 @@ export const validateWindowsPackagedPayload = Effect.fn(
35333566 return { packagedAppDir, fileCount, unpackedFiles } as const;
35343567});
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
35363616const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
35373617 options: ResolvedBuildOptions,
35383618) {
......@@ -3611,7 +3691,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
36113691
36123692 const appVersion = options.version ?? serverPackageJson.version;
36133693 const iconAssets = resolveDesktopBuildIconAssets(appVersion);
3614 const commitHash = yield* resolveGitCommitHash(repoRoot);
3694 const commitHash = yield* resolveCommitHash(repoRoot);
36153695 const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped;
36163696 const stageRoot = yield* mkdir({
36173697 prefix: `t3code-desktop-${options.platform}-stage-`,
......@@ -3964,6 +4044,32 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
39644044 });
39654045 }
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
39674073 // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="")
39684074 // as enabled, so copy the host env and scrub empty values instead of relying
39694075 // on `extendEnv` merging.
......@@ -4076,6 +4182,20 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
40764182 yield* fs.makeDirectory(options.outputDir, { recursive: true });
40774183
40784184 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 }
40794199 for (const entry of stageEntries) {
40804200 const from = path.join(stageDistDir, entry);
40814201 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(
113113 continue;
114114 }
115115
116 if (currentFile !== null && /^ blockMapSize:\s*\d+$/.test(line)) continue;
117
116118 if (line === "files:") {
117119 inFiles = true;
118120 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}