From 44b17c3a0daa9b95d2b15f3d87ff07cf8c3b3d64 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 12 Oct 2026 17:29:07 -0700 Subject: [PATCH] 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 --- .../scripts/main-process-bundle.test.mjs | 68 +- apps/desktop/src/app/DesktopApp.ts | 6 +- .../src/app/DesktopEnvironment.test.ts | 26 + apps/desktop/src/app/DesktopEnvironment.ts | 13 +- .../src/app/DesktopPreReadyPlatform.test.ts | 2 +- .../src/app/DesktopPreReadyPlatform.ts | 5 +- .../DesktopBackendConfiguration.test.ts | 2 + .../backend/DesktopBackendConfiguration.ts | 1 + apps/desktop/src/boot.ts | 38 +- apps/desktop/src/electron/ElectronApp.test.ts | 20 + apps/desktop/src/electron/ElectronApp.ts | 5 +- .../src/electron/ElectronProtocol.test.ts | 2 +- apps/desktop/src/electron/ElectronProtocol.ts | 33 +- apps/desktop/src/electron/ElectronUpdater.ts | 7 +- apps/desktop/src/main.ts | 10 +- .../src/updates/DesktopForkUpdates.test.ts | 320 +++++ .../desktop/src/updates/DesktopForkUpdates.ts | 265 ++++ .../src/updates/DesktopLocalUi.test.ts | 305 +++++ apps/desktop/src/updates/DesktopLocalUi.ts | 236 ++++ .../src/updates/DesktopRemoteUpdates.test.ts | 17 + .../src/updates/DesktopRemoteUpdates.ts | 4 + .../src/updates/DesktopUpdates.test.ts | 158 ++- apps/desktop/src/updates/DesktopUpdates.ts | 179 ++- apps/desktop/src/updates/forkRuntime.test.ts | 86 ++ apps/desktop/src/updates/forkRuntime.ts | 75 + apps/desktop/src/updates/updateMachine.ts | 1 + .../desktop/src/updates/updatesTestHarness.ts | 107 +- .../src/window/DesktopApplicationMenu.test.ts | 39 + .../src/window/DesktopApplicationMenu.ts | 32 +- apps/desktop/vite.config.ts | 4 +- apps/server/src/binCli.ts | 4 +- apps/server/src/cli/invocation.ts | 5 +- apps/server/src/cli/service.ts | 22 +- apps/server/src/cli/triage.ts | 4 +- apps/server/src/cli/update.ts | 4 +- .../server/src/cloud/serviceLauncherClient.ts | 4 +- apps/server/src/cloud/servicePreflight.ts | 4 +- .../src/environment/ServerEnvironment.ts | 4 +- apps/server/src/mcp/McpHttpServer.ts | 4 +- apps/server/src/provider/CodexProvider.ts | 4 +- .../src/provider/providerCompatibility.ts | 4 +- apps/server/src/serverRuntimeState.ts | 4 +- apps/server/src/telemetry/AnalyticsService.ts | 8 +- apps/server/src/version.test.ts | 37 + apps/server/src/version.ts | 6 + .../web/src/components/AutomaticUiRefresh.tsx | 39 + .../components/ComposerPromptEditor.test.tsx | 60 +- .../src/components/ComposerPromptEditor.tsx | 115 +- apps/web/src/components/chat/ChatComposer.tsx | 12 + .../components/chat/MessagesTimeline.test.tsx | 72 +- .../src/components/chat/MessagesTimeline.tsx | 33 +- .../components/chat/timelineRefresh.test.ts | 36 + .../chat/timelineScrollAnchoring.ts | 90 +- .../components/desktopUpdate.actions.test.ts | 81 ++ .../src/components/desktopUpdate.actions.ts | 17 +- apps/web/src/composerDraftStore.ts | 32 + apps/web/src/composerReload.test.ts | 67 + apps/web/src/routes/__root.tsx | 2 + apps/web/src/uiRefresh.test.ts | 190 +++ apps/web/src/uiRefresh.ts | 134 ++ docs/operations/development.md | 159 +++ infra/container/knife | 20 + package.json | 3 + packages/contracts/src/ipc.ts | 4 + packages/shared/package.json | 4 + .../shared/src/forkUpdateDirectory.test.ts | 16 + packages/shared/src/forkUpdateDirectory.ts | 53 + scripts/build-desktop-artifact.test.ts | 169 ++- scripts/build-desktop-artifact.ts | 168 ++- scripts/container-runtime.test.ts | 460 +++++++ scripts/container-runtime.ts | 259 ++++ scripts/lib/fork-runtime.test.ts | 187 +++ scripts/lib/fork-runtime.ts | 151 ++ scripts/lib/update-manifest.ts | 2 + scripts/release-fork.test.ts | 1210 ++++++++++++++++ scripts/release-fork.ts | 1215 +++++++++++++++++ 76 files changed, 7086 insertions(+), 158 deletions(-) create mode 100644 apps/desktop/src/updates/DesktopForkUpdates.test.ts create mode 100644 apps/desktop/src/updates/DesktopForkUpdates.ts create mode 100644 apps/desktop/src/updates/DesktopLocalUi.test.ts create mode 100644 apps/desktop/src/updates/DesktopLocalUi.ts create mode 100644 apps/desktop/src/updates/forkRuntime.test.ts create mode 100644 apps/desktop/src/updates/forkRuntime.ts create mode 100644 apps/server/src/version.test.ts create mode 100644 apps/server/src/version.ts create mode 100644 apps/web/src/components/AutomaticUiRefresh.tsx create mode 100644 apps/web/src/components/chat/timelineRefresh.test.ts create mode 100644 apps/web/src/components/desktopUpdate.actions.test.ts create mode 100644 apps/web/src/composerReload.test.ts create mode 100644 apps/web/src/uiRefresh.test.ts create mode 100644 apps/web/src/uiRefresh.ts create mode 100755 infra/container/knife create mode 100644 packages/shared/src/forkUpdateDirectory.test.ts create mode 100644 packages/shared/src/forkUpdateDirectory.ts create mode 100644 scripts/container-runtime.test.ts create mode 100644 scripts/container-runtime.ts create mode 100644 scripts/lib/fork-runtime.test.ts create mode 100644 scripts/lib/fork-runtime.ts create mode 100644 scripts/release-fork.test.ts create mode 100644 scripts/release-fork.ts diff --git a/apps/desktop/scripts/main-process-bundle.test.mjs b/apps/desktop/scripts/main-process-bundle.test.mjs index 678b62f58aacaa7d1126bdf81be529d122e2418c..8d7c1499da40c3d138ada1c6bfa29350a821b807 100644 --- a/apps/desktop/scripts/main-process-bundle.test.mjs +++ b/apps/desktop/scripts/main-process-bundle.test.mjs @@ -113,28 +113,23 @@ void import("./linux.ts").then(({ result }) => process.emit("ready", result));`, it("loads the emitted packaged boot entry and backend cache preload", async () => { const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-boot-")); try { - const entries = ["src/boot.ts", "src/compileCache.ts"]; - await NodeFSP.mkdir(NodePath.join(directory, "src")); - await Promise.all( - entries.map((entry) => - NodeFSP.copyFile(new URL(`../${entry}`, import.meta.url), NodePath.join(directory, entry)), - ), - ); + const entries = new Set(["src/boot.ts", "src/compileCache.ts"]); + const outputDirectory = NodePath.join(directory, "bundle/apps/desktop/dist-electron"); assert.ok(Array.isArray(desktopConfig.pack)); for (const packConfig of desktopConfig.pack) { if (!Array.isArray(packConfig.entry)) continue; - if (!packConfig.entry.some((entry) => entries.includes(entry))) continue; + if (!packConfig.entry.some((entry) => entries.has(entry))) continue; await build({ ...packConfig, config: false, - cwd: directory, + cwd: NodePath.resolve(import.meta.dirname, ".."), + outDir: outputDirectory, tsconfig: false, sourcemap: false, onSuccess: undefined, logLevel: "silent", }); } - const outputDirectory = NodePath.join(directory, "dist-electron"); const fixture = `console.log(require('node:module').getCompileCacheDir() ? 'cached' : 'uncached');`; await NodeFSP.writeFile(NodePath.join(outputDirectory, "main.cjs"), fixture); await NodeFSP.writeFile( @@ -167,6 +162,59 @@ it("loads the emitted packaged boot entry and backend cache preload", async () = assert.equal(child.stdout.trim(), disabled ? "uncached" : "cached"); } } + const shell = "a".repeat(64); + const payload = NodePath.join(directory, "data/userdata/fork-releases/release-test"); + for (const name of ["apps/desktop/dist-electron", "apps/server/dist/client"]) { + await NodeFSP.mkdir(NodePath.join(payload, name), { recursive: true }); + } + await NodeFSP.cp(outputDirectory, NodePath.join(payload, "apps/desktop/dist-electron"), { + recursive: true, + }); + await NodeFSP.writeFile( + NodePath.join(payload, "apps/desktop/dist-electron/main.cjs"), + "console.log('payload:' + JSON.parse(process.env.T3CODE_DESKTOP_PAYLOAD).version);", + ); + await NodeFSP.writeFile(NodePath.join(payload, "apps/desktop/dist-electron/preload.cjs"), ""); + await NodeFSP.writeFile(NodePath.join(payload, "apps/server/dist/bin.mjs"), ""); + await NodeFSP.writeFile( + NodePath.join(payload, "apps/server/dist/client/index.html"), + "fixture", + ); + await NodeFSP.writeFile( + NodePath.join(directory, "bundle/knife-runtime.json"), + JSON.stringify({ shell }), + ); + await NodeFSP.writeFile( + NodePath.join(payload, "../current.json"), + JSON.stringify({ + directory: "release-test", + shell, + runtime: "b".repeat(64), + version: "0.0.2000000000", + }), + ); + const electronStub = NodePath.join(directory, "electron.cjs"); + await NodeFSP.writeFile( + electronStub, + ` + Object.defineProperty(process.versions, 'electron', { value: '44.4.5' }); + const Module = require('node:module'); + const load = Module._load; + Module._load = function(name, ...args) { + return name === 'electron' ? { app: { isPackaged: true } } : load.call(this, name, ...args); + }; + `, + ); + const selected = NodeChildProcess.spawnSync( + process.execPath, + ["--require", electronStub, NodePath.join(outputDirectory, "boot.cjs")], + { + encoding: "utf8", + env: { ...process.env, T3CODE_HOME: NodePath.join(directory, "data") }, + }, + ); + assert.equal(selected.status, 0, selected.stderr); + assert.equal(selected.stdout.trim(), "payload:0.0.2000000000"); } finally { await NodeFSP.rm(directory, { recursive: true, force: true }); } diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 1504e0ca63edaa55f71b8e636a30509ab7ea0e58..938989eece546a80052bc4fb156c2826d3cc40e1 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -32,6 +32,7 @@ import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts"; import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; @@ -171,14 +172,13 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap start"); const settings = yield* desktopSettings.get; - // The renderer is served from the bundled client (or Vite in development) - // rather than through the local backend, so the window can open without one. + // The renderer loads independently of the chat server. const electronProtocol = yield* ElectronProtocol.ElectronProtocol; yield* electronProtocol.registerDesktopProtocol({ scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), ...(environment.isDevelopment ? { targetOrigin: Option.getOrThrow(environment.devServerUrl) } - : { assetDirectory: environment.clientAssetsDir }), + : { assetDirectories: (yield* DesktopLocalUi.DesktopLocalUi).assetDirectories }), clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, }); yield* installDesktopIpcHandlers(); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index fb6c5a8b1afdb5222465d9abe203d925cbf56ba6..92a19a1611c2359c8e3226c3bb22c7b020427053 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -1,6 +1,7 @@ import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -141,6 +142,31 @@ describe("DesktopEnvironment", () => { }), ); + it.effect("keeps downloaded Windows desktop and server dependencies separate", () => + Effect.gen(function* () { + vi.stubEnv("T3CODE_DESKTOP_PAYLOAD", JSON.stringify({ directory: "/updates/release-new" })); + try { + const environment = yield* makeEnvironment({ + platform: "win32", + isPackaged: true, + appPath: "/updates/release-new", + resourcesPath: "/install/resources", + }); + assert.equal(environment.serverRoot, "/updates/release-new/server"); + assert.equal( + environment.backendEntryPath, + "/updates/release-new/server/apps/server/dist/bin.mjs", + ); + assert.equal( + environment.clientAssetsDir, + "/updates/release-new/server/apps/server/dist/client", + ); + } finally { + vi.unstubAllEnvs(); + } + }), + ); + it.effect("uses the stable desktop entry as the packaged Linux portal identity", () => Effect.gen(function* () { const environment = yield* makeEnvironment({ diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index abc0f59da13e3e7046283cbd1ad1c2ec903070cf..0ff1458e3db98010c81a67b8b8afe9925ecf954a 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -11,6 +11,8 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import { activeForkRuntime } from "../updates/forkRuntime.ts"; + import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts"; @@ -54,12 +56,7 @@ export class DesktopEnvironment extends Context.Service< readonly browserArtifactsDir: string; readonly rootDir: string; readonly appRoot: string; - // Root of the tree containing apps/server/dist and node_modules for the - // backend. Equals appRoot everywhere except packaged Windows, where the - // server tree ships as the resources/server.asar sidecar (see - // scripts/build-desktop-artifact.ts) that the asar-aware - // ELECTRON_RUN_AS_NODE primary reads in place and the WSL backend - // extracts on demand (see DesktopWslServerTree). + // Windows keeps separate desktop and server dependency trees. readonly serverRoot: string; readonly backendEntryPath: string; // Built web client the packaged renderer is served from over t3code://app. @@ -173,7 +170,9 @@ const make = Effect.fn("desktop.environment.make")(function* ( const appRoot = input.isPackaged ? input.appPath : rootDir; const serverRoot = input.isPackaged && input.platform === "win32" - ? path.join(input.resourcesPath, "server.asar") + ? activeForkRuntime() === undefined + ? path.join(input.resourcesPath, "server.asar") + : path.join(appRoot, "server") : appRoot; const branding = resolveDesktopAppBranding({ isDevelopment, diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 29fa706698e0191eb7920e073e12af00fd5f908c..e338c6cd7d193540585206c784dc37f47c389059 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -175,7 +175,7 @@ describe("DesktopPreReadyPlatform", () => { const identity = yield* Effect.promise(() => portalIdentity); assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); - assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)"); + assert.include(identity.desktopEntry ?? "", "Name=The Knife"); assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); assert.include( identity.desktopEntry ?? "", diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index b021ae02e826cb72cc0f0994cd367cd0d39cad0f..3072ccbdef566b8f76cc10e1e9e0b362b183df8a 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -1,3 +1,4 @@ +import { activeForkRuntime } from "../updates/forkRuntime.ts"; // @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. import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; @@ -84,7 +85,7 @@ export const make = Effect.gen(function* () { NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true }); NodeFS.copyFileSync( NodePath.posix.join( - Electron.app.getAppPath(), + activeForkRuntime()?.directory ?? Electron.app.getAppPath(), "apps/desktop/prod-resources/icon.png", ), iconPath, @@ -98,7 +99,7 @@ export const make = Effect.gen(function* () { renderUrlHandlerDesktopEntry({ displayName: resolveDesktopAppBranding({ isDevelopment: linux.isDevelopment, - appVersion: Electron.app.getVersion(), + appVersion: activeForkRuntime()?.version ?? Electron.app.getVersion(), }).displayName, execTarget: process.env.APPIMAGE?.trim() || process.execPath, scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 090f6e0edf5e491f057b82f7bfe154ee21e7b72b..0a09771f9b79b4a852a689c09429a5088fe4542c 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -246,6 +246,7 @@ describe("DesktopBackendConfiguration", () => { assert.equal(first.cwd, environment.backendCwd); assert.equal(first.captureOutput, true); assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1"); + assert.equal(first.env.T3CODE_VERSION_OVERRIDE, environment.appVersion); assert.isUndefined(first.env.T3CODE_PORT); assert.isUndefined(first.env.T3CODE_MODE); assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST); @@ -964,6 +965,7 @@ describe("DesktopBackendConfiguration", () => { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const config = yield* configuration.resolvePrimary; assert.equal(config.captureOutput, true); + assert.isUndefined(config.env.T3CODE_VERSION_OVERRIDE); // Dev never shares the prod compile cache. assert.notInclude(config.args, "--require"); }).pipe( diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index c5f60e963dc0adadfff9751951b4f091dace2a6d..9994ee1cda276ed86dfd15e1be0497c8d3688c96 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -603,6 +603,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv env: { ...backendChildEnvPatch(), ELECTRON_RUN_AS_NODE: "1", + T3CODE_VERSION_OVERRIDE: environment.isPackaged ? environment.appVersion : undefined, // The server names this launcher in commands it asks a person to run. T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), }, diff --git a/apps/desktop/src/boot.ts b/apps/desktop/src/boot.ts index c9eb35b24e5fab04b8dc050a62c9317f025ba0e3..78ba5fa158b77801139ee795c56c99b610f77c89 100644 --- a/apps/desktop/src/boot.ts +++ b/apps/desktop/src/boot.ts @@ -1,4 +1,34 @@ -// Packaged app entry. Enables the compile cache before the main bundle loads, -// so the cache also covers main.cjs itself. -require("./compileCache.cjs"); -require("./main.cjs"); +// @effect-diagnostics nodeBuiltinImport:off - The synchronous signed bootstrap runs before Effect services exist. +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as Option from "effect/Option"; + +import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./app/DesktopStatePaths.ts"; +import { readActiveForkRuntime } from "./updates/forkRuntime.ts"; + +delete process.env.T3CODE_DESKTOP_PAYLOAD; +let entry = __dirname; +const app = process.versions.electron + ? (require("electron") as typeof import("electron")).app + : undefined; +if (app?.isPackaged) { + const t3Home = Option.fromNullishOr(process.env.T3CODE_HOME); + const baseDir = resolveDesktopBaseDir({ + homeDirectory: NodeOS.homedir(), + joinPath: NodePath.join, + t3Home, + }); + const stateDir = resolveDesktopStateDir({ + baseDir, + isDevelopment: false, + joinPath: NodePath.join, + t3Home, + }); + const active = readActiveForkRuntime(stateDir, NodePath.resolve(__dirname, "../../..")); + if (active !== undefined) { + process.env.T3CODE_DESKTOP_PAYLOAD = JSON.stringify(active); + entry = NodePath.join(active.directory, "apps/desktop/dist-electron"); + } +} +require(NodePath.join(entry, "compileCache.cjs")); +require(NodePath.join(entry, "main.cjs")); diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 84f37b6f5f53753cc2583b04a8dfed651b123630..3f3a3dbd0592fb87358fbd9aa2023c188a4fc498 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -84,6 +84,7 @@ import * as ElectronApp from "./ElectronApp.ts"; describe("ElectronApp", () => { beforeEach(() => { + vi.unstubAllEnvs(); appendSwitchMock.mockClear(); autoUpdaterOnMock.mockClear(); autoUpdaterRemoveListenerMock.mockClear(); @@ -111,6 +112,25 @@ describe("ElectronApp", () => { }).pipe(Effect.provide(ElectronApp.layer)), ); + it.effect("reports the selected payload version and path while retaining signed resources", () => + Effect.gen(function* () { + vi.stubEnv( + "T3CODE_DESKTOP_PAYLOAD", + JSON.stringify({ + version: "0.0.2000000000", + directory: "/staged/release", + shell: "a".repeat(64), + runtime: "b".repeat(64), + }), + ); + const metadata = yield* (yield* ElectronApp.ElectronApp).metadata; + assert.equal(metadata.appVersion, "0.0.2000000000"); + assert.equal(metadata.appPath, "/staged/release"); + assert.equal(metadata.resourcesPath, process.resourcesPath); + vi.unstubAllEnvs(); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + it.effect("reads the OS locale through the service", () => Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index f75ea51552e744247e813315eb17155c92b36dac..cbfa9e3a467025a782184661b2c3b95592793270 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Electron from "electron"; +import { activeForkRuntime } from "../updates/forkRuntime.ts"; export interface ElectronAppMetadata { readonly appVersion: string; @@ -101,7 +102,7 @@ const addScopedAppListener = >( export const make = ElectronApp.of({ metadata: Effect.gen(function* () { const appVersion = yield* Effect.try({ - try: () => Electron.app.getVersion(), + try: () => activeForkRuntime()?.version ?? Electron.app.getVersion(), catch: (cause) => new ElectronAppMetadataReadError({ property: "app-version", @@ -109,7 +110,7 @@ export const make = ElectronApp.of({ }), }); const appPath = yield* Effect.try({ - try: () => Electron.app.getAppPath(), + try: () => activeForkRuntime()?.directory ?? Electron.app.getAppPath(), catch: (cause) => new ElectronAppMetadataReadError({ property: "app-path", diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 3f0be7cc7b6d3deb9d2612adc3c088200861c54e..1b924bd4d601870edd1d4ebcc83abd68a5811639 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -43,7 +43,7 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ scheme: "t3code", - assetDirectory: directory, + assetDirectories: Effect.succeed([directory]), clerkFrontendApiHostname: undefined, }); const request = (pathname: string, init?: RequestInit) => diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 7b2bf8a59fc976e097b51dfec02225db4e14ed2a..2db2a5a8566eeff4da335320504cfb28525809a7 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -53,11 +53,14 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError } +); export class ElectronProtocol extends Context.Service< ElectronProtocol, @@ -213,12 +216,9 @@ const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* ( return withContentSecurityPolicy(response, contentSecurityPolicy); }); -// Serves the packaged web client without a backend: files resolve within the -// asset directory, and any other path falls back to index.html so the SPA -// router handles it, except for asset-shaped misses (`/missing.js`) which 404. const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( request: Request, - assetDirectory: string, + assetDirectories: readonly [string, ...string[]], ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -231,13 +231,21 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( Effect.orElseSucceed(() => null), ); if (pathname === null || pathname.includes("\0")) return new Response(null, { status: 400 }); - const root = path.resolve(assetDirectory); + const root = path.resolve(assetDirectories[0]); const assetPath = path.resolve(root, `.${pathname}`); if (assetPath !== root && !assetPath.startsWith(root + path.sep)) { return new Response(null, { status: 404 }); } - const stat = yield* fileSystem.stat(assetPath).pipe(Effect.orElseSucceed(() => null)); let filePath = assetPath; + let stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); + // Old pages can still request lazy chunks while the renderer reloads. + if (path.extname(assetPath) !== "" && path.basename(assetPath) !== "index.html") { + for (const previous of assetDirectories.slice(1)) { + if (stat?.type === "File") break; + filePath = path.resolve(previous, `.${pathname}`); + stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); + } + } if (stat?.type !== "File") { const wantsHtml = request.headers.get("accept")?.includes("text/html") ?? false; if (path.extname(assetPath) !== "" && !wantsHtml) { @@ -250,6 +258,7 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( return new Response(request.method === "HEAD" ? null : new Uint8Array(contents), { headers: { "content-type": Option.getOrElse(Mime.getType(filePath), () => "application/octet-stream"), + ...(path.basename(filePath) === "index.html" ? { "cache-control": "no-store" } : {}), }, }); }); @@ -270,9 +279,13 @@ export const make = Effect.gen(function* () { Effect.try({ try: () => { Electron.protocol.handle(input.scheme, async (request) => { - if ("assetDirectory" in input) { + if ("assetDirectories" in input) { return withContentSecurityPolicy( - await runPromise(serveDesktopAsset(request, input.assetDirectory)), + await runPromise( + Effect.flatMap(input.assetDirectories, (directories) => + serveDesktopAsset(request, directories), + ), + ), contentSecurityPolicy, ); } diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index e42ebc1003ab4b2f7803f04080b3228caebe8474..8e6674e4de0508494d2ea3f17b961da46c4f68c7 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -68,7 +68,10 @@ export class ElectronUpdater extends Context.Service< readonly setFullChangelog: (value: boolean) => Effect.Effect; readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; readonly checkForUpdates: Effect.Effect; - readonly downloadUpdate: Effect.Effect; + readonly downloadUpdate: Effect.Effect< + ReadonlyArray, + ElectronUpdaterDownloadUpdateError + >; readonly quitAndInstall: (options: { readonly isSilent: boolean; readonly isForceRunAfter: boolean; @@ -135,7 +138,7 @@ export const make = ElectronUpdater.of({ return Effect.tryPromise({ try: () => autoUpdater.downloadUpdate(), catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), - }).pipe(Effect.asVoid); + }); }), quitAndInstall: ({ isSilent, isForceRunAfter }) => Effect.suspend(() => { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9c0b083f00fc28a6d911ab780a140e377f46920d..85f987a3cda4fcfa4314b2fbda70910e4ac832ab 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -68,6 +68,9 @@ import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts"; import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopRendererHistory from "./telemetry/DesktopRendererHistory.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; +import * as DesktopLocalUi from "./updates/DesktopLocalUi.ts"; +import * as DesktopForkUpdates from "./updates/DesktopForkUpdates.ts"; +import { activeForkRuntime } from "./updates/forkRuntime.ts"; import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; import * as BrowserSession from "./preview/BrowserSession.ts"; @@ -81,7 +84,10 @@ import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts"; if (process.argv.includes("--version")) { try { - NodeFS.writeSync(process.stdout.fd, `${Electron.app.getVersion()}\n`); + NodeFS.writeSync( + process.stdout.fd, + `${activeForkRuntime()?.version ?? Electron.app.getVersion()}\n`, + ); } catch (error) { if (!(error instanceof Error) || !("code" in error) || error.code !== "EPIPE") throw error; } @@ -228,6 +234,8 @@ const layerDesktopApplication = Layer.mergeAll( ).pipe( Layer.provideMerge(layerDesktopSnapShot), Layer.provideMerge(DesktopUpdates.layer), + Layer.provideMerge(DesktopForkUpdates.layer), + Layer.provideMerge(DesktopLocalUi.layer), Layer.provideMerge(layerDesktopWslBackend), Layer.provideMerge(layerDesktopLocalEnvironmentAuth), ); diff --git a/apps/desktop/src/updates/DesktopForkUpdates.test.ts b/apps/desktop/src/updates/DesktopForkUpdates.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f74fb291a42facd458a70f8ea4093cf2a0613558 --- /dev/null +++ b/apps/desktop/src/updates/DesktopForkUpdates.test.ts @@ -0,0 +1,320 @@ +// @effect-diagnostics nodeBuiltinImport:off - Fixtures create real archives to exercise extraction and atomic activation. +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeHttp from "node:http"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { FetchHttpClient } from "effect/http"; +import { ForkReleaseManifest } from "@t3tools/shared/forkUpdateDirectory"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopLocalUi from "./DesktopLocalUi.ts"; +import * as DesktopForkUpdates from "./DesktopForkUpdates.ts"; +import { readActiveForkRuntime } from "./forkRuntime.ts"; + +const shell = "a".repeat(64); +const runtime = "b".repeat(64); +const nextRuntime = "c".repeat(64); +const version = "0.0.2000000000"; + +const withUpdater = ( + test: (input: { + updater: DesktopForkUpdates.DesktopForkUpdates["Service"]; + release: typeof ForkReleaseManifest.Type; + nextUi: (typeof ForkReleaseManifest.Type)["ui"]; + base: URL; + requested: string[]; + applied: string[]; + files: Map; + environment: DesktopEnvironment.DesktopEnvironment["Service"]; + }) => Effect.Effect, + platform: NodeJS.Platform = "darwin", +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const resources = NodePath.join(home, "resources"); + const bundled = NodePath.join(resources, "app.asar"); + yield* fs.makeDirectory(bundled, { recursive: true }); + yield* fs.writeFileString( + NodePath.join(bundled, "knife-runtime.json"), + JSON.stringify({ version: "0.0.1", sha256: runtime, shell }), + ); + const files = new Map(); + const archive = (kind: string, contents: Record) => + Effect.promise(async () => { + const directory = NodePath.join(home, kind); + await NodeFSP.mkdir(directory); + for (const [name, content] of Object.entries(contents)) { + const file = NodePath.join(directory, name); + await NodeFSP.mkdir(NodePath.dirname(file), { recursive: true }); + await NodeFSP.writeFile(file, content); + } + const file = `${kind}.tar.gz`; + NodeChildProcess.execFileSync("tar", [ + "-czf", + NodePath.join(home, file), + "-C", + directory, + ".", + ]); + const bytes = await NodeFSP.readFile(NodePath.join(home, file)); + files.set(file, bytes); + return { + file, + size: bytes.length, + sha256: NodeCrypto.createHash("sha256").update(bytes).digest("hex"), + }; + }); + const ui = yield* archive("ui", { + "index.html": '', + "app.js": "new UI", + ".vite/manifest.json": '{"app":{"file":"app.js"}}', + }); + const assets = yield* archive("assets", { "images/logo.svg": "static asset" }); + const nextUi = yield* archive("ui-next", { + "index.html": '', + "app-next.js": "next UI", + ".vite/manifest.json": '{"app":{"file":"app-next.js"}}', + }); + const code = yield* archive("code", { + "apps/desktop/dist-electron/main.cjs": "new main", + "apps/desktop/dist-electron/preload.cjs": "new preload", + "apps/desktop/dist-electron/compileCache.cjs": "cache", + [`${platform === "win32" ? "server/" : ""}apps/server/dist/bin.mjs`]: + 'console.log("fixture");', + }); + const platformKey = `${platform}-${platform === "darwin" ? "arm64" : "x64"}`; + const release = { + version, + ui, + assets, + server: { ...code, version, runtime: nextRuntime }, + desktop: { + [platformKey]: { + version, + ui, + assets, + shell, + runtime: nextRuntime, + code, + app: { ...code, version }, + }, + }, + }; + files.set( + "?ls", + new TextEncoder().encode(JSON.stringify({ files: [{ href: `${version}.json` }] })), + ); + const requested: string[] = []; + const applied: string[] = []; + const server = NodeHttp.createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const file = url.searchParams.has("ls") ? "?ls" : url.pathname.split("/").at(-1)!; + requested.push(file); + const body = file === `${version}.json` ? JSON.stringify(release) : files.get(file); + response.writeHead(body === undefined ? 404 : 200).end(body); + }); + yield* Effect.acquireRelease( + Effect.promise(() => new Promise((resolve) => server.listen(0, "127.0.0.1", resolve))), + () => + Effect.promise( + () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + ), + ); + const address = server.address(); + if (address === null || typeof address === "string") + return yield* Effect.die("Missing fixture server address"); + const base = new URL(`http://127.0.0.1:${address.port}/`); + const environmentLayer = DesktopEnvironment.layer({ + dirname: NodePath.join(bundled, "apps/desktop/dist-electron"), + homeDirectory: home, + platform, + processArch: platform === "darwin" ? "arm64" : "x64", + appVersion: "0.0.1", + appPath: bundled, + resourcesPath: resources, + isPackaged: true, + runningUnderArm64Translation: false, + }).pipe(Layer.provideMerge(DesktopConfig.layerTest({ T3CODE_HOME: home }))); + return yield* Effect.gen(function* () { + const updater = yield* DesktopForkUpdates.make; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return yield* test({ + updater, + release, + nextUi, + base, + requested, + applied, + files, + environment, + }); + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(DesktopLocalUi.DesktopLocalUi, { + assetDirectories: Effect.succeed(["/client"] as const), + currentVersion: Effect.succeed("0.0.1"), + reset: Effect.void, + apply: (source, version) => + Effect.promise(async () => { + assert.equal( + await NodeFSP.readFile(NodePath.join(source, "images/logo.svg"), "utf8"), + "static asset", + ); + applied.push(`${source}:${version}`); + }), + }), + Effect.provide(environmentLayer), + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +it.effect("downloads verified code and UI before atomically activating an exact shell match", () => + withUpdater(({ updater, base, release, requested, environment }) => + Effect.gen(function* () { + assert.deepEqual(yield* updater.check(base), release); + assert.equal(yield* updater.prepare(base, release), "code"); + assert.deepEqual(requested, [ + "?ls", + `${version}.json`, + "code.tar.gz", + "assets.tar.gz", + "ui.tar.gz", + ]); + assert.equal(readActiveForkRuntime(environment.stateDir, environment.appRoot), undefined); + assert.equal(yield* updater.activateCode("0.0.1999999999"), false); + assert.equal(yield* updater.activateCode(version), true); + const active = readActiveForkRuntime(environment.stateDir, environment.appRoot); + assert.equal(active?.version, version); + assert.equal(active?.runtime, nextRuntime); + assert.equal(active?.shell, shell); + }), + ), +); + +it.effect("downloads only new UI on a second release with unchanged code and static assets", () => + withUpdater(({ updater, base, release, nextUi, requested, applied }) => + Effect.gen(function* () { + const target = release.desktop["darwin-arm64"]!; + const matching = { ...release, desktop: { "darwin-arm64": { ...target, runtime } } }; + assert.equal(yield* updater.prepare(base, matching), "ui"); + assert.equal(yield* updater.installUi(version), true); + assert.equal(applied.length, 1); + const next = { + ...matching, + version: "0.0.2000000001", + ui: nextUi, + desktop: { + "darwin-arm64": { + ...matching.desktop["darwin-arm64"]!, + version: "0.0.2000000001", + ui: nextUi, + }, + }, + }; + assert.equal(yield* updater.prepare(base, next), "ui"); + assert.equal(yield* updater.activateCode(next.version), false); + assert.deepEqual(requested, ["assets.tar.gz", "ui.tar.gz", "ui-next.tar.gz"]); + }), + ), +); + +it.effect("leaves the active pointer unchanged after corrupt or incomplete downloads", () => + withUpdater(({ updater, base, release, files, environment }) => + Effect.gen(function* () { + files.set("code.tar.gz", new TextEncoder().encode("corrupt")); + const error = yield* updater.prepare(base, release).pipe(Effect.flip); + assert.equal(error._tag, "DesktopForkUpdateError"); + assert.equal(yield* updater.activateCode(version), false); + assert.equal(readActiveForkRuntime(environment.stateDir, environment.appRoot), undefined); + }), + ), +); + +it.effect("requires a signed shell update without downloading code when the shell differs", () => + withUpdater(({ updater, base, release, requested }) => + Effect.gen(function* () { + const mismatch = { + ...release, + desktop: { "darwin-arm64": { ...release.desktop["darwin-arm64"]!, shell: "d".repeat(64) } }, + }; + assert.equal(yield* updater.prepare(base, mismatch), "shell"); + assert.deepEqual(requested, []); + }), + ), +); + +it.effect("reports an empty public release directory without fetching an unfinished manifest", () => + withUpdater(({ updater, base, files, requested }) => + Effect.gen(function* () { + files.set( + "?ls", + new TextEncoder().encode(JSON.stringify({ files: [{ href: "unfinished.json" }] })), + ); + const error = yield* updater.check(base).pipe(Effect.flip); + assert.equal(error.message, "No updates have been published. Try again later."); + assert.deepEqual(requested, ["?ls"]); + }), + ), +); + +it.effect( + "preserves the platform's compatible renderer and version when the aggregate is newer", + () => + withUpdater(({ updater, base, release, nextUi, requested }) => + Effect.gen(function* () { + const aggregate = { ...release, version: "0.0.2000000001", ui: nextUi }; + assert.equal(yield* updater.prepare(base, aggregate), "code"); + assert.equal(yield* updater.activateCode(aggregate.version), false); + assert.equal(yield* updater.activateCode(version), true); + assert.deepEqual(requested, ["code.tar.gz", "assets.tar.gz", "ui.tar.gz"]); + }), + ), +); + +it.effect.each(["linux", "win32"] as const)( + "activates a verified %s code tree and preserves its shell", + (platform) => + withUpdater( + ({ updater, release, base, environment, requested }) => + Effect.gen(function* () { + assert.equal(yield* updater.prepare(base, release), "code"); + assert.equal(yield* updater.activateCode(version), true); + assert.equal( + readActiveForkRuntime(environment.stateDir, environment.appRoot, platform)?.version, + version, + ); + assert.deepEqual(requested, ["code.tar.gz", "assets.tar.gz", "ui.tar.gz"]); + }), + platform, + ), +); + +it.effect.each(["linux", "win32"] as const)( + "downloads only renderer assets for an unchanged %s runtime", + (platform) => + withUpdater( + ({ updater, release, base, requested, applied }) => + Effect.gen(function* () { + const key = `${platform}-x64`; + const matching = { + ...release, + desktop: { [key]: { ...release.desktop[key]!, runtime } }, + }; + assert.equal(yield* updater.prepare(base, matching), "ui"); + assert.equal(yield* updater.installUi(version), true); + assert.equal(applied.length, 1); + assert.deepEqual(requested, ["assets.tar.gz", "ui.tar.gz"]); + }), + platform, + ), +); diff --git a/apps/desktop/src/updates/DesktopForkUpdates.ts b/apps/desktop/src/updates/DesktopForkUpdates.ts new file mode 100644 index 0000000000000000000000000000000000000000..482fb808e966d0f005f129494aed600b8562b5be --- /dev/null +++ b/apps/desktop/src/updates/DesktopForkUpdates.ts @@ -0,0 +1,265 @@ +// @effect-diagnostics nodeBuiltinImport:off - Release downloads are hashed before extraction. +import * as NodeCrypto from "node:crypto"; +import { + ForkReleaseManifest, + ForkRuntimeManifest, + ForkReleaseListing, + latestForkRelease, +} from "@t3tools/shared/forkUpdateDirectory"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopLocalUi from "./DesktopLocalUi.ts"; +import { activeForkRuntime } from "./forkRuntime.ts"; + +export class DesktopForkUpdateError extends Schema.TaggedError()( + "DesktopForkUpdateError", + { message: Schema.String, cause: Schema.Defect() }, +) {} + +const isForkUpdateError = Schema.is(DesktopForkUpdateError); +const decodeReleaseListing = Schema.decodeUnknownEffect(ForkReleaseListing); +const decodeRelease = Schema.decodeUnknownEffect(ForkReleaseManifest); +const decodeRuntime = Schema.decodeUnknownEffect(Schema.fromJsonString(ForkRuntimeManifest)); + +type Release = typeof ForkReleaseManifest.Type; +type Artifact = Release["ui"]; +type Prepared = { + version: string; + directory: string; + shell: string; + runtime: string; + kind: "ui" | "code"; +}; + +export class DesktopForkUpdates extends Context.Service< + DesktopForkUpdates, + { + readonly check: (base: URL) => Effect.Effect; + readonly kind: ( + release: Release, + ) => Effect.Effect<"ui" | "code" | "shell", DesktopForkUpdateError>; + readonly prepare: ( + base: URL, + release: Release, + ) => Effect.Effect<"ui" | "code" | "shell", DesktopForkUpdateError>; + readonly installUi: (version: string) => Effect.Effect; + readonly activateCode: (version: string) => Effect.Effect; + } +>()("@t3tools/desktop/updates/DesktopForkUpdates") {} + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fs = yield* FileSystem.FileSystem; + const path = environment.path; + const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const ui = yield* DesktopLocalUi.DesktopLocalUi; + const prepared = yield* Ref.make(null); + const encodePointer = Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ + version: Schema.String, + directory: Schema.String, + shell: Schema.String, + runtime: Schema.String, + }), + ), + ); + const releasesDir = path.join(environment.stateDir, "fork-releases"); + const fail = (cause: unknown) => + isForkUpdateError(cause) + ? cause + : new DesktopForkUpdateError({ + message: "Could not prepare the update. Try again.", + cause, + }); + + const check = (base: URL) => + Effect.scoped( + Effect.gen(function* () { + const listingUrl = new URL(base); + listingUrl.searchParams.set("ls", ""); + const client = HttpClient.withScope(http); + const response = yield* client.get(listingUrl.href); + const listing = yield* decodeReleaseListing(yield* response.json); + const version = latestForkRelease(listing); + if (version === undefined) + return yield* new DesktopForkUpdateError({ + message: "No updates have been published. Try again later.", + cause: "Empty release share", + }); + const manifest = yield* client.get(new URL(`${version}.json`, base).href); + const release = yield* decodeRelease(yield* manifest.json); + if (release.version !== version) + return yield* fail("The published manifest has a different version."); + return release; + }), + ).pipe(Effect.mapError(fail)); + + const extract = (base: URL, artifact: Artifact, directory: string) => + Effect.scoped( + Effect.gen(function* () { + const archive = path.join(releasesDir, artifact.file); + const valid = (bytes: Uint8Array) => + bytes.byteLength === artifact.size && + NodeCrypto.createHash("sha256").update(bytes).digest("hex") === artifact.sha256; + if (!(yield* fs.exists(archive)) || !valid(yield* fs.readFile(archive))) { + const response = yield* HttpClient.withScope(http).get(new URL(artifact.file, base).href); + const bytes = new Uint8Array(yield* response.arrayBuffer); + if (!valid(bytes)) + return yield* fail("The downloaded update did not match its checksum."); + yield* fs.writeFile(`${archive}.tmp`, bytes); + yield* fs.rename(`${archive}.tmp`, archive); + } + yield* fs.makeDirectory(directory, { recursive: true }); + const child = yield* spawner.spawn( + ChildProcess.make("tar", ["-xzf", archive, "-C", directory]), + ); + if (Number(yield* child.exitCode) !== 0) + return yield* fail("Could not extract the update."); + }), + ); + + const kind = (release: Release) => + Effect.gen(function* () { + const target = release.desktop[`${environment.platform}-${environment.processArch}`]; + if (target === undefined) return yield* fail("This release is unavailable for this machine."); + const baseline = yield* fs + .readFileString(path.join(environment.resourcesPath, "app.asar", "knife-runtime.json")) + .pipe(Effect.flatMap(decodeRuntime)); + if (target.shell !== baseline.shell) return "shell" as const; + const runtime = activeForkRuntime()?.runtime ?? baseline.sha256; + return target.runtime === runtime ? ("ui" as const) : ("code" as const); + }).pipe(Effect.mapError(fail)); + + const prepare = (base: URL, release: Release) => + Effect.gen(function* () { + const previous = yield* Ref.getAndSet(prepared, null); + if (previous !== null) yield* fs.remove(previous.directory, { recursive: true, force: true }); + const installKind = yield* kind(release); + if (installKind === "shell") return installKind; + const target = release.desktop[`${environment.platform}-${environment.processArch}`]!; + const serverRoot = environment.platform === "win32" ? "server" : "."; + yield* fs.makeDirectory(releasesDir, { recursive: true }); + const directory = yield* fs.makeTempDirectory({ directory: releasesDir, prefix: "release-" }); + return yield* Effect.gen(function* () { + if (installKind === "code") yield* extract(base, target.code, directory); + const client = + installKind === "ui" + ? directory + : path.join(directory, serverRoot, "apps/server/dist/client"); + yield* extract(base, target.assets, client); + yield* extract(base, target.ui, client); + if (!(yield* fs.exists(path.join(client, "index.html")))) + return yield* fail("The update has no completed UI build."); + if (installKind === "code") { + for (const file of [ + "apps/desktop/dist-electron/main.cjs", + "apps/desktop/dist-electron/preload.cjs", + "apps/desktop/dist-electron/compileCache.cjs", + path.join(serverRoot, "apps/server/dist/bin.mjs"), + ]) { + if (!(yield* fs.exists(path.join(directory, file)))) + return yield* fail("The update is incomplete."); + } + const probe = yield* spawner.spawn( + ChildProcess.make( + process.execPath, + [path.join(directory, serverRoot, "apps/server/dist/bin.mjs"), "--version"], + { + env: { ELECTRON_RUN_AS_NODE: "1", T3CODE_VERSION_OVERRIDE: target.version }, + extendEnv: true, + stdout: "ignore", + stderr: "ignore", + }, + ), + ); + if (Number(yield* probe.exitCode) !== 0) + return yield* fail("The downloaded server could not start."); + } + yield* fs.readDirectory(releasesDir).pipe( + Effect.flatMap((entries) => + Effect.forEach(entries, (name) => + name.endsWith(".tar.gz") && + name !== target.code.file && + name !== target.ui.file && + name !== target.assets.file + ? fs.remove(path.join(releasesDir, name), { force: true }) + : Effect.void, + ), + ), + Effect.ignore, + ); + yield* Ref.set(prepared, { + version: target.version, + directory, + runtime: target.runtime, + shell: target.shell, + kind: installKind, + }); + return installKind; + }).pipe( + Effect.onError(() => + fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + }).pipe(Effect.scoped, Effect.mapError(fail)); + + const installUi = (version: string) => + Effect.gen(function* () { + const candidate = yield* Ref.get(prepared); + if (candidate?.version !== version || candidate.kind !== "ui") return false; + yield* ui.apply(candidate.directory, version); + yield* Ref.set(prepared, null); + yield* fs.remove(candidate.directory, { recursive: true, force: true }).pipe(Effect.ignore); + return true; + }).pipe(Effect.mapError(fail)); + + const activateCode = (version: string) => + Effect.gen(function* () { + const candidate = yield* Ref.get(prepared); + if (candidate?.version !== version || candidate.kind !== "code") return false; + const pointer = path.join(releasesDir, "current.json"); + yield* Effect.uninterruptible( + Effect.gen(function* () { + yield* fs.writeFileString( + `${pointer}.tmp`, + yield* encodePointer({ + version, + directory: path.basename(candidate.directory), + shell: candidate.shell, + runtime: candidate.runtime, + }), + ); + yield* fs.rename(`${pointer}.tmp`, pointer); + yield* Ref.set(prepared, null); + }), + ); + yield* fs.readDirectory(releasesDir).pipe( + Effect.flatMap((entries) => + Effect.forEach(entries, (name) => { + const directory = path.join(releasesDir, name); + return name.startsWith("release-") && + directory !== candidate.directory && + directory !== environment.appRoot + ? fs.remove(directory, { recursive: true, force: true }) + : Effect.void; + }), + ), + Effect.ignore, + ); + return true; + }).pipe(Effect.mapError(fail)); + + return DesktopForkUpdates.of({ check, kind, prepare, installUi, activateCode }); +}); + +export const layer = Layer.effect(DesktopForkUpdates, make); diff --git a/apps/desktop/src/updates/DesktopLocalUi.test.ts b/apps/desktop/src/updates/DesktopLocalUi.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..763c7ebf6f8cefcf2d66574d1450d6a00b287eba --- /dev/null +++ b/apps/desktop/src/updates/DesktopLocalUi.test.ts @@ -0,0 +1,305 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import { beforeEach, vi } from "vite-plus/test"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as DesktopLocalUi from "./DesktopLocalUi.ts"; + +const { handle } = vi.hoisted(() => ({ handle: vi.fn() })); +vi.mock("electron", () => ({ + net: { fetch: vi.fn() }, + protocol: { handle, unhandle: vi.fn() }, +})); + +const withUi = ( + test: ( + source: string, + ) => Effect.Effect, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const source = `${home}/dev/t3code/apps/web/dist`; + const bundled = `${home}/bundle/apps/server/dist/client`; + yield* fs.makeDirectory(source, { recursive: true }); + yield* fs.makeDirectory(bundled, { recursive: true }); + yield* fs.writeFileString(`${bundled}/index.html`, "bundled"); + + return yield* test(source).pipe( + Effect.provide( + DesktopEnvironment.layer({ + dirname: `${home}/bundle/apps/desktop/dist-electron`, + homeDirectory: home, + platform: "darwin", + processArch: "arm64", + appVersion: "0.0.45", + appPath: `${home}/bundle`, + isPackaged: true, + resourcesPath: `${home}/resources`, + runningUnderArm64Translation: false, + }).pipe(Layer.provideMerge(DesktopConfig.layerTest({ T3CODE_HOME: home }))), + ), + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +const writeBuild = (source: string, version: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${source}/.vite`, { recursive: true }); + yield* fs.writeFileString( + `${source}/index.html`, + `${version}`, + ); + yield* fs.writeFileString(`${source}/app-${version}.js`, `export default "${version}";`); + yield* fs.writeFileString(`${source}/lazy-${version}.js`, `export default "lazy-${version}";`); + yield* fs.writeFileString( + `${source}/.vite/manifest.json`, + JSON.stringify({ + app: { file: `app-${version}.js` }, + lazy: { file: `lazy-${version}.js` }, + }), + ); + }); + +describe("DesktopLocalUi", () => { + beforeEach(() => handle.mockReset()); + + it.effect( + "uses the bundled UI after a full-app update instead of a snapshot from the previous version", + () => + withUi((source) => + Effect.gen(function* () { + const ui = yield* DesktopLocalUi.make; + yield* writeBuild(source, "local"); + yield* ui.apply(source, "local"); + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const updated = yield* DesktopLocalUi.make.pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, { + ...environment, + appVersion: "0.0.2000000000", + }), + ); + assert.deepEqual(yield* updated.assetDirectories, [environment.clientAssetsDir]); + const restarted = yield* DesktopLocalUi.make; + assert.deepEqual(yield* restarted.assetDirectories, yield* ui.assetDirectories); + }), + ), + ); + + it.effect( + "switches the stable renderer origin and retains assets requested by the old page", + () => + withUi((source) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const ui = yield* DesktopLocalUi.make; + let request: ((request: Request) => Promise) | undefined; + handle.mockImplementation((_scheme, handler) => { + request = handler; + }); + const protocol = yield* ElectronProtocol.make; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + clerkFrontendApiHostname: undefined, + assetDirectories: ui.assetDirectories, + }); + const read = (pathname: string) => + Effect.promise(async () => { + const response = await request!(new Request(`t3code://app${pathname}`)); + return { status: response.status, text: await response.text() }; + }); + + assert.equal((yield* read("/settings")).text, "bundled"); + yield* writeBuild(source, "one"); + yield* ui.apply(source, "local"); + const [first] = yield* ui.assetDirectories; + yield* fs.remove(source, { recursive: true }); + assert.include((yield* read("/thread/active")).text, "one"); + assert.equal((yield* read("/app-one.js")).text, 'export default "one";'); + + yield* writeBuild(source, "two"); + yield* ui.apply(source, "local"); + assert.include((yield* read("/thread/active")).text, "two"); + assert.equal((yield* read("/lazy-one.js")).text, 'export default "lazy-one";'); + + yield* writeBuild(source, "three"); + yield* ui.apply(source, "local"); + assert.isFalse(yield* fs.exists(first)); + const resumed = yield* DesktopLocalUi.make; + assert.deepEqual((yield* resumed.assetDirectories)[0], (yield* ui.assetDirectories)[0]); + + yield* ui.reset; + assert.equal((yield* read("/thread/active")).text, "bundled"); + const restored = yield* DesktopLocalUi.make; + assert.equal((yield* restored.assetDirectories)[0], (yield* ui.assetDirectories)[0]); + }), + ), + ); + + it.effect("rejects a partial build without switching away from the current UI", () => + withUi((source) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const ui = yield* DesktopLocalUi.make; + yield* writeBuild(source, "one"); + yield* ui.apply(source, "local"); + const current = yield* ui.assetDirectories; + yield* writeBuild(source, "two"); + yield* fs.remove(`${source}/lazy-two.js`); + + const error = yield* ui.apply(source, "local").pipe(Effect.flip); + assert.include(error.message, "incomplete"); + assert.deepEqual(yield* ui.assetDirectories, current); + const resumed = yield* DesktopLocalUi.make; + assert.equal((yield* resumed.assetDirectories)[0], current[0]); + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const entries = yield* fs.readDirectory( + `${environment.stateDir}/local-ui/${environment.appVersion}`, + ); + assert.equal(entries.filter((name) => name.startsWith("build-")).length, 1); + }), + ), + ); + + it.effect("leaves the current UI active when saving the selection fails", () => + withUi((source) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const fail = yield* Ref.make(false); + const ui = yield* DesktopLocalUi.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + rename: (from, to) => + Effect.gen(function* () { + if (yield* Ref.get(fail)) + return yield* PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + pathOrDescriptor: to, + }); + yield* fs.rename(from, to); + }), + }), + ); + yield* writeBuild(source, "one"); + yield* ui.apply(source, "local"); + const current = yield* ui.assetDirectories; + yield* writeBuild(source, "two"); + yield* Ref.set(fail, true); + yield* ui.apply(source, "local").pipe(Effect.flip); + assert.deepEqual(yield* ui.assetDirectories, current); + const resumed = yield* DesktopLocalUi.make; + assert.equal((yield* resumed.assetDirectories)[0], current[0]); + }), + ), + ); + + it.effect("rejects an empty application script", () => + withUi((source) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const ui = yield* DesktopLocalUi.make; + const current = yield* ui.assetDirectories; + yield* writeBuild(source, "one"); + yield* fs.writeFileString(`${source}/app-one.js`, ""); + assert.include((yield* ui.apply(source, "local").pipe(Effect.flip)).message, "incomplete"); + assert.deepEqual(yield* ui.assetDirectories, current); + }), + ), + ); + + it.effect("rejects files changed while copying a build", () => + withUi((source) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const ui = yield* DesktopLocalUi.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + copy: (from, to) => + Effect.gen(function* () { + yield* fs.writeFileString(`${source}/app-one.js`, 'export default "partial";'); + yield* fs.copy(from, to); + }), + }), + ); + const current = yield* ui.assetDirectories; + yield* writeBuild(source, "one"); + assert.include((yield* ui.apply(source, "local").pipe(Effect.flip)).message, "incomplete"); + assert.deepEqual(yield* ui.assetDirectories, current); + }), + ), + ); + + it.effect("starts with bundled assets when the saved selection cannot be read", () => + withUi((source) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const ui = yield* DesktopLocalUi.make; + yield* writeBuild(source, "one"); + yield* ui.apply(source, "local"); + const resumed = yield* DesktopLocalUi.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + readFileString: (file, options) => + file.endsWith("/current") + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: file, + }), + ) + : fs.readFileString(file, options), + }), + ); + assert.deepEqual(yield* resumed.assetDirectories, [environment.clientAssetsDir]); + yield* resumed.reset; + }), + ), + ); + + it.effect("keeps the current UI available while staging and after an interrupted update", () => + withUi((source) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const started = yield* Deferred.make(); + const finish = yield* Deferred.make(); + const ui = yield* DesktopLocalUi.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + copy: (from, to) => + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(finish); + yield* fs.copy(from, to); + }), + }), + ); + yield* writeBuild(source, "one"); + const current = yield* ui.assetDirectories; + const update = yield* ui.apply(source, "local").pipe(Effect.forkChild); + yield* Deferred.await(started); + assert.deepEqual(yield* ui.assetDirectories, current); + yield* Fiber.interrupt(update); + assert.deepEqual(yield* ui.assetDirectories, current); + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const entries = yield* fs.readDirectory( + `${environment.stateDir}/local-ui/${environment.appVersion}`, + ); + assert.isEmpty(entries); + }), + ), + ); +}); diff --git a/apps/desktop/src/updates/DesktopLocalUi.ts b/apps/desktop/src/updates/DesktopLocalUi.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ff6c9cb0284a5cdbba22a20b6ae7531c9140f71 --- /dev/null +++ b/apps/desktop/src/updates/DesktopLocalUi.ts @@ -0,0 +1,236 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as NodeBuffer from "node:buffer"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +const UiBuildManifest = Schema.fromJsonString( + Schema.Record( + Schema.String, + Schema.Struct({ + file: Schema.String, + css: Schema.optionalKey(Schema.Array(Schema.String)), + assets: Schema.optionalKey(Schema.Array(Schema.String)), + }), + ), +); + +export class DesktopLocalUiError extends Schema.TaggedError()( + "DesktopLocalUiError", + { message: Schema.String, cause: Schema.Defect() }, +) {} + +const isLocalUiError = Schema.is(DesktopLocalUiError); +const decodeUiBuildManifest = Schema.decodeEffect(UiBuildManifest); +const UiPointer = Schema.fromJsonString( + Schema.Struct({ name: Schema.String, version: Schema.String }), +); +const decodeUiPointer = Schema.decodeEffect(UiPointer); +const encodeUiPointer = Schema.encodeEffect(UiPointer); +const incompleteBuildMessage = "The UI build is incomplete. Finish the build and try again."; + +export class DesktopLocalUi extends Context.Service< + DesktopLocalUi, + { + readonly assetDirectories: Effect.Effect; + readonly currentVersion: Effect.Effect; + readonly apply: (source: string, version: string) => Effect.Effect; + readonly reset: Effect.Effect; + } +>()("@t3tools/desktop/updates/DesktopLocalUi") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const path = environment.path; + const directory = path.join(environment.stateDir, "local-ui", environment.appVersion); + const pointer = path.join(directory, "current"); + const bundled = environment.clientAssetsDir; + const currentVersion = yield* Ref.make(environment.appVersion); + const initial = yield* Effect.gen(function* () { + if (yield* fs.exists(pointer)) { + const { name, version } = yield* decodeUiPointer(yield* fs.readFileString(pointer)); + if (name.startsWith("build-") && path.basename(name) === name) { + const snapshot = path.join(directory, name); + if (yield* fs.exists(path.join(snapshot, "index.html"))) { + yield* Ref.set(currentVersion, version); + return snapshot; + } + } + } + return bundled; + }).pipe(Effect.catch((error) => Effect.logError(error).pipe(Effect.as(bundled)))); + + const active = yield* Ref.make( + initial === bundled ? [bundled] : [initial, bundled], + ); + const mutex = yield* Semaphore.make(1); + + const apply = (source: string, version: string) => + Effect.gen(function* () { + if (environment.isDevelopment) { + return yield* new DesktopLocalUiError({ + message: "The development UI updates automatically.", + cause: "Development renderer", + }); + } + if (!(yield* fs.exists(path.join(source, "index.html")))) { + return yield* new DesktopLocalUiError({ + message: "The downloaded update has no completed UI build.", + cause: source, + }); + } + + yield* fs.makeDirectory(directory, { recursive: true }); + const snapshot = yield* fs.makeTempDirectory({ directory, prefix: "build-" }); + + yield* Effect.gen(function* () { + yield* Effect.gen(function* () { + const html = yield* fs.readFileString(path.join(source, "index.html")); + const manifestJson = yield* fs.readFileString( + path.join(source, ".vite", "manifest.json"), + ); + const manifest = yield* decodeUiBuildManifest(manifestJson); + const references = [...html.matchAll(/<(?:script|link)\b[^>]*(?:src|href)="([^"]+)"/g)]; + if (!references.some(([tag]) => tag.startsWith(" [ + entry.file, + ...(entry.css ?? []), + ...(entry.assets ?? []), + ]); + const stagedFiles: Array<{ file: string; contents: NodeBuffer.Buffer }> = [ + { file: "index.html", contents: NodeBuffer.Buffer.from(html) }, + { + file: ".vite/manifest.json", + contents: NodeBuffer.Buffer.from(manifestJson), + }, + ]; + for (const reference of new Set([...files, ...references.map((match) => match[1]!)])) { + const url = yield* Effect.try({ + try: () => new URL(reference, "http://local-ui/"), + catch: (cause) => new DesktopLocalUiError({ message: incompleteBuildMessage, cause }), + }); + if (url.origin !== "http://local-ui") continue; + const file = yield* Effect.try({ + try: () => path.resolve(source, `.${decodeURIComponent(url.pathname)}`), + catch: (cause) => new DesktopLocalUiError({ message: incompleteBuildMessage, cause }), + }); + if (!file.startsWith(source + path.sep)) { + return yield* new DesktopLocalUiError({ + message: incompleteBuildMessage, + cause: reference, + }); + } + const contents = NodeBuffer.Buffer.from(yield* fs.readFile(file)); + if (contents.length === 0) { + return yield* new DesktopLocalUiError({ + message: incompleteBuildMessage, + cause: reference, + }); + } + stagedFiles.push({ file: path.relative(source, file), contents }); + } + + yield* fs.copy(source, snapshot); + for (const { file, contents } of stagedFiles) { + for (const root of [source, snapshot]) { + const copied = yield* fs.readFile(path.join(root, file)); + if (!contents.equals(copied)) { + return yield* new DesktopLocalUiError({ + message: incompleteBuildMessage, + cause: file, + }); + } + } + } + }).pipe( + Effect.mapError((cause) => + isLocalUiError(cause) + ? cause + : new DesktopLocalUiError({ message: incompleteBuildMessage, cause }), + ), + ); + + yield* Effect.uninterruptible( + Effect.gen(function* () { + yield* fs.writeFileString( + `${pointer}.tmp`, + yield* encodeUiPointer({ name: path.basename(snapshot), version }), + ); + yield* fs.rename(`${pointer}.tmp`, pointer); + const previous = yield* Ref.get(active); + yield* Ref.set(active, [snapshot, previous[0]]); + yield* Ref.set(currentVersion, version); + }), + ); + }).pipe( + Effect.onError(() => + Effect.gen(function* () { + if (!(yield* Ref.get(active)).includes(snapshot)) { + yield* fs.remove(snapshot, { recursive: true, force: true }); + } + }).pipe(Effect.ignore), + ), + ); + + const keep = yield* Ref.get(active); + yield* fs.readDirectory(directory).pipe( + Effect.flatMap((entries) => + Effect.forEach(entries, (name) => { + const candidate = path.join(directory, name); + return name.startsWith("build-") && !keep.includes(candidate) + ? fs.remove(candidate, { recursive: true, force: true }) + : Effect.void; + }), + ), + Effect.ignore, + ); + }).pipe( + Effect.mapError((cause) => + isLocalUiError(cause) + ? cause + : new DesktopLocalUiError({ + message: "Could not apply the UI build. Try again.", + cause, + }), + ), + Effect.tapError((error) => Effect.logError(error)), + mutex.withPermit, + ); + + const reset = Effect.uninterruptible( + Effect.gen(function* () { + yield* fs.remove(pointer, { force: true }); + yield* Ref.set(currentVersion, environment.appVersion); + const previous = yield* Ref.get(active); + yield* Ref.set(active, previous[0] === bundled ? [bundled] : [bundled, previous[0]]); + }), + ).pipe( + Effect.mapError( + (cause) => new DesktopLocalUiError({ message: "Could not restore the bundled UI.", cause }), + ), + Effect.tapError((error) => Effect.logError(error)), + mutex.withPermit, + ); + + return DesktopLocalUi.of({ + assetDirectories: Ref.get(active), + currentVersion: Ref.get(currentVersion), + apply, + reset, + }); +}); + +export const layer = Layer.effect(DesktopLocalUi, make); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 1198429a17007614bb593fe8d42cc9a85a16c0f2..55ff5741d2e3c4e368db15dc773dd9755c4f0545 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -84,6 +84,23 @@ function terminalReports(reports: DesktopUpdateStatusReport[]): DesktopUpdateSta } describe("DesktopRemoteUpdates", () => { + it.effect( + "leaves a UI-only update pending for the local renderer without restarting its backend", + () => { + const harness = makeHarness({ uiOnly: true }); + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("ui-only")); + yield* settle; + assert.equal(terminalReports(reports).at(-1)?.outcome, "up-to-date"); + assert.equal((yield* (yield* DesktopUpdates.DesktopUpdates).getState).installKind, "ui"); + assert.deepEqual(harness.installSteps, []); + assert.deepEqual(harness.uiInstalls, []); + }), + ); + }, + ); + it.effect("drives check, download, and install with no local confirmation", () => { const harness = makeHarness(); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index 31e3c5af5260532b8b4eb244edeae6c65daf271b..8f3f1ed3295ad06f739aa55692cd0a0c84fbdab4 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -228,6 +228,10 @@ export const listen: Effect.Effect< ); return false; case "install": { + if (state.installKind === "ui") { + yield* publishReport(state, { outcome: "up-to-date" }, request.requestId); + return true; + } // The download event fires before its action releases the // updater reservation. Wait until the prepared install can // be committed by the client in a separate RPC. diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 26ad0f1c4e705d82e65d2b02ab1a4eb7921a4700..5618354eb9f35043b7650bbb54a9ac411ccb89cc 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -18,9 +18,165 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; -import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; +import { flushCallbacks, makeHarness, forkReleaseFixture } from "./updatesTestHarness.ts"; describe("DesktopUpdates", () => { + it.effect( + "discovers new private releases and uses them for either stored channel preference", + () => { + let latest = "0.0.2000000000"; + const harness = makeHarness({ + env: { T3CODE_DESKTOP_MOCK_UPDATES: "false" }, + appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n", + releaseManifest: () => forkReleaseFixture(latest), + }); + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("test"); + assert.equal(harness.checkCount(), 0); + assert.equal(harness.downloadCount(), 0); + assert.equal((yield* updates.getState).status, "downloaded"); + latest = "0.0.2000000001"; + yield* updates.setChannel("nightly"); + yield* updates.check("test"); + const state = yield* updates.getState; + assert.equal(state.channel, "nightly"); + assert.equal(state.status, "downloaded"); + assert.equal(state.availableVersion, latest); + }), + ).pipe(Effect.provide(harness.layer)); + }, + ); + + it.effect("applies a compatible downloaded UI without stopping a backend or quitting", () => { + const harness = makeHarness({ uiOnly: true }); + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("test"); + const downloaded = yield* updates.download; + assert.equal(downloaded.state.installKind, "ui"); + assert.equal(downloaded.state.status, "downloaded"); + const result = yield* updates.install; + assert.equal(result.completed, true); + assert.equal(result.state.currentVersion, "1.2.3"); + assert.equal(result.state.uiVersion, "1.2.4"); + assert.equal(result.state.status, "up-to-date"); + assert.deepEqual(harness.uiInstalls, ["1.2.4"]); + assert.equal(harness.quitAndInstalls(), 0); + assert.isEmpty(harness.installSteps); + assert.isEmpty([...harness.updateRestartMarkers]); + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + assert.equal((yield* updates.getState).status, "up-to-date"); + }), + ).pipe(Effect.provide(harness.layer)); + }); + + it.effect("downloads code automatically but activates it only after a confirmed install", () => { + let stopped = 0; + const harness = makeHarness({ + appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n", + releaseManifest: () => forkReleaseFixture("0.0.2000000000"), + stopBackend: Effect.sync(() => { + stopped += 1; + }), + }); + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + const checked = yield* updates.check("test"); + assert.equal(checked.state.status, "downloaded"); + assert.equal(checked.state.installKind, "app"); + assert.deepEqual(harness.installSteps, []); + assert.equal(harness.downloadCount(), 0); + const installed = yield* updates.install; + assert.equal(installed.accepted, true); + assert.deepEqual(harness.installSteps, ["activateCode", "relaunch", "quit"]); + assert.equal(stopped, 1); + assert.equal(harness.quitAndInstalls(), 0); + }), + ).pipe(Effect.provide(harness.layer)); + }); + + it.effect("retains a confirmed full restart for incompatible downloaded releases", () => { + const harness = makeHarness({ uiOnly: false }); + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + assert.equal((yield* updates.download).state.installKind, "app"); + const result = yield* updates.install; + assert.equal(result.accepted, true); + assert.equal(harness.quitAndInstalls(), 1); + assert.isEmpty(harness.uiInstalls); + }), + ).pipe(Effect.provide(harness.layer)); + }); + + it.effect("does not offer an already-applied UI release after relaunch", () => { + const harness = makeHarness({ uiVersion: "1.2.4" }); + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + assert.equal((yield* updates.getState).status, "up-to-date"); + assert.equal((yield* updates.getState).currentVersion, "1.2.3"); + }), + ).pipe(Effect.provide(harness.layer)); + }); + + it.effect("does not offer an older private release", () => { + const harness = makeHarness({ + env: { T3CODE_DESKTOP_MOCK_UPDATES: "false" }, + appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n", + releaseManifest: () => forkReleaseFixture("0.0.2000000000"), + uiVersion: "0.0.2000000001", + }); + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("test"); + assert.equal(harness.checkCount(), 0); + assert.equal((yield* updates.getState).status, "up-to-date"); + }), + ).pipe(Effect.provide(harness.layer)); + }); + + it.effect("keeps a platform up to date when only another platform has a newer release", () => { + const release = forkReleaseFixture("0.0.2000000001"); + const target = release.desktop["darwin-x64"]!; + const harness = makeHarness({ + env: { T3CODE_DESKTOP_MOCK_UPDATES: "false" }, + appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n", + releaseManifest: () => ({ + ...release, + desktop: { "darwin-x64": { ...target, version: "0.0.2000000000" } }, + }), + uiVersion: "0.0.2000000000", + }); + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("test"); + const state = yield* updates.getState; + assert.equal(state.status, "up-to-date"); + assert.equal(state.availableVersion, null); + assert.equal(harness.downloadCount(), 0); + }), + ).pipe(Effect.provide(harness.layer)); + }); + it("preserves complete causes for update poller and event failures", () => { const cause = Cause.combine( Cause.fail(new Error("updater failed")), diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 77069d3ba3fe76a704ef2409b9c545d1207a16ea..79d1d90f1058a5c0aa9fb4415c90bede1ab78982 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -7,7 +7,9 @@ import { type DesktopUpdateCheckResult, type DesktopUpdateState, } from "@t3tools/contracts"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { errorTag } from "@t3tools/shared/observability"; +import { ForkReleaseManifest } from "@t3tools/shared/forkUpdateDirectory"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -30,6 +32,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; +import * as DesktopForkUpdates from "./DesktopForkUpdates.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as DesktopLocalUi from "./DesktopLocalUi.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -350,20 +355,36 @@ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const fork = yield* DesktopForkUpdates.DesktopForkUpdates; + const electronApp = yield* ElectronApp.ElectronApp; + const forkRelease = yield* Ref.make<{ + base: URL; + release: typeof ForkReleaseManifest.Type; + kind: "ui" | "code" | "shell"; + } | null>(null); const appUpdateYmlConfigRef = yield* Ref.make>(Option.none()); + const forkUpdateDirectory = Ref.get(appUpdateYmlConfigRef).pipe( + Effect.map((config) => { + if (Option.isNone(config) || config.value.provider !== "generic") return; + const url = URL.parse(config.value.url ?? ""); + return url?.searchParams.has("ls") ? url : undefined; + }), + ); + const localUi = yield* DesktopLocalUi.DesktopLocalUi; const activeUpdateActionRef = yield* Ref.make>(Option.none()); const finishedUpdateActions = yield* PubSub.unbounded(); const updaterConfiguredRef = yield* Ref.make(false); const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); const notAppliedVersionRef = yield* Ref.make(Option.none()); - const updateStateRef = yield* Ref.make( - createInitialDesktopUpdateState( + const updateStateRef = yield* Ref.make({ + ...createInitialDesktopUpdateState( environment.appVersion, environment.runtimeInfo, environment.defaultDesktopSettings.updateChannel, ), - ); + uiVersion: yield* localUi.currentVersion, + }); const stateChanges = yield* PubSub.sliding(16); // Makes ref writes + publishes atomic against subscribe, so a snapshot @@ -377,7 +398,11 @@ export const make = Effect.gen(function* () { const setState = (state: DesktopUpdateState): Effect.Effect => stateMutex .withPermits(1)( - Ref.set(updateStateRef, state).pipe(Effect.andThen(PubSub.publish(stateChanges, state))), + Effect.gen(function* () { + const next = { ...state, uiVersion: yield* localUi.currentVersion }; + yield* Ref.set(updateStateRef, next); + yield* PubSub.publish(stateChanges, next); + }), ) .pipe(Effect.andThen(emitState)); @@ -498,9 +523,50 @@ export const make = Effect.gen(function* () { yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); yield* logUpdaterInfo("checking for updates", { reason }); - return yield* electronUpdater.checkForUpdates.pipe( - Effect.as(true), + return yield* Effect.gen(function* () { + const directory = yield* forkUpdateDirectory; + if (directory) { + directory.searchParams.delete("ls"); + const release = yield* fork.check(directory); + const kind = yield* fork.kind(release); + const target = release.desktop[`${environment.platform}-${environment.processArch}`]!; + const effectiveVersion = state.uiVersion ?? state.currentVersion; + if ( + /^0\.0\.[0-9]+$/.test(effectiveVersion) && + compareSemverVersions(target.version, effectiveVersion) <= 0 + ) { + yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); + return true; + } + yield* Ref.set(forkRelease, { base: directory, release, kind }); + if (kind !== "shell") { + yield* setState({ + ...reduceDesktopUpdateStateOnUpdateAvailable(state, target.version, checkedAt), + installKind: kind === "ui" ? "ui" : "app", + }); + return true; + } + yield* electronUpdater.setFeedURL({ provider: "generic", url: directory.href }); + yield* electronUpdater.setChannel(target.app.version); + yield* electronUpdater.setAllowDowngrade(false); + } + yield* electronUpdater.checkForUpdates; + return true; + }).pipe( + Effect.mapError((error) => + error._tag === "ElectronUpdaterCheckForUpdatesError" || + error._tag === "DesktopForkUpdateError" + ? error + : new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({ + channel: state.channel, + cause: error, + }), + ), Effect.catchTags({ + DesktopForkUpdateError: (error) => + updateState((current) => + reduceDesktopUpdateStateOnCheckFailure(current, error.message, checkedAt), + ).pipe(Effect.as(true)), ElectronUpdaterCheckForUpdatesError: Effect.fn( "desktop.updates.handleCheckForUpdatesFailure", )(function* (error) { @@ -543,10 +609,27 @@ export const make = Effect.gen(function* () { isArm64HostRunningIntelBuild(environment.runtimeInfo), ); yield* logUpdaterInfo("downloading update"); - yield* electronUpdater.downloadUpdate; + const candidate = yield* Ref.get(forkRelease); + const version = state.availableVersion; + let uiOnly = false; + if (candidate !== null && candidate.kind !== "shell") { + uiOnly = (yield* fork.prepare(candidate.base, candidate.release)) === "ui"; + } else { + yield* electronUpdater.downloadUpdate; + } + yield* updateState((current) => ({ + ...(version === null + ? current + : reduceDesktopUpdateStateOnDownloadComplete(current, version)), + installKind: uiOnly ? "ui" : "app", + })); return { accepted: true, completed: true }; }).pipe( Effect.catchTags({ + DesktopForkUpdateError: (error) => + updateState((current) => + reduceDesktopUpdateStateOnDownloadFailure(current, error.message), + ).pipe(Effect.as({ accepted: true, completed: false })), ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")( function* (error) { yield* updateState((current) => @@ -744,6 +827,37 @@ export const make = Effect.gen(function* () { return { accepted: false, completed: false, failed: false }; } + const installing = yield* Ref.get(updateStateRef); + if ( + expectedVersion === undefined && + installing.installKind === "ui" && + installing.downloadedVersion !== null + ) { + return yield* fork.installUi(installing.downloadedVersion).pipe( + Effect.flatMap((applied) => + applied + ? updateState((current) => ({ + ...current, + status: "up-to-date", + availableVersion: null, + downloadedVersion: null, + installKind: "app", + downloadPercent: null, + message: null, + errorContext: null, + canRetry: false, + })).pipe(Effect.as({ accepted: true, completed: true, failed: false })) + : Effect.succeed({ accepted: false, completed: false, failed: false }), + ), + Effect.catch((error) => + updateState((current) => + reduceDesktopUpdateStateOnInstallFailure(current, error.message), + ).pipe(Effect.as({ accepted: true, completed: false, failed: true })), + ), + Effect.ensuring(finishUpdateAction("install")), + ); + } + yield* Ref.set(desktopState.quitting, true); return yield* Effect.gen(function* () { @@ -763,13 +877,26 @@ export const make = Effect.gen(function* () { (instance) => instance.stop({ timeout: Duration.seconds(5) }), { concurrency: "unbounded" }, ); - yield* electronUpdater.quitAndInstall({ - isSilent: true, - isForceRunAfter: true, - }); + const candidate = yield* Ref.get(forkRelease); + if (candidate?.kind === "code" && downloadedVersion !== null) { + if (!(yield* fork.activateCode(downloadedVersion))) { + return yield* new DesktopForkUpdates.DesktopForkUpdateError({ + message: "The update is no longer prepared. Download it again.", + cause: downloadedVersion, + }); + } + yield* electronApp.relaunch({}); + yield* electronApp.quit; + } else { + yield* electronUpdater.quitAndInstall({ isSilent: true, isForceRunAfter: true }); + } return { accepted: true, completed: false, failed: false }; }).pipe( Effect.catchTags({ + DesktopForkUpdateError: (error) => + recoverFailedInstall(error.message).pipe( + Effect.as({ accepted: true, completed: false, failed: true }), + ), ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( function* (error) { yield* recoverFailedInstall(error.message); @@ -827,6 +954,11 @@ export const make = Effect.gen(function* () { const startUpdatePollers: Effect.Effect = Effect.gen(function* () { yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( Effect.andThen(checkForUpdates("startup")), + Effect.andThen( + Effect.gen(function* () { + if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate; + }), + ), Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { return Effect.void; @@ -842,6 +974,11 @@ export const make = Effect.gen(function* () { ); yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe( Effect.andThen(checkForUpdates("poll")), + Effect.andThen( + Effect.gen(function* () { + if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate; + }), + ), Effect.forever, Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { @@ -865,7 +1002,10 @@ export const make = Effect.gen(function* () { Effect.flatMap( Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) { const state = yield* Ref.get(updateStateRef); - if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) { + if ( + !(yield* forkUpdateDirectory) && + resolveDefaultDesktopUpdateChannel(info.version) !== state.channel + ) { yield* logUpdaterInfo("ignoring update that does not match selected channel", { version: info.version, channel: state.channel, @@ -877,6 +1017,14 @@ export const make = Effect.gen(function* () { } const checkedAt = yield* currentIsoTimestamp; + if ( + state.uiVersion && + state.uiVersion !== state.currentVersion && + compareSemverVersions(info.version, state.uiVersion) <= 0 + ) { + yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); + return; + } const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( info.releaseNotes, info.version, @@ -1002,6 +1150,12 @@ export const make = Effect.gen(function* () { Effect.flatMap( Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) { const state = yield* Ref.get(updateStateRef); + if ( + state.uiVersion && + state.uiVersion !== state.currentVersion && + compareSemverVersions(info.version, state.uiVersion) <= 0 + ) + return; yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); yield* logUpdaterInfo("update downloaded", { version: info.version }); }), @@ -1157,6 +1311,7 @@ export const make = Effect.gen(function* () { }; } const checked = yield* checkForUpdates(reason); + if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate; return { checked, state: yield* Ref.get(updateStateRef), diff --git a/apps/desktop/src/updates/forkRuntime.test.ts b/apps/desktop/src/updates/forkRuntime.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2e44fe2409a4b93216834030734c28b49b8f9e70 --- /dev/null +++ b/apps/desktop/src/updates/forkRuntime.test.ts @@ -0,0 +1,86 @@ +// @effect-diagnostics nodeBuiltinImport:off - The fixture verifies synchronous bootstrap selection on disk. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, beforeEach, expect, it } from "vite-plus/test"; +import { readActiveForkRuntime } from "./forkRuntime.ts"; + +let home: string; +let bundled: string; +let releases: string; +const shell = "a".repeat(64); +const pointer = { + directory: "release-abc123", + version: "0.0.2000000000", + shell, + runtime: "b".repeat(64), +}; + +beforeEach(() => { + home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "knife-bootstrap-")); + bundled = NodePath.join(home, "bundle"); + releases = NodePath.join(home, "fork-releases"); + NodeFS.mkdirSync(bundled); + NodeFS.mkdirSync(releases); + NodeFS.writeFileSync( + NodePath.join(bundled, "knife-runtime.json"), + JSON.stringify({ shell, version: "0.0.1999999999" }), + ); + for (const file of [ + "apps/desktop/dist-electron/main.cjs", + "apps/desktop/dist-electron/compileCache.cjs", + "apps/desktop/dist-electron/preload.cjs", + "apps/server/dist/bin.mjs", + "apps/server/dist/client/index.html", + ]) { + const target = NodePath.join(releases, pointer.directory, file); + NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true }); + NodeFS.writeFileSync(target, "fixture"); + } + NodeFS.writeFileSync(NodePath.join(releases, "current.json"), JSON.stringify(pointer)); +}); +afterEach(() => NodeFS.rmSync(home, { recursive: true, force: true })); + +it("selects the fully staged runtime matching the signed shell", () => { + expect(readActiveForkRuntime(home, bundled)).toEqual({ + ...pointer, + directory: NodePath.join(releases, pointer.directory), + }); +}); + +it.each([ + { ...pointer, shell: "c".repeat(64) }, + { ...pointer, directory: "../outside" }, + { ...pointer, runtime: "malformed" }, +])("ignores an incompatible or malformed active pointer", (invalid) => { + NodeFS.writeFileSync(NodePath.join(releases, "current.json"), JSON.stringify(invalid)); + expect(readActiveForkRuntime(home, bundled)).toBeUndefined(); +}); + +it.each([ + "apps/server/dist/client/index.html", + "apps/desktop/dist-electron/compileCache.cjs", + "apps/desktop/dist-electron/preload.cjs", +])("keeps the bundled app when the staged runtime is missing %s", (file) => { + NodeFS.unlinkSync(NodePath.join(releases, pointer.directory, file)); + expect(readActiveForkRuntime(home, bundled)).toBeUndefined(); +}); + +it("prefers a newer bundled release even when an older pointer uses the same shell", () => { + NodeFS.writeFileSync( + NodePath.join(bundled, "knife-runtime.json"), + JSON.stringify({ shell, version: "0.0.2000000001" }), + ); + expect(readActiveForkRuntime(home, bundled)).toBeUndefined(); +}); + +it("requires the separate Windows server tree before selecting its runtime", () => { + expect(readActiveForkRuntime(home, bundled, "win32")).toBeUndefined(); + const directory = NodePath.join(releases, pointer.directory); + NodeFS.mkdirSync(NodePath.join(directory, "server/apps"), { recursive: true }); + NodeFS.renameSync( + NodePath.join(directory, "apps/server"), + NodePath.join(directory, "server/apps/server"), + ); + expect(readActiveForkRuntime(home, bundled, "win32")?.version).toBe(pointer.version); +}); diff --git a/apps/desktop/src/updates/forkRuntime.ts b/apps/desktop/src/updates/forkRuntime.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0e4536a0244bef0b22f5b3c58426da6c2e6c5de --- /dev/null +++ b/apps/desktop/src/updates/forkRuntime.ts @@ -0,0 +1,75 @@ +// @effect-diagnostics nodeBuiltinImport:off - Bootstrap metadata is read synchronously before the main bundle loads. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +export interface ActiveForkRuntime { + readonly directory: string; + readonly version: string; + readonly shell: string; + readonly runtime: string; +} + +export function readActiveForkRuntime( + stateDir: string, + bundledRoot: string, + // oxlint-disable-next-line t3code/no-global-process-runtime -- The synchronous bootstrap runs before Effect services exist. + platform: NodeJS.Platform = process.platform, +): ActiveForkRuntime | undefined { + try { + const baseline: unknown = JSON.parse( + NodeFS.readFileSync(NodePath.join(bundledRoot, "knife-runtime.json"), "utf8"), + ); + const active: unknown = JSON.parse( + NodeFS.readFileSync(NodePath.join(stateDir, "fork-releases", "current.json"), "utf8"), + ); + if ( + typeof baseline !== "object" || + baseline === null || + !("shell" in baseline) || + typeof active !== "object" || + active === null || + !("directory" in active) || + typeof active.directory !== "string" || + !("version" in active) || + typeof active.version !== "string" || + !("shell" in active) || + active.shell !== baseline.shell || + typeof active.shell !== "string" || + !("runtime" in active) || + typeof active.runtime !== "string" || + !/^[a-f0-9]{64}$/.test(active.runtime) || + !/^release-[A-Za-z0-9-]+$/.test(active.directory) + ) + return; + + const bundledVersion = + "version" in baseline && typeof baseline.version === "string" ? baseline.version : undefined; + if ( + bundledVersion !== undefined && + /^0\.0\.[0-9]+$/.test(bundledVersion) && + /^0\.0\.[0-9]+$/.test(active.version) && + BigInt(active.version.slice(4)) < BigInt(bundledVersion.slice(4)) + ) + return; + + const directory = NodePath.join(stateDir, "fork-releases", active.directory); + const serverRoot = platform === "win32" ? "server" : "."; + for (const file of [ + "apps/desktop/dist-electron/main.cjs", + "apps/desktop/dist-electron/compileCache.cjs", + "apps/desktop/dist-electron/preload.cjs", + NodePath.join(serverRoot, "apps/server/dist/bin.mjs"), + NodePath.join(serverRoot, "apps/server/dist/client/index.html"), + ]) { + if (!NodeFS.statSync(NodePath.join(directory, file)).isFile()) return; + } + return { directory, version: active.version, shell: active.shell, runtime: active.runtime }; + } catch { + return; + } +} + +export function activeForkRuntime(): ActiveForkRuntime | undefined { + const raw = process.env.T3CODE_DESKTOP_PAYLOAD; + return raw === undefined ? undefined : JSON.parse(raw); +} diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index 48fcc07acda9bc0e028b8f3da8846851dfcd4e9e..b115ec216def2a55ae268d72beedd14e1cdc231f 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -99,6 +99,7 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( ...state, status: isDownloadedVersion ? "downloaded" : "available", availableVersion: version, + installKind: isDownloadedVersion ? (state.installKind ?? "app") : "app", downloadedVersion: isDownloadedVersion ? version : null, releaseNotes: preserveReleaseNotes ? state.releaseNotes : releaseNotes, omittedReleaseCount: preserveReleaseNotes ? state.omittedReleaseCount : omittedReleaseCount, diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index c3701280ad55a89bd7aa514c4ac26c83a2c6e976..dda1752a68c000b2915a9395c726d5dab30af730 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -5,15 +5,20 @@ import * as FileSystem from "effect/FileSystem"; import * as PlatformError from "effect/PlatformError"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { HttpClient, HttpClientResponse } from "effect/http"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as DesktopForkUpdates from "./DesktopForkUpdates.ts"; +import { ForkReleaseManifest } from "@t3tools/shared/forkUpdateDirectory"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; +import * as DesktopLocalUi from "./DesktopLocalUi.ts"; /** Shared DesktopUpdates test harness: a fully stubbed updater layer whose electron-updater events are driven by hand via `emit`. Used by @@ -30,6 +35,8 @@ export interface UpdatesHarnessOptions { readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; readonly setDisableDifferentialDownload?: Effect.Effect; readonly downloadUpdate?: Effect.Effect; + readonly uiOnly?: boolean; + readonly uiVersion?: string; readonly quitAndInstall?: Effect.Effect; readonly stopBackend?: Effect.Effect; readonly startBackend?: Effect.Effect; @@ -39,6 +46,9 @@ export interface UpdatesHarnessOptions { readonly packageType?: string | undefined; /** Contents of the install record a previous run left behind. */ readonly installRecord?: string | undefined; + readonly appUpdateYml?: string; + readonly releaseManifest?: () => typeof ForkReleaseManifest.Type; + readonly forkKind?: "ui" | "code" | "shell"; } export function makeHarness(options: UpdatesHarnessOptions = {}) { @@ -48,6 +58,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { let allowDowngrade = false; let fullChangelog = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; + const channels: string[] = []; const listeners = new Map void>>(); const sentStates: DesktopUpdateState[] = []; const installSteps: string[] = []; @@ -76,7 +87,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { }), setAutoDownload: () => Effect.void, setAutoInstallOnAppQuit: () => Effect.void, - setChannel: () => Effect.void, + setChannel: (channel) => + Effect.sync(() => { + channels.push(channel); + }), setAllowPrerelease: () => Effect.void, allowDowngrade: Effect.sync(() => allowDowngrade), setAllowDowngrade: (value) => @@ -93,7 +107,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), downloadUpdate: Effect.sync(() => { downloadCount += 1; - }).pipe(Effect.andThen(options.downloadUpdate ?? Effect.void)), + }).pipe( + Effect.andThen(options.downloadUpdate ?? Effect.void), + Effect.as(["/download/update.zip"]), + ), quitAndInstall: () => Effect.sync(() => { quitAndInstallCount += 1; @@ -224,6 +241,14 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { ? (installRecords.get(path) ?? previousInstallRecord) : undefined; if (installRecord !== undefined) return Effect.succeed(installRecord); + if ( + path === "/missing/resources/app-update.yml" && + (options.appUpdateYml !== undefined || options.uiOnly) + ) + return Effect.succeed( + options.appUpdateYml ?? + "provider: generic\nurl: https://updates.example.test/fork/?ls=\n", + ); return path === "/missing/resources/package-type" && options.packageType !== undefined ? Effect.succeed(options.packageType) : Effect.fail( @@ -249,7 +274,62 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { }), }); + const uiInstalls: string[] = []; + let uiVersion = options.uiVersion ?? "1.2.3"; const layer = DesktopUpdates.layer.pipe( + Layer.provide( + Layer.succeed(DesktopLocalUi.DesktopLocalUi, { + currentVersion: Effect.sync(() => uiVersion), + assetDirectories: Effect.succeed(["/client"] as const), + apply: () => Effect.void, + reset: Effect.void, + }), + ), + Layer.provide( + Layer.succeed(DesktopForkUpdates.DesktopForkUpdates, { + check: () => Effect.sync(() => options.releaseManifest?.() ?? forkReleaseFixture("1.2.4")), + kind: () => Effect.succeed(options.forkKind ?? (options.uiOnly ? "ui" : "code")), + prepare: () => Effect.succeed(options.forkKind ?? (options.uiOnly ? "ui" : "code")), + installUi: (version) => + Effect.sync(() => { + uiInstalls.push(version); + uiVersion = version; + return true; + }), + activateCode: () => + Effect.sync(() => { + installSteps.push("activateCode"); + return true; + }), + }), + ), + Layer.provide( + Layer.succeed(ElectronApp.ElectronApp, { + ...ElectronApp.make, + relaunch: () => + Effect.sync(() => { + installSteps.push("relaunch"); + }), + quit: Effect.sync(() => { + installSteps.push("quit"); + }), + }), + ), + Layer.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify(options.releaseManifest?.() ?? forkReleaseFixture("1.2.4")), + ), + ), + ), + ), + ), + ), Layer.provide(layerFileSystem), Layer.provideMerge(layerUpdater), Layer.provideMerge(layerWindow), @@ -273,12 +353,14 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { checkCount: () => checkCount, quitAndInstalls: () => quitAndInstallCount, installSteps, + uiInstalls, updateRestartMarkers, installRecords, /** The record the test started with, until the app removes it. */ previousInstallRecord: () => previousInstallRecord, downloadCount: () => downloadCount, feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls, + channels, fullChangelog: () => fullChangelog, listenerCount: () => Array.from(listeners.values()).reduce( @@ -293,3 +375,24 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { }, }; } + +export function forkReleaseFixture(version: string): typeof ForkReleaseManifest.Type { + const artifact = { file: "fixture.tar.gz", size: 1, sha256: "a".repeat(64) }; + return { + version, + ui: artifact, + assets: artifact, + server: { ...artifact, version, runtime: "b".repeat(64) }, + desktop: { + "darwin-x64": { + version, + ui: artifact, + assets: artifact, + shell: "c".repeat(64), + runtime: "d".repeat(64), + code: artifact, + app: { ...artifact, version }, + }, + }, + }; +} diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 86869ae1fe0abed9dfb2cce577d4026cd5bb1bc1..640f45fc6061289897b4485f87fc9af2b8001c25 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -13,6 +13,7 @@ import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; const environmentInput = { @@ -106,6 +107,12 @@ const configureMenu = ( selectedAction: Deferred.Deferred, applicationMenuTemplate: Deferred.Deferred, environment: Partial = {}, + localUi: DesktopLocalUi.DesktopLocalUi["Service"] = { + assetDirectories: Effect.die("unexpected asset read"), + currentVersion: Effect.succeed("1.0.0"), + apply: () => Effect.die("unexpected local UI update"), + reset: Effect.die("unexpected local UI reset"), + }, ) => Effect.gen(function* () { const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; @@ -116,6 +123,7 @@ const configureMenu = ( Layer.provideMerge(layerElectronMenu(applicationMenuTemplate)), Layer.provideMerge(layerDesktopWindow(selectedAction)), Layer.provideMerge(layerDesktopUpdates), + Layer.provideMerge(Layer.succeed(DesktopLocalUi.DesktopLocalUi, localUi)), Layer.provideMerge(layerElectronDialog), Layer.provideMerge(layerElectronApp), Layer.provideMerge( @@ -128,6 +136,37 @@ const configureMenu = ( ); describe("DesktopApplicationMenu", () => { + it.effect.each([["Use Bundled UI", "reset"]] as const)( + "runs %s without needing a working renderer", + ([label, operation]) => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const templateReady = + yield* Deferred.make(); + const steps: string[] = []; + yield* configureMenu( + selectedAction, + templateReady, + {}, + { + assetDirectories: Effect.die("unexpected asset read"), + currentVersion: Effect.succeed("1.0.0"), + apply: () => Effect.die("unexpected local UI update"), + reset: Effect.sync(() => { + steps.push("reset"); + }), + }, + ); + const template = yield* Deferred.await(templateReady); + const view = template.find((item) => item.label === "View"); + if (!Array.isArray(view?.submenu)) throw new Error("Missing View menu"); + const item = view.submenu.find((entry) => entry.label === label); + if (!item?.click) throw new Error(`Missing ${label} menu action`); + item.click({} as Electron.MenuItem, undefined, {} as KeyboardEvent); + assert.equal(yield* Deferred.await(selectedAction), "main-forceReload"); + assert.deepEqual(steps, [operation]); + }), + ); it.effect("keeps display branding in the macOS application menu", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index ecd901b48758d296c6635c9179cf7c396786116d..3e790b8097f0c550bd02f21382adda0bd0a382ca 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -11,6 +11,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; export class DesktopApplicationMenuActionError extends Schema.TaggedError()( @@ -34,6 +35,7 @@ export class DesktopApplicationMenu extends Context.Service< type DesktopApplicationMenuRuntimeServices = | DesktopUpdates.DesktopUpdates + | DesktopLocalUi.DesktopLocalUi | DesktopWindow.DesktopWindow | ElectronDialog.ElectronDialog; @@ -74,7 +76,7 @@ const checkForUpdatesFromMenu = Effect.gen(function* () { yield* electronDialog.showMessageBox({ type: "info", title: "You're up to date!", - message: `The Knife ${updateState.currentVersion} is currently the newest version available.`, + message: `The Knife ${updateState.uiVersion ?? updateState.currentVersion} is currently the newest version available.`, buttons: ["OK"], }); } else if (updateState.status === "error") { @@ -160,6 +162,29 @@ export const make = Effect.gen(function* () { const mainContentsClick = (command: DesktopWindow.MainWindowContentsCommand) => () => { runMenuEffect(command, runMainContentsCommand(command)); }; + const localUiClick = () => () => { + runMenuEffect( + "reset-ui", + Effect.gen(function* () { + const localUi = yield* DesktopLocalUi.DesktopLocalUi; + yield* localUi.reset; + yield* runMainContentsCommand("forceReload"); + }).pipe( + Effect.catchTags({ + DesktopLocalUiError: (error) => + Effect.gen(function* () { + const dialog = yield* ElectronDialog.ElectronDialog; + yield* dialog.showMessageBox({ + type: "error", + title: "Could not update UI", + message: error.message, + buttons: ["OK"], + }); + }), + }), + ), + ); + }; const template: Electron.MenuItemConstructorOptions[] = []; if (environment.platform === "darwin") { @@ -244,6 +269,11 @@ export const make = Effect.gen(function* () { the main window (see DesktopWindow.zoomMain). */ { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") }, + { + label: "Use Bundled UI", + enabled: !environment.isDevelopment, + click: localUiClick(), + }, { label: "Force Reload", accelerator: "Shift+CmdOrCtrl+R", diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 17ce805ffe164a197337764b070984bacd029964..02cd1bd8cda8b530290d64ddf639b762502a163b 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -95,7 +95,9 @@ export default defineConfig({ entry: ["src/boot.ts", "src/compileCache.ts"], clean: false, deps: { - neverBundle: (id) => id === "./main.cjs" || id === "./compileCache.cjs", + alwaysBundle: (id) => !id.startsWith("node:") && id !== "electron", + neverBundle: (id) => + id === "electron" || id === "./main.cjs" || id === "./compileCache.cjs", }, }, { diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index 76035ded632b63f072a2c294e7ef8fc58ce37316..e78374287815b1f7c0d5493a9892491c78e875a4 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -1,3 +1,4 @@ +import { SERVER_VERSION } from "./version.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -6,7 +7,6 @@ import { Argument, Command } from "effect/cli"; import * as CliError from "effect/cli/CliError"; import * as NetService from "@t3tools/shared/Net"; -import packageJson from "../package.json" with { type: "json" }; import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts"; import { authCommand } from "./cli/auth.ts"; import { appCommand } from "./cli/app.ts"; @@ -92,7 +92,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => export const cli = makeCli(); export function runCli() { - Command.run(cli, { version: packageJson.version }).pipe( + Command.run(cli, { version: SERVER_VERSION }).pipe( Effect.scoped, Effect.provide(layerCliRuntime), NodeRuntime.runMain, diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index ff5cb9d8f9c8d68c53962db7473749d7aa225bc1..dd1d5abad922263823390fbb887c3fbf454dba71 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -1,3 +1,4 @@ +import { SERVER_VERSION } from "../version.ts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -8,8 +9,6 @@ import type { ServerInstallation } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { isCommandAvailable } from "@t3tools/shared/shell"; -import packageJson from "../../package.json" with { type: "json" }; - export type CliRunner = "npx" | "pnpm dlx" | "bunx"; /** @@ -139,7 +138,7 @@ export const resolveCliCommand = (subcommand: string) => formatCliCommand({ subcommand, entryPath: processArguments[1] ?? "", - version: packageJson.version, + version: SERVER_VERSION, }), ); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 7a5dd733747806aef38dd9aad8056ac828f63d38..77c8cc5b866a57c99c26142ae537bbf1682dd64e 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -1,3 +1,4 @@ +import { SERVER_VERSION } from "../version.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; @@ -6,7 +7,6 @@ import * as Terminal from "effect/Terminal"; import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import { FetchHttpClient } from "effect/http"; -import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; import type * as ServerConfig from "../config.ts"; @@ -17,7 +17,7 @@ export const layer = (config: ServerConfig.ServerConfig["Service"]) => BootService.layer({ baseDir: config.baseDir, logsDir: config.logsDir, - cliVersion: packageJson.version, + cliVersion: SERVER_VERSION, }).pipe( Layer.provide(ProcessRunner.layer), // Archive-distributed versions download the release archive here. @@ -48,11 +48,11 @@ export const reconcileService = Effect.fn("cli.service.reconcile")(function* (op if ( status.installedVersion !== undefined && options?.allowDowngrade !== true && - compareExactServiceVersions(packageJson.version, status.installedVersion) < 0 + compareExactServiceVersions(SERVER_VERSION, status.installedVersion) < 0 ) { return yield* new BootService.BootServiceDowngradeRefusedError({ installedVersion: status.installedVersion, - targetVersion: packageJson.version, + targetVersion: SERVER_VERSION, }); } const plan = yield* service.install(options); @@ -126,13 +126,11 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip Effect.gen(function* () { const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); if (!result.changed) { - yield* Console.log( - `T3 Code service is already installed with t3@${packageJson.version}.`, - ); + yield* Console.log(`T3 Code service is already installed with t3@${SERVER_VERSION}.`); return; } yield* Console.log( - `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, + `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${SERVER_VERSION}.\nLogs: ${result.plan.logPath}`, ); }), ), @@ -153,11 +151,11 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( ); const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); if (!result.changed) { - yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); + yield* Console.log(`T3 Code service is already using t3@${SERVER_VERSION}.`); return; } yield* Console.log( - `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, + `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${SERVER_VERSION}.\nLogs: ${result.plan.logPath}`, ); }), ), @@ -208,7 +206,7 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( flags, Effect.gen(function* () { const service = yield* BootService.BootService; - yield* Console.log(formatServiceStatus(yield* service.status, packageJson.version)); + yield* Console.log(formatServiceStatus(yield* service.status, SERVER_VERSION)); }), ), ), @@ -231,7 +229,7 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { if ( installed && status.installedVersion !== undefined && - compareExactServiceVersions(status.installedVersion, packageJson.version) > 0 + compareExactServiceVersions(status.installedVersion, SERVER_VERSION) > 0 ) { yield* Console.log( `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`, diff --git a/apps/server/src/cli/triage.ts b/apps/server/src/cli/triage.ts index 8c666f09ed997c89289a64aef849caa47e86cb26..d1e355bb428a17ba062aad8d871bcf852d3042b6 100644 --- a/apps/server/src/cli/triage.ts +++ b/apps/server/src/cli/triage.ts @@ -1,3 +1,4 @@ +import { SERVER_VERSION } from "../version.ts"; /** * `t3 triage` - hand a misbehaving install to the user's own coding agent. * @@ -26,7 +27,6 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/cli"; -import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; import * as ServerConfig from "../config.ts"; import { resolveBaseDir } from "../os-jank.ts"; @@ -195,7 +195,7 @@ export const triageCommand = Command.make("triage", { contextFilePath, buildTriageContext({ generatedAt: DateTime.formatIso(now), - cliVersion: packageJson.version, + cliVersion: SERVER_VERSION, os: `${yield* HostProcess.Platform} ${yield* HostProcess.Architecture} (${NodeOS.release()})`, nodeVersion: process.version, launchedAs: yield* resolveCliCommand("triage"), diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 073217d505cba83b26f75e8247fc47229ce8a911..23d0785a0a60eb399abee98e257676592dcce374 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -1,3 +1,4 @@ +import { SERVER_VERSION } from "../version.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { CLI_RELEASE_BASE_URL_ENV, @@ -18,7 +19,6 @@ import * as Schema from "effect/Schema"; import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; -import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; import { ensurePinnedRuntimeInstalled, @@ -339,7 +339,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { const httpClient = yield* HttpClient.HttpClient; const service = yield* BootService.BootService; - const currentVersion = packageJson.version; + const currentVersion = SERVER_VERSION; const channel = input.channel ?? cliReleaseChannelOf(currentVersion); if (input.requestedVersion !== undefined && !isExactServiceVersion(input.requestedVersion)) { return yield* new CliUpdateError({ diff --git a/apps/server/src/cloud/serviceLauncherClient.ts b/apps/server/src/cloud/serviceLauncherClient.ts index b58d133494cb397a7c40534a4c3fdfaf03ba9c1d..5074a6eebdb557a4a35d22c14756849bf1a701d0 100644 --- a/apps/server/src/cloud/serviceLauncherClient.ts +++ b/apps/server/src/cloud/serviceLauncherClient.ts @@ -1,3 +1,4 @@ +import { SERVER_VERSION } from "../version.ts"; import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; @@ -5,7 +6,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import packageJson from "../../package.json" with { type: "json" }; import { decodeServiceLauncherContext, decodeServiceLauncherParentMessage, @@ -115,7 +115,7 @@ const resolveStartup = Effect.fn("cloud.service_launcher_client.resolve_startup" function* (options?: { readonly currentVersion?: string }) { const host = yield* ServiceLauncherHostProcess; const environment = yield* HostProcess.Environment; - const currentVersion = options?.currentVersion ?? packageJson.version; + const currentVersion = options?.currentVersion ?? SERVER_VERSION; const rawContext = environment[SERVICE_LAUNCHER_CONTEXT_ENV]; const context = rawContext === undefined ? undefined : decodeServiceLauncherContext(rawContext); diff --git a/apps/server/src/cloud/servicePreflight.ts b/apps/server/src/cloud/servicePreflight.ts index ee0f972baa3fc9b2296740f8d3533f3ae4cced35..4b9dbe2c0db6768c7a5a10a9e127c97eea97b84a 100644 --- a/apps/server/src/cloud/servicePreflight.ts +++ b/apps/server/src/cloud/servicePreflight.ts @@ -1,4 +1,4 @@ -import packageJson from "../../package.json" with { type: "json" }; +import { SERVER_VERSION } from "../version.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; export type ServicePreflightResult = @@ -19,7 +19,7 @@ export function runServicePreflight(input: { readonly launcherProtocol: number; readonly version?: string; }): ServicePreflightResult { - const version = input.version ?? packageJson.version; + const version = input.version ?? SERVER_VERSION; if (input.launcherProtocol !== SERVICE_LAUNCHER_PROTOCOL) { return { status: "blocked", diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 61037cf9ff9c1367764a975bbbe3e307bbd0a526..31ff070832fa1496578ceccb46ddba3bb8a29f31 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -1,3 +1,4 @@ +import { SERVER_VERSION } from "../version.ts"; import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION, @@ -13,7 +14,6 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import packageJson from "../../package.json" with { type: "json" }; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { resolveServerInstallation } from "../cli/invocation.ts"; import { readAgentActivityPublishingActive } from "../cloud/config.ts"; @@ -213,7 +213,7 @@ export const make = Effect.gen(function* () { arch: platformArch(hostArchitecture), ...(machine === null ? {} : { machine }), }, - serverVersion: packageJson.version, + serverVersion: SERVER_VERSION, orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, capabilities: { repositoryIdentity: true, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 64e8a344cde359cb2b884eb9f4f16a8826ab9e54..d1978f28cfdfe38dced736d0ee7c8685937ed649 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -1,3 +1,4 @@ +import { SERVER_VERSION } from "../version.ts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -15,7 +16,6 @@ import { AiError, McpProtocol, McpSchema, McpServer, Tool, type Toolkit } from " import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts"; -import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import * as DeviceService from "../device/DeviceService.ts"; import * as HtmlRender from "../htmlRender/HtmlRender.ts"; @@ -860,7 +860,7 @@ export const layerDeviceToolkit = Layer.mergeAll( export const layerMcpTransport = McpServer.layerHttp({ name: "T3 Code", - version: packageJson.version, + version: SERVER_VERSION, path: "/mcp", protocols: [McpProtocol.v2025_06_18], allowSessionTermination: true, diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 7d4bbfdf5d0fad1cc1f6f20aa882e62760fb2a2d..5a4049ef8b25c1dba9759abf7173cef0b12d8fc6 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -1,3 +1,4 @@ +import { SERVER_VERSION } from "../version.ts"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -51,7 +52,6 @@ import { type CodexResetCreditsSummary, } from "./codexUsageLimits.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import packageJson from "../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; @@ -410,7 +410,7 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { clientInfo: { name: CODEX_CLIENT_NAME, title: CODEX_CLIENT_NAME, - version: packageJson.version, + version: SERVER_VERSION, }, capabilities: { experimentalApi: true, diff --git a/apps/server/src/provider/providerCompatibility.ts b/apps/server/src/provider/providerCompatibility.ts index b309d507dc13172b56cfbd925bca99dcdb9342e5..f04079d74e9047de618482fc650b10e829f61197 100644 --- a/apps/server/src/provider/providerCompatibility.ts +++ b/apps/server/src/provider/providerCompatibility.ts @@ -1,3 +1,4 @@ +import { SERVER_VERSION } from "../version.ts"; import { TrimmedNonEmptyString, ServerProviderCompatibilityStatus, @@ -7,7 +8,6 @@ import { } from "@t3tools/contracts"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as Schema from "effect/Schema"; -import packageJson from "../../package.json" with { type: "json" }; // Deliberately uses the shared CLI gate syntax: comparator groups joined by ||. // Prereleases and unrecognized release tags remain unknown. @@ -60,7 +60,7 @@ export function resolveProviderCompatibility( policies: ReadonlyArray | undefined, driver: ProviderDriverKind, version: string | null, - t3CodeVersion = packageJson.version, + t3CodeVersion = SERVER_VERSION, ): ServerProviderCompatibilityAdvisory | undefined { const policy = policies?.find( (entry) => entry.driver === driver && satisfiesSemverRange(t3CodeVersion, entry.t3CodeRange), diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index fd11a74f94573e2db71553bd08ae71cf4db6daa1..72c0acbe8a4ae537aac4a9b12b7f5b7ad9bc59f3 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -1,3 +1,4 @@ +import { SERVER_VERSION } from "./version.ts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -5,7 +6,6 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; -import packageJson from "../package.json" with { type: "json" }; import type * as ServerConfig from "./config.ts"; import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; @@ -76,7 +76,7 @@ export const makePersistedServerRuntimeState = (input: { ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), startedAt: DateTime.formatIso(now), ...(input.serviceManaged ? { serviceManaged: true } : {}), - serverVersion: packageJson.version, + serverVersion: SERVER_VERSION, })); export const persistServerRuntimeState = (input: { diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 752cf6f7ebae158615bc16c1c5b4063de59e7072..f481385647033bd8ed7a2f638094c7c3fe2c25ca 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -1,3 +1,4 @@ +import { SERVER_VERSION } from "../version.ts"; /** * Anonymous PostHog telemetry service. * @@ -27,7 +28,6 @@ import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import { getTelemetryIdentifier } from "./Identify.ts"; @@ -188,12 +188,12 @@ export const make = Effect.gen(function* () { platform: hostPlatform, wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), arch: hostArchitecture, - t3CodeVersion: packageJson.version, + t3CodeVersion: SERVER_VERSION, clientType, serverOs: serverOsFromNodePlatform(hostPlatform), serverArch: hostArchitecture, serverWslDistro: Option.getOrUndefined(telemetryConfig.wslDistroName), - serverAppVersion: packageJson.version, + serverAppVersion: SERVER_VERSION, serverMode: serverConfig.mode, }, timestamp: event.capturedAt, @@ -286,5 +286,3 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(AnalyticsService, make); - -const layerTest = AnalyticsService.layerTest; diff --git a/apps/server/src/version.test.ts b/apps/server/src/version.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..714cb349bd3b661140f5887730a32c79d5d183fa --- /dev/null +++ b/apps/server/src/version.test.ts @@ -0,0 +1,37 @@ +// @effect-diagnostics nodeBuiltinImport:off - Exercises the actual CLI in isolated child processes. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeUtil from "node:util"; +import { afterAll, beforeAll, expect, it } from "vite-plus/test"; + +import packageJson from "../package.json" with { type: "json" }; + +const run = NodeUtil.promisify(NodeChildProcess.execFile); +let home: string; + +beforeAll(async () => { + home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "knife-version-")); +}); + +afterAll(async () => { + await NodeFSP.rm(home, { recursive: true, force: true }); +}); + +it.each([ + [undefined, packageJson.version], + ["0.0.1234567890", "0.0.1234567890"], + ["9.0.1", packageJson.version], +])("reports the running server version for override %s", async (override, expected) => { + const env: NodeJS.ProcessEnv = { ...process.env, T3CODE_HOME: home }; + delete env.T3CODE_VERSION_OVERRIDE; + if (override !== undefined) env.T3CODE_VERSION_OVERRIDE = override; + const { stdout } = await run( + process.execPath, + [NodeURL.fileURLToPath(new URL("./bin.ts", import.meta.url)), "--version"], + { env }, + ); + expect(stdout.trim()).toBe(`t3 v${expected}`); +}); diff --git a/apps/server/src/version.ts b/apps/server/src/version.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d2e4654a841624dfd3a1870d1255d9a0e30477f --- /dev/null +++ b/apps/server/src/version.ts @@ -0,0 +1,6 @@ +import packageJson from "../package.json" with { type: "json" }; + +const override = process.env.T3CODE_VERSION_OVERRIDE; + +export const SERVER_VERSION = + override && /^0\.0\.\d+$/.test(override) ? override : packageJson.version; diff --git a/apps/web/src/components/AutomaticUiRefresh.tsx b/apps/web/src/components/AutomaticUiRefresh.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a5928543446c057b98425e57bd8300a9e511ab06 --- /dev/null +++ b/apps/web/src/components/AutomaticUiRefresh.tsx @@ -0,0 +1,39 @@ +import { useEffect, useLayoutEffect, useRef } from "react"; +import { useDesktopUpdateState } from "~/state/desktopUpdate"; +import { prepareUiRefresh, startAutomaticUiRefresh } from "~/uiRefresh"; +import { APP_VERSION } from "~/branding"; +import { installLocalUpdate } from "./desktopUpdate.actions"; + +export function AutomaticUiRefresh() { + const state = useDesktopUpdateState(); + const latest = useRef(state); + const controller = useRef | null>(null); + + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge) return; + const refresh = startAutomaticUiRefresh({ + pending: () => + (latest.current?.status === "downloaded" && latest.current.installKind === "ui") || + Boolean(latest.current?.uiVersion && latest.current.uiVersion !== APP_VERSION), + apply: async () => { + if (latest.current?.uiVersion && latest.current.uiVersion !== APP_VERSION) { + if (prepareUiRefresh()) window.location.reload(); + return; + } + await installLocalUpdate(bridge, undefined, { automatic: true }); + }, + }); + controller.current = refresh; + return () => { + controller.current = null; + refresh.dispose(); + }; + }, []); + + useLayoutEffect(() => { + latest.current = state; + controller.current?.check(); + }, [state]); + return null; +} diff --git a/apps/web/src/components/ComposerPromptEditor.test.tsx b/apps/web/src/components/ComposerPromptEditor.test.tsx index 366f11d8dcdd60efe7739955ee170ebb9954a5e2..73815232f6f5d28a0cc15a426583ee02c19baec4 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.tsx +++ b/apps/web/src/components/ComposerPromptEditor.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, createRef, useLayoutEffect, useState } from "react"; +import { act, createRef, StrictMode, useEffect, useLayoutEffect, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -17,6 +17,8 @@ import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { ComposerContextActionsContext } from "./composerContextPresentation"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; +import { EditorView } from "@codemirror/view"; +import { PREPARE_UI_REFRESH_EVENT } from "~/uiRefresh"; let root: Root; let container: HTMLDivElement; @@ -27,16 +29,28 @@ const openMention = vi.fn(); const commandKey = vi.fn((_key: string, _event: KeyboardEvent) => false); const changes: Array<{ value: string; suppressed: boolean }> = []; -function ComposerProbe() { +function ComposerProbe({ + focusOnMount = false, + initiallyEmpty = false, +}: { + focusOnMount?: boolean; + initiallyEmpty?: boolean; +}) { const [richText, setRichText] = useState(false); const [draft, setDraft] = useState({ - value: "before :dizzy after", - cursor: "before :dizzy".length, + value: initiallyEmpty ? "" : "before :dizzy after", + cursor: initiallyEmpty ? 0 : "before :dizzy".length, }); useLayoutEffect(() => { replace = (value, cursor) => setDraft({ value, cursor }); enableRichText = setRichText; }); + useEffect(() => { + if (!focusOnMount) return; + // oxlint-disable-next-line react/set-state-in-effect -- Reproduce the parent's mount-time cursor reset. + setDraft((current) => ({ ...current, cursor: current.value.length })); + handle.current?.focusAtEnd(); + }, [focusOnMount]); return ( { afterEach(async () => { await act(() => root.unmount()); container.remove(); + sessionStorage.clear(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); +it("restores the real editor selection and viewport after a refresh remount", async () => { + const textbox = container.querySelector('[data-testid="composer-editor"]')!; + const view = EditorView.findFromDOM(textbox)!; + await act(() => view.dispatch({ selection: { anchor: 2, head: 6 } })); + view.scrollDOM.scrollTop = 35; + expect(window.dispatchEvent(new Event(PREPARE_UI_REFRESH_EVENT, { cancelable: true }))).toBe( + true, + ); + vi.spyOn(performance, "timeOrigin", "get").mockReturnValue(performance.timeOrigin + 1); + await act(() => root.unmount()); + root = createRoot(container); + await act(() => + root.render( + + + , + ), + ); + await act(() => replace("before :dizzy after", "before :dizzy after".length)); + expect(handle.current!.readSelectionRange()).toEqual({ start: 2, end: 6 }); + expect(document.getSelection()?.toString()).toBe("fore"); + const restored = EditorView.findFromDOM( + container.querySelector('[data-testid="composer-editor"]')!, + )!; + expect(restored.scrollDOM.scrollTop).toBe(35); + await act(() => { + restored.contentDOM.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }), + ); + handle.current!.focusAtEnd(); + }); + const end = handle.current!.readSnapshot().value.length; + expect(handle.current!.readSelectionRange()).toEqual({ start: end, end }); +}); + describe("emoji completion in the real composer editor", () => { it("keeps the caret and undo history when a controlled completion replaces the shortcode", async () => { await act(() => handle.current!.focus()); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 6e0d1af8d96b172e38f6350d1d1839d9ab32ff53..5383914a7e1db69fb9ca5ee83ba06a4b661d1723 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -10,14 +10,16 @@ import { import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; import { isolateHistory } from "@codemirror/commands"; import { Transaction } from "@codemirror/state"; +import * as Schema from "effect/Schema"; import { createPortal } from "react-dom"; -import { use, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; +import { use, useCallback, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; import { expandCollapsedComposerCursor } from "~/composer-logic"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { basenameOfPath } from "~/pierre-icons"; import { cn } from "~/lib/utils"; +import { PREPARE_UI_REFRESH_EVENT } from "~/uiRefresh"; import { ComposerContextActionsContext, ComposerContextRecordsContext, @@ -66,6 +68,7 @@ export interface ComposerPromptEditorHandle { } export interface ComposerPromptEditorProps { + persistenceKey?: string | undefined; value: string; cursor: number; /** @@ -129,20 +132,45 @@ type CitationComment = { insertedSpaces?: CitationInsertedSpaces | undefined; }; +const RefreshSelection = Schema.fromJsonString( + Schema.Struct({ + pageOrigin: Schema.Finite, + value: Schema.String, + anchor: Schema.Int, + head: Schema.Int, + scrollTop: Schema.Finite, + scrollLeft: Schema.Finite, + }), +); +const decodeRefreshSelection = Schema.decodeSync(RefreshSelection); +const REFRESH_SELECTION_STORAGE_PREFIX = "t3code:ui-refresh-composer:"; + export function ComposerPromptEditor(props: ComposerPromptEditorProps) { const host = useRef(null); const currentProps = useRef(props); const editor = useRef | null>(null); + const restoringRefreshSelection = useRef(false); const pendingCitation = useRef(null); const citationDraft = useRef<{ start: number; comment: string } | null>(null); const [comment, setComment] = useState(null); const [chips, setChips] = useState([]); + const discardRefreshSelection = useCallback(() => { + restoringRefreshSelection.current = false; + const key = currentProps.current.persistenceKey; + if (!key) return; + try { + window.sessionStorage.removeItem(`${REFRESH_SELECTION_STORAGE_PREFIX}${key}`); + } catch { + // Storage availability must not prevent editing. + } + }, []); useLayoutEffect(() => { currentProps.current = props; }); useLayoutEffect(() => { let alive = true; + restoringRefreshSelection.current = false; const instance = createComposerMarkdownEditor({ props: () => currentProps.current, mountChip: (chip) => { @@ -154,12 +182,18 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { }); editor.current = instance; host.current!.append(instance.dom); + instance.dom.addEventListener("pointerdown", discardRefreshSelection, true); + instance.dom.addEventListener("keydown", discardRefreshSelection, true); + instance.dom.addEventListener("beforeinput", discardRefreshSelection, true); return () => { alive = false; editor.current = null; + instance.dom.removeEventListener("pointerdown", discardRefreshSelection, true); + instance.dom.removeEventListener("keydown", discardRefreshSelection, true); + instance.dom.removeEventListener("beforeinput", discardRefreshSelection, true); instance.destroy(); }; - }, []); + }, [discardRefreshSelection]); // oxlint-disable react/exhaustive-effect-dependencies -- Editor callbacks read the ref; DOM configuration follows these props. useLayoutEffect(() => { @@ -180,6 +214,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { if (!instance) return; const snapshot = instance.snapshot(); if (snapshot.value !== props.value) { + if (restoringRefreshSelection.current) discardRefreshSelection(); const draft = citationDraft.current; citationDraft.current = null; const saveDraft = currentProps.current.onCitationDraftSave; @@ -229,7 +264,11 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { }, annotations: [isolateHistory.of("full"), Transaction.userEvent.of("t3.controlled")], }); - } else if (snapshot.cursor !== props.cursor && !instance.view.compositionStarted) { + } else if ( + snapshot.cursor !== props.cursor && + !instance.view.compositionStarted && + !restoringRefreshSelection.current + ) { instance.view.dispatch({ selection: { anchor: expandCollapsedComposerCursor(props.value, props.cursor, props.literalText), @@ -251,7 +290,66 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { insertedSpaces: request.insertedSpaces, }); } - }, [props.value, props.cursor, props.literalText]); + }, [props.value, props.cursor, props.literalText, discardRefreshSelection]); + + useLayoutEffect(() => { + const instance = editor.current; + if (!instance || !props.persistenceKey) return; + const storageKey = `${REFRESH_SELECTION_STORAGE_PREFIX}${props.persistenceKey}`; + const value = props.value; + try { + const saved = window.sessionStorage.getItem(storageKey); + if (saved) { + const selection = decodeRefreshSelection(saved); + if ( + selection.pageOrigin !== performance.timeOrigin && + selection.value === value && + selection.anchor >= 0 && + selection.anchor <= value.length && + selection.head >= 0 && + selection.head <= value.length + ) { + // Mount-time cursor resets and autofocus must not replace a restored selection. + restoringRefreshSelection.current = true; + instance.view.dispatch({ selection: { anchor: selection.anchor, head: selection.head } }); + instance.view.scrollDOM.scrollTop = selection.scrollTop; + instance.view.scrollDOM.scrollLeft = selection.scrollLeft; + } + } + } catch { + // An unreadable snapshot leaves the editor at its ordinary initial caret. + } + }, [props.persistenceKey, props.value]); + + useLayoutEffect(() => { + const instance = editor.current; + if (!instance || !props.persistenceKey) return; + const storageKey = `${REFRESH_SELECTION_STORAGE_PREFIX}${props.persistenceKey}`; + const save = (event: Event) => { + if (instance.view.compositionStarted || citationDraft.current || comment) { + event.preventDefault(); + return; + } + try { + const selection = instance.view.state.selection.main; + window.sessionStorage.setItem( + storageKey, + JSON.stringify({ + pageOrigin: performance.timeOrigin, + value: instance.view.state.doc.toString(), + anchor: selection.anchor, + head: selection.head, + scrollTop: instance.view.scrollDOM.scrollTop, + scrollLeft: instance.view.scrollDOM.scrollLeft, + }), + ); + } catch { + event.preventDefault(); + } + }; + window.addEventListener(PREPARE_UI_REFRESH_EVENT, save); + return () => window.removeEventListener(PREPARE_UI_REFRESH_EVENT, save); + }, [props.persistenceKey, comment]); useLayoutEffect(() => { editor.current?.view.requestMeasure(); @@ -264,6 +362,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { focusAt: (cursor) => { const instance = editor.current; if (!instance) return; + discardRefreshSelection(); const snapshot = instance.snapshot(); instance.view.dispatch({ selection: { @@ -280,6 +379,10 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { focusAtEnd: () => { const instance = editor.current; if (!instance) return; + if (restoringRefreshSelection.current) { + instance.focus(); + return; + } instance.view.dispatch({ selection: { anchor: instance.view.state.doc.length }, scrollIntoView: true, @@ -337,7 +440,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { ); }, }), - [props.editorRef, props.value, props.cursor, props.literalText], + [props.editorRef, props.value, props.cursor, props.literalText, discardRefreshSelection], ); const copy = (event: React.ClipboardEvent, cut: boolean) => { @@ -381,7 +484,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { onKeyUp={(event) => props.onPageScrollKeyUp?.(event.key)} onBlur={props.onPageScrollRelease} > -
+
{props.value.length === 0 && props.contextRecords.size === 0 && props.placeholder ? (
{ pasteAsTextShortcutUntilRef.current = 0; }; + const onPrepareUiRefresh = (event: Event) => { + if ( + pendingImageCompressionsRef.current.size > 0 || + pendingThreadImportsRef.current.size > 0 || + stashInFlightRef.current.size > 0 + ) + event.preventDefault(); + }; const onDesktopPasteAsText = () => { const activeElement = document.activeElement; const blocksPasteToFocus = @@ -2585,10 +2594,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) window.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); window.addEventListener("keydown", onKeyDown, true); window.addEventListener("blur", onBlur); + window.addEventListener(PREPARE_UI_REFRESH_EVENT, onPrepareUiRefresh); return () => { window.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); window.removeEventListener("keydown", onKeyDown, true); window.removeEventListener("blur", onBlur); + window.removeEventListener(PREPARE_UI_REFRESH_EVENT, onPrepareUiRefresh); }; }, []); @@ -7691,6 +7702,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null} { } }); + it("loads missing history before restoring a saved reading anchor", async () => { + const { rememberTimelinePosition } = await import("./timelineScrollAnchoring"); + const props = { ...buildProps(), routeThreadKey: "environment-local:refresh-old-history" }; + rememberTimelinePosition( + props.routeThreadKey, + { + rowId: "entry-old", + offsetWithinRow: 20, + scrollOffset: 700, + atEnd: false, + }, + false, + ); + const viewport = { + scrollTop: 0, + ownerDocument: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + getBoundingClientRect: () => ({ top: 0 }), + }; + const scrollToIndex = vi.fn(async () => {}); + const scrollToOffset = vi.fn(async () => {}); + props.listRef.current = { + getScrollableNode: () => viewport, + getState: () => undefined, + scrollToIndex, + scrollToOffset, + } as unknown as LegendListRef; + const onLoadEarlier = vi.fn(); + const historyControls = { hasMoreHistory: true, loading: false, error: null, onLoadEarlier }; + const latest = buildUserTimelineEntry("Latest prompt"); + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create( + , + ); + }); + expect(onLoadEarlier).toHaveBeenCalledWith("entry-old"); + expect(scrollToOffset).not.toHaveBeenCalled(); + expect(scrollToIndex).not.toHaveBeenCalled(); + const older = { + ...buildUserTimelineEntry("Old prompt"), + id: "entry-old", + message: { ...latest.message, id: MessageId.make("old-message"), text: "Old prompt" }, + }; + await act(() => + renderer!.update( + , + ), + ); + expect(scrollToIndex).toHaveBeenCalledWith({ + index: 0, + animated: false, + viewPosition: 0, + viewOffset: -20, + }); + } finally { + await act(() => renderer?.unmount()); + } + }); + it("keeps a following thread's intent while its timeline is held for the next thread", async () => { const { readTimelinePosition } = await import("./timelineScrollAnchoring"); const timeline = stubFollowTimeline("environment-local:held-departure"); @@ -2647,8 +2717,6 @@ describe("MessagesTimeline", () => { />, ); - // The T3 wordmark replaces the generic tool icon for T3 MCP calls. - expect(markup).toContain('viewBox="15.5309 37 94.3941 56.96"'); expect(markup).toContain("Read a T3 thread"); expect(markup).not.toContain("mcp__t3-code__t3_thread_read"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b410e18de850de7591ed61ac52ba9d41f64f9825..8469675a4f5c4c18ff55d35903bf986d0c277e08 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1003,6 +1003,26 @@ const ConversationTimeline = memo(function ConversationTimeline({ const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1; if (position?.atEnd === false) onManualNavigation(); if (cancelPositionRestoreRef) cancelPositionRestoreRef.current = cancelRestoration; + const cleanup = () => { + cancelled = true; + if (cancelPositionRestoreRef?.current === cancelRestoration) { + cancelPositionRestoreRef.current = null; + } + if (settleFrame !== null) cancelAnimationFrame(settleFrame); + viewport?.removeEventListener("wheel", cancelForNavigation); + viewport?.removeEventListener("touchmove", cancelForNavigation); + viewport?.removeEventListener("pointerdown", cancelForNavigation); + viewport?.ownerDocument.removeEventListener("keydown", onScrollKey); + }; + if ( + position?.atEnd === false && + index < 0 && + historyControls?.hasMoreHistory && + !historyControls.error + ) { + if (!historyControls.loading) historyControls.onLoadEarlier(position.rowId); + return cleanup; + } const scrolling = position?.atEnd === false ? index >= 0 @@ -1055,23 +1075,14 @@ const ConversationTimeline = memo(function ConversationTimeline({ }; settleFrame = requestAnimationFrame(reconcile); }); - return () => { - cancelled = true; - if (cancelPositionRestoreRef?.current === cancelRestoration) { - cancelPositionRestoreRef.current = null; - } - if (settleFrame !== null) cancelAnimationFrame(settleFrame); - viewport?.removeEventListener("wheel", cancelForNavigation); - viewport?.removeEventListener("touchmove", cancelForNavigation); - viewport?.removeEventListener("pointerdown", cancelForNavigation); - viewport?.ownerDocument.removeEventListener("keydown", onScrollKey); - }; + return cleanup; }, [ citationRequest, cancelPositionRestoreRef, listIdentityKey, listRef, onManualNavigation, + historyControls, rememberedPosition, restoringThreadPosition, rows, diff --git a/apps/web/src/components/chat/timelineRefresh.test.ts b/apps/web/src/components/chat/timelineRefresh.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d93eefb90cde6bc4296820e5a577ae7c743cc53 --- /dev/null +++ b/apps/web/src/components/chat/timelineRefresh.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment jsdom + +import { RunAttemptId, RunId } from "@t3tools/contracts"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +afterEach(() => { + sessionStorage.clear(); + vi.resetModules(); +}); + +it("restores the reading anchor, expanded work and nested scroll after a module reload", async () => { + const timeline = await import("./timelineScrollAnchoring"); + const reading = { + rowId: "message-50", + offsetWithinRow: 23, + scrollOffset: 800, + atEnd: false, + disclosures: { + runs: new Set([RunId.make("run-a")]), + workGroups: new Set(["work-a"]), + attempts: new Set([RunAttemptId.make("attempt-a")]), + workGroupState: { + scrollPositions: new Map([["work-a", { entryId: "tool-3", offset: 20 }]]), + expandedEntries: new Set(["tool-3"]), + }, + }, + }; + timeline.rememberTimelinePosition("environment:thread", reading, false); + timeline.persistTimelinePositionsForReload(); + vi.resetModules(); + const refreshed = await import("./timelineScrollAnchoring"); + expect(refreshed.readTimelinePosition("environment:thread")).toEqual(reading); + vi.resetModules(); + const later = await import("./timelineScrollAnchoring"); + expect(later.readTimelinePosition("environment:thread")).toBeUndefined(); +}); diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index d38e63d543a5d32454a70b1d339d1dcad3dac269..91d17654499ac5aa355f8d741b2c51c68761e1bf 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -1,4 +1,5 @@ -import type { MessageId, RunId } from "@t3tools/contracts"; +import { RunId, RunAttemptId, type MessageId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; export interface TimelineRunObservation { readonly threadKey: string | null; @@ -33,8 +34,6 @@ export function observeTimelineRun( } return { observation, anchorMessageId: input.messageId }; } -import type { RunAttemptId } from "@t3tools/contracts"; - // Match the titlebar fade inset so draft promotion preserves the first row's position. export const CHAT_TIMELINE_ANCHOR_OFFSET = 24; @@ -161,6 +160,91 @@ export interface RememberedTimelinePosition { // Scoped thread keys keep separate environments independent. Bound the session cache. const rememberedTimelinePositions = new Map(); +const REFRESH_POSITIONS_KEY = "t3code:ui-refresh-timeline:v1"; +const RefreshPositions = Schema.fromJsonString( + Schema.Record( + Schema.String, + Schema.Struct({ + rowId: Schema.String, + offsetWithinRow: Schema.Finite, + scrollOffset: Schema.Finite, + atEnd: Schema.Boolean, + disclosures: Schema.optionalKey( + Schema.Struct({ + runs: Schema.Array(RunId), + workGroups: Schema.Array(Schema.String), + attempts: Schema.Array(RunAttemptId), + workGroupState: Schema.Struct({ + scrollPositions: Schema.Array( + Schema.Tuple([ + Schema.String, + Schema.Struct({ entryId: Schema.String, offset: Schema.Finite }), + ]), + ), + expandedEntries: Schema.Array(Schema.String), + }), + }), + ), + }), + ), +); + +/** Save only at refresh time; scrolling keeps its existing in-memory cache. */ +export function persistTimelinePositionsForReload(): void { + const positions = Object.fromEntries( + [...rememberedTimelinePositions].map(([key, position]) => [ + key, + { + ...position, + ...(position.disclosures + ? { + disclosures: { + runs: [...position.disclosures.runs], + workGroups: [...position.disclosures.workGroups], + attempts: [...position.disclosures.attempts], + workGroupState: { + scrollPositions: [...position.disclosures.workGroupState.scrollPositions], + expandedEntries: [...position.disclosures.workGroupState.expandedEntries], + }, + }, + } + : {}), + }, + ]), + ); + window.sessionStorage.setItem(REFRESH_POSITIONS_KEY, JSON.stringify(positions)); +} + +if (typeof window !== "undefined") { + try { + const saved = window.sessionStorage.getItem(REFRESH_POSITIONS_KEY); + window.sessionStorage.removeItem(REFRESH_POSITIONS_KEY); + if (saved) { + const positions = Schema.decodeSync(RefreshPositions)(saved); + for (const [key, { disclosures, ...position }] of Object.entries(positions)) { + rememberedTimelinePositions.set(key, { + ...position, + ...(disclosures + ? { + disclosures: { + runs: new Set(disclosures.runs), + workGroups: new Set(disclosures.workGroups), + attempts: new Set(disclosures.attempts), + workGroupState: { + scrollPositions: new Map(disclosures.workGroupState.scrollPositions), + expandedEntries: new Set(disclosures.workGroupState.expandedEntries), + }, + }, + } + : {}), + }); + } + } + } catch { + // Unreadable refresh state falls back to the normal initial position. + } +} + export function readTimelinePosition(threadKey: string) { return rememberedTimelinePositions.get(threadKey); } diff --git a/apps/web/src/components/desktopUpdate.actions.test.ts b/apps/web/src/components/desktopUpdate.actions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..140387272471546846058c3494f86fe9adc745b2 --- /dev/null +++ b/apps/web/src/components/desktopUpdate.actions.test.ts @@ -0,0 +1,81 @@ +import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ prepare: vi.fn(() => true), reload: vi.fn(), toast: vi.fn() })); +vi.mock("~/uiRefresh", () => ({ prepareUiRefresh: mocks.prepare })); +vi.mock("./ui/toast", () => ({ + toastManager: { add: mocks.toast }, + stackedThreadToast: (value: unknown) => value, +})); +vi.mock("./desktopUpdate.toast", () => ({ showDesktopUpdateDownloadedToast: mocks.toast })); + +import { downloadLocalUpdate, installLocalUpdate } from "./desktopUpdate.actions"; + +const state: DesktopUpdateState = { + enabled: true, + status: "downloaded", + channel: "latest", + currentVersion: "0.0.1", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: "0.0.2", + downloadedVersion: "0.0.2", + installKind: "ui", + releaseNotes: [], + omittedReleaseCount: 0, + downloadPercent: 100, + checkedAt: null, + message: null, + errorContext: null, + canRetry: true, +}; +const result: DesktopUpdateActionResult = { + accepted: true, + completed: true, + state: { ...state, status: "idle", uiVersion: "0.0.2" }, +}; + +beforeEach(() => { + mocks.prepare.mockReset().mockReturnValue(true); + mocks.reload.mockReset(); + mocks.toast.mockReset(); + vi.stubGlobal("window", { location: { reload: mocks.reload } }); +}); +afterEach(() => vi.unstubAllGlobals()); + +it("downloads a UI release without activating it", async () => { + const bridge = { + openExternal: vi.fn(async () => true), + downloadUpdate: vi.fn(async () => ({ ...result, state })), + }; + expect(await downloadLocalUpdate(bridge, { notifyDownloaded: false })).toBe(true); + expect(mocks.reload).not.toHaveBeenCalled(); + expect(mocks.prepare).not.toHaveBeenCalled(); +}); + +it("activates an automatic update only when state can roundtrip", async () => { + const bridge = { + openExternal: vi.fn(async () => true), + installUpdate: vi.fn(async () => result), + }; + mocks.prepare.mockReturnValue(false); + expect(await installLocalUpdate(bridge, undefined, { automatic: true })).toBe(false); + expect(bridge.installUpdate).not.toHaveBeenCalled(); + mocks.prepare.mockReturnValue(true); + expect(await installLocalUpdate(bridge, undefined, { automatic: true })).toBe(true); + expect(mocks.reload).toHaveBeenCalledOnce(); +}); + +it("keeps the current UI if unsafe state appears while activation is in progress", async () => { + const bridge = { + openExternal: vi.fn(async () => true), + installUpdate: vi.fn(async () => { + mocks.prepare.mockReturnValue(false); + return result; + }), + }; + expect(await installLocalUpdate(bridge, undefined, { automatic: true })).toBe(true); + expect(bridge.installUpdate).toHaveBeenCalledOnce(); + expect(mocks.reload).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/desktopUpdate.actions.ts b/apps/web/src/components/desktopUpdate.actions.ts index f6cf68445a711a599f9d2dfc150e44ebfaece653..a2fc3d9d90b1497eb6300c0f9a64485cf38cc8f8 100644 --- a/apps/web/src/components/desktopUpdate.actions.ts +++ b/apps/web/src/components/desktopUpdate.actions.ts @@ -9,14 +9,15 @@ import { } from "./desktopUpdate.logic"; import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; import type { ReportLocalUpdateError } from "./sidebar/useUpdateEverything"; +import { prepareUiRefresh } from "~/uiRefresh"; export const toastUpdateError: ReportLocalUpdateError = (title, description) => { toastManager.add(stackedThreadToast({ type: "error", title, description })); }; -/** Prepares a full-app update or applies a compatible UI without stopping tasks. */ +/** Downloads updates; activation waits for confirmation or a safe idle refresh. */ export async function downloadLocalUpdate( - bridge: DesktopBridge, + bridge: Pick, { notifyDownloaded, reportError = toastUpdateError, @@ -24,9 +25,6 @@ export async function downloadLocalUpdate( ): Promise { try { const result = await bridge.downloadUpdate(); - if (result.completed && result.state.installKind === "ui") { - return await installLocalUpdate(bridge, reportError); - } if (result.completed && notifyDownloaded) { showDesktopUpdateDownloadedToast(bridge, result.state); } @@ -46,10 +44,12 @@ export async function downloadLocalUpdate( /** Applies a prepared UI in place or starts an app relaunch after the caller confirms. */ export async function installLocalUpdate( - bridge: DesktopBridge, + bridge: Pick, reportError: ReportLocalUpdateError = toastUpdateError, + options: { readonly automatic?: boolean } = {}, ): Promise { try { + if (options.automatic && !prepareUiRefresh()) return false; const result = await bridge.installUpdate(); if (!result.accepted) { showDesktopUpdateDownloadedToast(bridge, result.state); @@ -59,7 +59,10 @@ export async function installLocalUpdate( ? getDesktopUpdateActionError(result) : null; if (!actionError) { - if (result.completed && result.state.uiVersion) window.location.reload(); + if (result.completed && result.state.uiVersion) { + const preserved = prepareUiRefresh(); + if (!options.automatic || preserved) window.location.reload(); + } return true; } reportError("Could not install update", actionError); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f5663094da620e7a810bf5900b5bdb0c9d0b69ad..a3f060a6d4e85d0aa85b19fa611f73e1fd437376 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -130,6 +130,38 @@ const composerPersistStorage: PersistStorage = { removeItem: (name) => composerDebouncedStorage.removeItem(name), }; +/** A reload is safe only after every draft byte is durably represented in storage. */ +export function prepareComposerDraftsForReload(): boolean { + const state = useComposerDraftStore.getState(); + if ( + state.rewindingThreadKeys.size > 0 || + Object.keys(state.backgroundSubmissionThreadKeys).length > 0 || + Object.values(state.draftThreadsByThreadKey).some(isDraftThreadPromoting) || + Object.values(state.draftsByThreadKey).some( + (draft) => + draft.nonPersistedImageIds.length > 0 || + draft.images.some((image) => !draft.persistedAttachments.some(({ id }) => id === image.id)) || + draft.files.some((file) => !file.uploadedAttachmentId || !file.uploadEnvironmentId), + ) + ) + return false; + + try { + composerPersistStorage.setItem(COMPOSER_DRAFT_STORAGE_KEY, { + state: { capturedState: state }, + version: COMPOSER_DRAFT_STORAGE_VERSION, + }); + composerDebouncedStorage.flush(); + const expected = JSON.stringify({ + state: partializeComposerDraftStoreState(state), + version: COMPOSER_DRAFT_STORAGE_VERSION, + }); + return window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY) === expected; + } catch { + return false; + } +} + // Flush pending composer draft writes before page unload to prevent data loss. if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("beforeunload", () => { diff --git a/apps/web/src/composerReload.test.ts b/apps/web/src/composerReload.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b631aef25fb70fe797c543fe00544a64e244a05 --- /dev/null +++ b/apps/web/src/composerReload.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { + COMPOSER_DRAFT_STORAGE_KEY, + prepareComposerDraftsForReload, + useComposerDraftStore, +} from "./composerDraftStore"; + +const target = scopeThreadRef(EnvironmentId.make("refresh-host"), ThreadId.make("refresh-thread")); + +beforeEach(() => { + vi.useFakeTimers(); + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + }); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +it("flushes current text synchronously and confirms it survives rehydration", async () => { + useComposerDraftStore.getState().setPrompt(target, "latest keystroke"); + expect(prepareComposerDraftsForReload()).toBe(true); + expect(localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY)).toContain("latest keystroke"); + useComposerDraftStore.setState({ draftsByThreadKey: {} }); + await useComposerDraftStore.persist.rehydrate(); + expect(useComposerDraftStore.getState().getComposerDraft(target)?.prompt).toBe( + "latest keystroke", + ); +}); + +it("defers while file bytes exist only locally, then permits their uploaded reference", () => { + const file = new File(["private bytes"], "note.txt", { type: "text/plain" }); + useComposerDraftStore + .getState() + .addFiles(target, [ + { + type: "file", + id: "note", + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + file, + }, + ]); + expect(prepareComposerDraftsForReload()).toBe(false); + useComposerDraftStore + .getState() + .setFileUpload(target, "note", target.environmentId, "uploaded-note"); + expect(prepareComposerDraftsForReload()).toBe(true); +}); + +it("defers when storage rejects or silently drops the flushed draft", () => { + useComposerDraftStore.getState().setPrompt(target, "must survive"); + const write = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("full", "QuotaExceededError"); + }); + expect(prepareComposerDraftsForReload()).toBe(false); + write.mockImplementation(() => {}); + expect(prepareComposerDraftsForReload()).toBe(false); +}); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index f2301f4d7604fb6e0f32fd7f4c1e2a10a136cb31..b94d979dfc09f6fbec2c6b0f6e61ebf28a7ae75a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -32,6 +32,7 @@ import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkC import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { DesktopUpdateLaunchNotice } from "../components/sidebar/updateEverythingRun"; +import { AutomaticUiRefresh } from "../components/AutomaticUiRefresh"; import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast"; import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; @@ -261,6 +262,7 @@ function RootRouteView() { ) : null} {primaryEnvironmentAuthenticated ? : null} {isElectron ? : null} + {isElectron ? : null} {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */} {appShell} diff --git a/apps/web/src/uiRefresh.test.ts b/apps/web/src/uiRefresh.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..38552a0e23c5433ab14bdbc34bbc14ccf4014bb7 --- /dev/null +++ b/apps/web/src/uiRefresh.test.ts @@ -0,0 +1,190 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + drafts: vi.fn(() => true), + timeline: vi.fn(), + recordings: vi.fn(() => new Set()), +})); +vi.mock("./composerDraftStore", () => ({ prepareComposerDraftsForReload: mocks.drafts })); +vi.mock("./components/chat/timelineScrollAnchoring", () => ({ + persistTimelinePositionsForReload: mocks.timeline, +})); +vi.mock("./browser/browserRecording", () => ({ + readActiveBrowserRecordingTabIds: mocks.recordings, +})); + +import { PREPARE_UI_REFRESH_EVENT, prepareUiRefresh, startAutomaticUiRefresh } from "./uiRefresh"; + +let focused = true; +let controller: ReturnType | undefined; + +beforeEach(() => { + vi.useFakeTimers(); + focused = true; + vi.spyOn(document, "hasFocus").mockImplementation(() => focused); + Object.defineProperty(document, "hidden", { configurable: true, value: false }); + document.body.replaceChildren(); + mocks.drafts.mockReset().mockReturnValue(true); + mocks.timeline.mockReset(); + mocks.recordings.mockReset().mockReturnValue(new Set()); +}); +afterEach(() => { + controller?.dispose(); + controller = undefined; + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("automatic UI refresh", () => { + it("waits one uninterrupted unfocused minute with an update pending", async () => { + const apply = vi.fn(async () => {}); + controller = startAutomaticUiRefresh({ pending: () => true, apply }); + focused = false; + window.dispatchEvent(new Event("blur")); + await vi.advanceTimersByTimeAsync(59_999); + expect(apply).not.toHaveBeenCalled(); + focused = true; + window.dispatchEvent(new Event("focus")); + await vi.advanceTimersByTimeAsync(60_000); + expect(apply).not.toHaveBeenCalled(); + focused = false; + window.dispatchEvent(new Event("blur")); + await vi.advanceTimersByTimeAsync(60_000); + expect(apply).toHaveBeenCalledOnce(); + }); + + it("resets the ten-minute idle threshold on input, and never applies a nonpending update", async () => { + let pending = false; + const apply = vi.fn(async () => { + pending = false; + }); + controller = startAutomaticUiRefresh({ pending: () => pending, apply }); + await vi.advanceTimersByTimeAsync(9 * 60_000); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" })); + pending = true; + controller.check(); + await vi.advanceTimersByTimeAsync(10 * 60_000 - 1); + expect(apply).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(apply).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(20 * 60_000); + expect(apply).toHaveBeenCalledOnce(); + }); + + it("retries an eligible refresh after unsafe local state clears", async () => { + const apply = vi.fn(async () => {}); + const guest = document.createElement("webview"); + document.body.append(guest); + controller = startAutomaticUiRefresh({ pending: () => true, apply }); + await vi.advanceTimersByTimeAsync(10 * 60_000); + expect(apply).not.toHaveBeenCalled(); + guest.remove(); + await vi.advanceTimersByTimeAsync(30_000); + expect(apply).toHaveBeenCalledOnce(); + }); + + it("does not interrupt IME composition or outlive its owner", async () => { + const apply = vi.fn(async () => {}); + controller = startAutomaticUiRefresh({ pending: () => true, apply }); + window.dispatchEvent(new CompositionEvent("compositionstart")); + await vi.advanceTimersByTimeAsync(10 * 60_000); + expect(apply).not.toHaveBeenCalled(); + controller.dispose(); + window.dispatchEvent(new CompositionEvent("compositionend")); + await vi.advanceTimersByTimeAsync(20 * 60_000); + expect(apply).not.toHaveBeenCalled(); + }); +}); + +describe("refresh preservation", () => { + it.each(["webview", "iframe", "[data-thread-terminal-drawer]", '[role="dialog"]'])( + "defers while %s owns live state", + (selector) => { + const node = document.createElement(selector.startsWith("[") ? "div" : selector); + if (selector === "[data-thread-terminal-drawer]") node.dataset.threadTerminalDrawer = ""; + if (selector === '[role="dialog"]') node.setAttribute("role", "dialog"); + document.body.append(node); + expect(prepareUiRefresh()).toBe(false); + expect(mocks.timeline).not.toHaveBeenCalled(); + }, + ); + + it("defers when drafts cannot be durably saved or an editor vetoes its snapshot", () => { + mocks.drafts.mockReturnValue(false); + expect(prepareUiRefresh()).toBe(false); + mocks.drafts.mockReturnValue(true); + const veto = (event: Event) => event.preventDefault(); + window.addEventListener(PREPARE_UI_REFRESH_EVENT, veto); + expect(prepareUiRefresh()).toBe(false); + window.removeEventListener(PREPARE_UI_REFRESH_EVENT, veto); + expect(prepareUiRefresh()).toBe(true); + expect(mocks.timeline).toHaveBeenCalledOnce(); + }); + + it("keeps unsaved form input, recordings and unhandled scroll positions intact", () => { + const input = document.createElement("input"); + input.value = "unsaved edit"; + document.body.append(input); + expect(prepareUiRefresh()).toBe(false); + input.remove(); + mocks.recordings.mockReturnValue(new Set(["recording"])); + expect(prepareUiRefresh()).toBe(false); + mocks.recordings.mockReturnValue(new Set()); + const sidebar = document.createElement("div"); + sidebar.scrollTop = 140; + document.body.append(sidebar); + expect(prepareUiRefresh()).toBe(false); + sidebar.className = "messages-timeline-scroll"; + expect(prepareUiRefresh()).toBe(true); + }); + + it("allows hidden controls that reflect saved selections", () => { + const hidden = document.createElement("input"); + hidden.type = "hidden"; + hidden.value = "selected-model"; + const toggle = document.createElement("input"); + toggle.type = "checkbox"; + toggle.checked = true; + document.body.append(hidden, toggle); + expect(prepareUiRefresh()).toBe(true); + }); + + it("ignores Base UI's hidden text controls while retaining visible edits", () => { + for (const value of ["all", "full-access"]) { + const hidden = document.createElement("input"); + hidden.type = "text"; + hidden.value = value; + hidden.setAttribute("aria-hidden", "true"); + hidden.style.cssText = "position:fixed;left:-1px;top:-1px;width:1px;height:1px"; + document.body.append(hidden); + } + expect(prepareUiRefresh()).toBe(true); + const edited = document.createElement("input"); + edited.value = "Unsaved settings edit"; + document.body.append(edited); + expect(prepareUiRefresh()).toBe(false); + }); + + it("ignores retained closed Base UI popovers but defers for an open dialog", () => { + for (const attribute of ["data-closed", "inert", "hidden"]) { + const popover = document.createElement("div"); + popover.setAttribute("role", "dialog"); + popover.setAttribute(attribute, ""); + const input = document.createElement("input"); + input.value = "retained menu selection"; + popover.append(input); + const editor = document.createElement("div"); + editor.className = "cm-editor"; + popover.append(editor); + popover.scrollTop = 30; + document.body.append(popover); + } + expect(prepareUiRefresh()).toBe(true); + const open = document.createElement("div"); + open.setAttribute("role", "dialog"); + document.body.append(open); + expect(prepareUiRefresh()).toBe(false); + }); +}); diff --git a/apps/web/src/uiRefresh.ts b/apps/web/src/uiRefresh.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec459dc8e7d001f4a8b819c8dd0cbdb561d098c7 --- /dev/null +++ b/apps/web/src/uiRefresh.ts @@ -0,0 +1,134 @@ +import { prepareComposerDraftsForReload } from "./composerDraftStore"; +import { persistTimelinePositionsForReload } from "./components/chat/timelineScrollAnchoring"; +import { readActiveBrowserRecordingTabIds } from "./browser/browserRecording"; +import { useThemeEditorStore } from "./components/settings/themeEditorStore"; + +export const PREPARE_UI_REFRESH_EVENT = "t3:prepare-ui-refresh"; +const HIDDEN_UI_SELECTOR = '[data-closed], [aria-hidden="true"], [hidden], [inert]'; + +/** DOM-owned guests and terminal viewports cannot survive a renderer replacement. */ +export function prepareUiRefresh(): boolean { + const selection = document.getSelection(); + const selectionElement = selection?.anchorNode?.parentElement; + if ( + document.querySelector( + "webview, iframe, [data-thread-terminal-drawer], [data-timeline-loading]", + ) || + [...document.querySelectorAll('[role="dialog"], [role="alertdialog"]')].some( + (dialog) => !dialog.closest(HIDDEN_UI_SELECTOR), + ) || + readActiveBrowserRecordingTabIds().size > 0 || + useThemeEditorStore.getState().session !== null || + (selection && + !selection.isCollapsed && + !selectionElement?.closest("[data-ui-refresh-composer]")) || + [...document.querySelectorAll(".cm-editor")].some( + (editor) => + !editor.closest("[data-ui-refresh-composer]") && + !editor.closest(HIDDEN_UI_SELECTOR), + ) || + [ + ...document.querySelectorAll( + '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]', + ), + ].some( + (element) => + !element.closest("[data-ui-refresh-composer]") && + !element.closest(HIDDEN_UI_SELECTOR) && + (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement + ? element.value.length > 0 + : (element.textContent?.length ?? 0) > 0), + ) || + [...document.querySelectorAll("*")].some( + (element) => + (element.scrollTop !== 0 || element.scrollLeft !== 0) && + !element.closest(HIDDEN_UI_SELECTOR) && + !element.matches(".messages-timeline-scroll, [data-tool-group-scroll]") && + !(element.matches(".cm-scroller") && element.closest("[data-ui-refresh-composer]")), + ) + ) + return false; + + try { + if (!window.dispatchEvent(new Event(PREPARE_UI_REFRESH_EVENT, { cancelable: true }))) + return false; + if (!prepareComposerDraftsForReload()) return false; + persistTimelinePositionsForReload(); + return true; + } catch { + return false; + } +} + +export function startAutomaticUiRefresh(options: { + readonly pending: () => boolean; + readonly apply: () => Promise; +}) { + let lastActivity = Date.now(); + let unfocusedSince = document.hasFocus() && !document.hidden ? null : lastActivity; + let composing = false; + let applying = false; + let disposed = false; + let timer: ReturnType | undefined; + + const check = () => { + clearTimeout(timer); + if (disposed || applying || !options.pending()) return; + const now = Date.now(); + const idleDelay = 10 * 60_000 - (now - lastActivity); + const unfocusedDelay = unfocusedSince === null ? Infinity : 60_000 - (now - unfocusedSince); + const delay = Math.min(idleDelay, unfocusedDelay); + if (delay > 0) { + timer = setTimeout(check, delay); + return; + } + if (composing || !prepareUiRefresh()) { + timer = setTimeout(check, 30_000); + return; + } + applying = true; + void options + .apply() + .catch(() => undefined) + .finally(() => { + applying = false; + if (!disposed) timer = setTimeout(check, 30_000); + }); + }; + const activity = () => { + lastActivity = Date.now(); + check(); + }; + const focus = () => { + unfocusedSince = + document.hasFocus() && !document.hidden ? null : (unfocusedSince ?? Date.now()); + activity(); + }; + const composition = (event: Event) => { + composing = event.type === "compositionstart"; + activity(); + }; + const activityEvents = ["pointerdown", "pointermove", "keydown", "wheel", "input", "touchstart"]; + for (const name of activityEvents) + window.addEventListener(name, activity, { capture: true, passive: true }); + window.addEventListener("focus", focus); + window.addEventListener("blur", focus); + document.addEventListener("visibilitychange", focus); + window.addEventListener("compositionstart", composition, true); + window.addEventListener("compositionend", composition, true); + check(); + + return { + check, + dispose: () => { + disposed = true; + clearTimeout(timer); + for (const name of activityEvents) window.removeEventListener(name, activity, true); + window.removeEventListener("focus", focus); + window.removeEventListener("blur", focus); + document.removeEventListener("visibilitychange", focus); + window.removeEventListener("compositionstart", composition, true); + window.removeEventListener("compositionend", composition, true); + }, + }; +} diff --git a/docs/operations/development.md b/docs/operations/development.md index 803c335a532fbac2e931b07a8166d1c9b3d2c435..ac427db9428a24a9c8efaf59540f09623ed1af1d 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -214,3 +214,162 @@ Licensed Name Sans and Name Mono files live in the ignored build workspace before building with these fonts. The web and desktop builds bundle the local files automatically; source checkouts without them use system fallback fonts. Keep the font binaries out of source history. + +Create `~/.config/t3-fork-release.json` with permissions `600`: + +```json +{ + "feedUrl": "https://files.example.com/read-only-share/", + "uploadUrl": "https://files.example.com/private-write-share/", + "uploadApiKey": "", + "signingIdentity": "<40-character certificate fingerprint>", + "signingKeychain": "/Users/you/.config/t3-fork-signing.keychain-db" +} +``` + +Only the public feed URL is bundled in the app. Keep the upload URL, API key, and +signing credentials outside the repository. Uploads authenticate through Copyparty's +`PW` header. The publishing account needs to create, overwrite, and delete files; +the public share needs GET, HEAD, and Copyparty's `?ls` listing access to the same +files. No application server or package registry is required for the feed. + +Import the signing certificate's `.p12` into a dedicated build keychain so signing +works from Terminal without granting access to other login-keychain keys. Put its +password in a private file such as `/tmp/certpass.txt`: + +```sh +umask 077 +fork_keychain="$HOME/.config/t3-fork-signing.keychain-db" +openssl rand -base64 32 > "$fork_keychain.password" +security create-keychain -p "$(cat "$fork_keychain.password")" "$fork_keychain" +security unlock-keychain -p "$(cat "$fork_keychain.password")" "$fork_keychain" +security import "$HOME/Certificates.p12" -k "$fork_keychain" \ + -P "$(cat /tmp/certpass.txt)" -T /usr/bin/codesign +security set-key-partition-list -S apple-tool:,apple: -s \ + -k "$(cat "$fork_keychain.password")" "$fork_keychain" +security find-identity -v -p codesigning "$fork_keychain" +``` + +Copy the certificate fingerprint into `signingIdentity`. Releases unlock this +keychain using its adjacent `.password` file. The fingerprint avoids selecting +an older or revoked certificate with the same display name. + +Direct downloads that pass Gatekeeper need a **Developer ID Application** +certificate from the same Apple Developer account used for iOS, plus Apple's +notarization service. Apple Development and iOS distribution certificates do not +replace Developer ID. Store notarization credentials in the build keychain with +`xcrun notarytool store-credentials knife-notary --keychain "$fork_keychain"`, +then add `"notarizationKeychainProfile": "knife-notary"` to the private release +config and select the Developer ID certificate fingerprint. The release checks +the certificate and credentials before building, signs with the hardened runtime, +notarizes and staples the app, then verifies Gatekeeper acceptance. It stops if +any of those checks fail. The notarization profile stays outside the repository. + +```sh +vp run fork:build +vp run fork:publish release/fork/0.0. +# Build and publish together: +vp run fork:release +``` + +Builds still verify a signed Apple Silicon app locally. Publishing separates UI, +desktop application code, portable server code, and static assets into checksum-verified archives; +unchanged archives are reused. The full app ZIP is uploaded only when Electron, +the bootstrap, signing policy, or shell resources change. A UI edit therefore uploads +the UI code archive and small release manifests. Static models, fonts, and images +are cached separately across UI edits. Versions increase independently of +upstream versions and rebases. Publishing makes every referenced archive readable, +then creates the immutable version manifest. Clients select the newest completed +version from the directory listing. Run one publisher at a time: installer URLs are +mutable, so simultaneous installer replacement is unsupported. +The root `preadme.md` links to the available platform installers and is refreshed +after a completed publication. Version-free download entrypoints use +`The-Knife-darwin-arm64.zip`, `The-Knife-linux-x64.AppImage`, and +`The-Knife-win32-x64.exe`. Publication uploads replacements under temporary names, +verifies their checksums, then promotes them with WebDAV MOVE. Native versions +and checksums remain in the completion JSON and per-version updater YAML. +Before replacement, conflicting completed releases and unreferenced updater YAML +are retired. The two newest nonconflicting completed releases and their referenced +components are retained; unrelated files and incomplete uploads are left alone. +If publication fails after retirement, retry the same local release directory. +Its atomically written `publication.json` preserves the merged release and cleanup +ownership until the exact completion and all dependencies are verified. + +Build Linux installers on Linux for the native capture and credential helpers. +Windows builds can run natively, or use matching prebuilt helpers with +`T3CODE_DESKTOP_WINDOWS_RUNTIME` pointing to a Wine launcher for the isolated +backend probe. Native Windows builds also exercise the packaged file index. +Export the packaged resources alongside the installer and updater manifest: + +```sh +# On Linux (use --platform win --target nsis on Windows): +T3CODE_DESKTOP_FORK_RESOURCES=true T3CODE_DESKTOP_UPDATE_URL=https://files.example.com/read-only-share/ \ + node scripts/build-desktop-artifact.ts --platform linux --target AppImage \ + --arch x64 --build-version 0.0. --output-dir /path/to/native-build +``` + +Copy that output directory to the publishing host, then run: + +```sh +node scripts/release-fork.ts ingest linux-x64 /path/to/native-build +# Windows: +node scripts/release-fork.ts ingest win32-x64 /path/to/native-build +``` + +Ingestion verifies the installer and resource versions, separates reusable +components, and publishes a new completed manifest. Each platform keeps its own +backend, UI, and asset versions until its next native build is published. + +Install the generated `.app` once on each Mac to bootstrap this updater. Subsequent +code updates download without replacing Electron. Application code and shell changes +require a confirmed restart. UI updates can apply after one minute unfocused or ten +minutes idle once restorable state is saved; live browser guests and other state +that cannot be restored defer automatic refresh. +The Knife retains the shipped bundle ID, data directory, credential storage, and +Connect configuration. Its display name and new artifacts use The Knife; the +existing updater accepts the new artifact filenames. The initial +build must include any public Connect configuration used by that installation; +the build loads the repository's local environment files. + +`security find-identity -v -p codesigning` lists usable signing identities. A +certificate needs its private key in the keychain; a `.p8` App Store Connect key +alone cannot sign an app. Keep the same signing identity across releases. A local +Apple Development identity still signs private builds, with a build warning that +Gatekeeper may block downloaded copies. Native passkeys additionally need the +provisioned signing procedure linked above. + +### Private server container + +The [Compose configuration](../../compose.yaml) runs a persistent Knife server, +including the private shared-account authority. The public +`knife-server-linux-amd64.tar.gz` deployment bundle contains the image, this Compose +configuration, and a public-feed environment example. Verify it against its +adjacent `.sha256` file, extract it, and load `image.tar` with +`docker image load -i image.tar`. Copy `.env.example` to `.env` and prepare the +archive and workspace directories with write access for UID 1000. Start it with +`docker compose up -d --no-build`; the deployment bundle needs no source checkout +or registry account. + +To build the base image from source, run `docker build -t knife:local .` on the +target Linux architecture. `docker image save knife:local` exports a loadable image. +A registry can supply `KNIFE_IMAGE` instead. + +Set `KNIFE_UPDATE_URL` to the public release directory, +`KNIFE_ARCHIVES_DIRECTORY` and `KNIFE_WORKSPACES_DIRECTORY` to host directories, +then run `docker compose up -d`. The default port binds to localhost for a reverse +proxy; set `KNIFE_BIND_ADDRESS` for a different binding. `/data` and the provider home +use persistent volumes. Keep both private and backed up; account credentials never +belong on the public build share. + +At startup the container downloads only missing server/UI/assets archives from the same +feed, checks their hashes and native dependency contract, and runs the selected +release. `docker compose restart knife` applies a published code update after +confirming the restart with Clover. While running it prefetches new archives every +four minutes without restarting or replacing the running code. A feed outage uses the cached release or bundled +baseline; a checksum or dependency mismatch stops startup. Rebuild the base image +when the server's locked native dependency closure or container bootstrap changes. + +Enroll execution hosts with the private authority using the existing +[`t3 shared` commands](../user/shared-server.md). Each machine gets its own +revocable session credential. The authority owns Codex token refreshes; Claude +accounts need setup tokens. Connected hosts synchronize accounts automatically. diff --git a/infra/container/knife b/infra/container/knife new file mode 100755 index 0000000000000000000000000000000000000000..872c3ef4c52ded9becae7ce99553bda53c063125 --- /dev/null +++ b/infra/container/knife @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +if [ "${1:-serve}" = serve ]; then + node /opt/knife/scripts/container-runtime.ts +fi + +runtime="${T3CODE_HOME:-/data}/runtime/knife-container/current" +if [ ! -f "$runtime/apps/server/dist/bin.mjs" ] || + [ "$(cat "$runtime/knife-server-runtime")" != "$(cat /opt/knife/knife-server-runtime)" ]; then + runtime=/opt/knife +fi +if [ -f "$runtime/version" ]; then + T3CODE_VERSION_OVERRIDE="$(cat "$runtime/version")" + export T3CODE_VERSION_OVERRIDE +fi +if [ "${1:-serve}" = serve ]; then + exec node --import /opt/knife/scripts/container-runtime.ts "$runtime/apps/server/dist/bin.mjs" "$@" +fi +exec node "$runtime/apps/server/dist/bin.mjs" "$@" diff --git a/package.json b/package.json index 7b15acd6f0a335191ed22428a6c412abcb793ab6..2886a3521b523c098df71de7bd0a2446ed44b9ab 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,9 @@ "fmt": "vp fmt", "fmt:check": "vp fmt --check", "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", + "fork:build": "node scripts/release-fork.ts build", + "fork:publish": "node scripts/release-fork.ts publish", + "fork:release": "node scripts/release-fork.ts release", "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 6db8b8a491290028e757c2da1ece9aeece24ce13..f05f11c1d1a8f04b9429c2d7a05701149bbb4ac3 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -290,6 +290,8 @@ export interface DesktopUpdateState { * not install it again; the user can retry it from the update button. */ notAppliedVersion?: string; + installKind?: "ui" | "app"; + uiVersion?: string; } export interface DesktopUpdateReleaseNote { @@ -322,6 +324,8 @@ export const DesktopUpdateStateSchema = Schema.Struct({ errorContext: Schema.NullOr(Schema.Literals(["check", "download", "install"])), canRetry: Schema.Boolean, notAppliedVersion: Schema.optionalKey(Schema.String), + installKind: Schema.optionalKey(Schema.Literals(["ui", "app"])), + uiVersion: Schema.optionalKey(Schema.String), }); /** The desktop app's `t3` command on PATH, managed from Settings. */ diff --git a/packages/shared/package.json b/packages/shared/package.json index 7a13c4f4b9e160f77dfaf39f6f56992d0390d0ca..b453dad3f3a998b64e8fe4252cebaf146d6800ab 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./forkUpdateDirectory": { + "types": "./src/forkUpdateDirectory.ts", + "import": "./src/forkUpdateDirectory.ts" + }, "./githubMarkdownLinks": { "types": "./src/githubMarkdownLinks.ts", "import": "./src/githubMarkdownLinks.ts" diff --git a/packages/shared/src/forkUpdateDirectory.test.ts b/packages/shared/src/forkUpdateDirectory.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d646ed70eae1b1fa174f8c71a8682be6bb11020 --- /dev/null +++ b/packages/shared/src/forkUpdateDirectory.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from "vite-plus/test"; +import { latestForkRelease } from "./forkUpdateDirectory.ts"; + +it("selects the newest immutable release regardless of upload completion order", () => { + const listing = { + files: [ + { href: "0.0.2000000001.json" }, + { href: "0.0.2000000000.json" }, + { href: "0.0.3000000000.json.PARTIAL" }, + { href: "../0.0.4000000000.json" }, + { href: "0.0.5000000000-mac.yml" }, + ], + }; + expect(latestForkRelease(listing)).toBe("0.0.2000000001"); + expect(latestForkRelease({ files: [] })).toBeUndefined(); +}); diff --git a/packages/shared/src/forkUpdateDirectory.ts b/packages/shared/src/forkUpdateDirectory.ts new file mode 100644 index 0000000000000000000000000000000000000000..493b12645a82cf3c9fba6a6cc995455aaf5f503a --- /dev/null +++ b/packages/shared/src/forkUpdateDirectory.ts @@ -0,0 +1,53 @@ +import * as Schema from "effect/Schema"; + +const ForkVersion = Schema.String.check(Schema.isPattern(/^0\.0\.[0-9]+$/)); + +export const ForkReleaseListing = Schema.Struct({ + files: Schema.Array(Schema.Struct({ href: Schema.String })), +}); + +export function latestForkRelease(listing: typeof ForkReleaseListing.Type): string | undefined { + let latest: string | undefined; + for (const { href } of listing.files) { + const version = /^(0\.0\.[0-9]+)\.json$/.exec(href)?.[1]; + if (version && (!latest || BigInt(version.slice(4)) > BigInt(latest.slice(4)))) + latest = version; + } + return latest; +} + +export const FORK_RUNTIME_MANIFEST_FILE = "knife-runtime.json"; +export const ForkRuntimeManifest = Schema.Struct({ + version: ForkVersion, + sha256: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), + shell: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), +}); + +export const ForkArtifact = Schema.Struct({ + file: Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)), + sha256: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), + size: Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)), +}); + +export const ForkReleaseManifest = Schema.Struct({ + version: ForkVersion, + ui: ForkArtifact, + assets: ForkArtifact, + server: Schema.Struct({ + ...ForkArtifact.fields, + version: ForkVersion, + runtime: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), + }), + desktop: Schema.Record( + Schema.String, + Schema.Struct({ + version: ForkVersion, + ui: ForkArtifact, + assets: ForkArtifact, + shell: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), + runtime: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), + code: ForkArtifact, + app: Schema.Struct({ ...ForkArtifact.fields, version: ForkVersion }), + }), + ), +}); diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 272bbae83ee36d1b20ffb87f8937d4878108676d..856539c74ed2d8bf8790358813d776168b2ef436 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -24,6 +24,7 @@ import { createStagePatchedDependencies, readWorkspaceConfig, createBuildConfig, + exportForkResources, DESKTOP_ELECTRON_LANGUAGES, DESKTOP_FILE_EXCLUSIONS, DESKTOP_EXTRA_RESOURCES, @@ -98,6 +99,54 @@ import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +it.effect.each(["linux", "win"] as const)( + "exports the actual %s packaged archives and their native sidecars", + (platform) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped(); + const stageDistDir = path.join(root, "dist"); + const resources = path.join(stageDistDir, `${platform}-unpacked/resources`); + const files = [ + "app.asar", + "app.asar.unpacked/native.node", + ...(platform === "win" ? ["server.asar", "server.asar.unpacked/server.node"] : []), + ]; + for (const file of [...files, "unrelated-electron-resource"]) { + yield* fs.makeDirectory(path.dirname(path.join(resources, file)), { recursive: true }); + yield* fs.writeFileString(path.join(resources, file), file); + } + const archive = yield* exportForkResources({ + stageDistDir, + outputDir: path.join(root, "output"), + platform, + arch: "x64", + serverRuntime: "a".repeat(64), + }); + assert.equal( + path.basename(archive), + `knife-resources-${platform === "win" ? "win32" : platform}-x64.tar.gz`, + ); + const extracted = path.join(root, "extracted"); + yield* fs.makeDirectory(extracted); + const child = yield* spawner.spawn( + ChildProcess.make("tar", ["-xzf", archive, "-C", extracted]), + ); + assert.equal(Number(yield* child.exitCode), 0); + for (const file of files) + assert.equal(yield* fs.readFileString(path.join(extracted, file)), file); + assert.equal( + yield* fs.readFileString(path.join(extracted, "knife-server-runtime")), + "a".repeat(64), + ); + assert.equal(yield* fs.exists(path.join(extracted, "unrelated-electron-resource")), false); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + // Keeps pnpm from auto-installing TypeScript, a types-only peer, into the app. const stagePackageExtensions = { "electron-webauthn": { peerDependenciesMeta: { typescript: { optional: true } } }, @@ -267,6 +316,77 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu }); it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { + it.effect("packages a signed private feed instead of the upstream release channel", () => + Effect.gen(function* () { + const config = yield* createBuildConfig( + "mac", + "zip", + "0.0.1791936000", + false, + false, + undefined, + undefined, + ); + assert.deepStrictEqual(config.publish, [ + { provider: "generic", url: "https://updates.example.test/fork/?ls=" }, + ]); + assert.equal(config.forceCodeSigning, true); + assert.containsSubset(config.mac, { + identity: "Apple Development: Private Owner (TESTTEAM)", + type: "development", + }); + assert.property(config.mac, "sign"); + assert.notProperty(config.mac, "provisioningProfile"); + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + GITHUB_REPOSITORY: "pingdotgg/t3code", + T3CODE_DESKTOP_UPDATE_URL: "https://updates.example.test/fork/", + T3CODE_DESKTOP_MAC_SIGN_IDENTITY: "Apple Development: Private Owner (TESTTEAM)", + }, + }), + ), + ), + ), + ); + + it.effect("notarizes Developer ID fork builds without requiring passkey provisioning", () => + Effect.gen(function* () { + const config = yield* createBuildConfig( + "mac", + "zip", + "0.0.1791936000", + false, + false, + undefined, + undefined, + ); + + assert.equal(config.forceCodeSigning, true); + assert.containsSubset(config.mac, { + identity: "A".repeat(40), + type: "distribution", + hardenedRuntime: true, + notarize: true, + }); + assert.property(config.mac, "sign"); + assert.notProperty(config.mac, "provisioningProfile"); + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_DESKTOP_MAC_SIGN_IDENTITY: "A".repeat(40), + T3CODE_DESKTOP_MAC_SIGN_TYPE: "distribution", + }, + }), + ), + ), + ), + ); + it("resolves the dedicated nightly updater channel from nightly versions", () => { assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly"); assert.equal(resolveDesktopUpdateChannel("0.0.17"), "latest"); @@ -1608,6 +1728,53 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ); }); + it.effect("checks the isolated Windows bundle through an explicit Wine runtime", () => { + const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command)) { + return Effect.die(new Error("Expected a standard command")); + } + commands.push(command); + return Effect.succeed(mockProcess(0)); + }), + ); + return Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, + }); + const probes = commands.filter((command) => command.command === "/tools/wine"); + assert.lengthOf(probes, 1); + const [isolated] = probes; + if (isolated === undefined) return assert.fail("The Windows bundle probe must run"); + assert.isTrue( + probes.every((command) => command.args[0]?.endsWith(fixture.appExecutableName)), + ); + assert.equal(isolated.args.at(-1), "--version"); + assert.isTrue( + isolated.args.some((arg) => arg.startsWith("Z:\\") && arg.endsWith("bin.mjs")), + ); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + spawnerLayer, + Layer.succeed(HostProcess.Platform, "darwin"), + Layer.succeed(HostProcess.Architecture, "arm64"), + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { T3CODE_DESKTOP_WINDOWS_RUNTIME: "/tools/wine" } }), + ), + ), + ), + ); + }); + it.effect("refuses a Linux build on a host that cannot build the browser secret helper", () => { const commands: Array<{ readonly command: string }> = []; const spawnerLayer = Layer.succeed( @@ -1670,7 +1837,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); assert.isFalse( - commands.some((command) => command.options.env?.ELECTRON_RUN_AS_NODE === "1"), + commands.some((command) => command.command.endsWith(fixture.appExecutableName)), ); assert.isTrue( commands.some( diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 8b7d22490674687e277eea84f50cf1d1fe69a0f7..3692e5443d18da7dcec2dd64737d523003188c9d 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { fingerprintForkRuntime, fingerprintForkServerRuntime } from "./lib/fork-runtime.ts"; +import { FORK_RUNTIME_MANIFEST_FILE } from "@t3tools/shared/forkUpdateDirectory"; // @effect-diagnostics nodeBuiltinImport:off - Node's typed junction API avoids Windows symlink privileges while keeping the probe isolated. import * as NodeFSP from "node:fs/promises"; @@ -845,23 +847,19 @@ const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* ( return { stdout, stderr, exitCode } as const; }); -const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRoot: string) { - const result = yield* spawnAndCollectOutput( - ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], { - cwd: repoRoot, - }), - ).pipe( - Effect.orElseSucceed(() => ({ - stdout: "", - stderr: "", - exitCode: 1, - })), - ); - - if (result.exitCode !== 0) { - return "unknown"; - } - const hash = result.stdout.trim(); +const resolveCommitHash = Effect.fn("resolveCommitHash")(function* (repoRoot: string) { + const configuredHash = yield* Config.String("T3CODE_COMMIT_HASH").pipe(Config.option); + const output = yield* Option.match(configuredHash, { + onSome: Effect.succeed, + onNone: () => + spawnAndCollectOutput( + ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], { cwd: repoRoot }), + ).pipe( + Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), + Effect.orElseSucceed(() => ""), + ), + }); + const hash = output.trim(); if (!/^[0-9a-f]{7,40}$/i.test(hash)) { return "unknown"; } @@ -2158,7 +2156,11 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin ); const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")( - function* (input: { readonly asarPath: string; readonly verbose: boolean }) { + function* (input: { + readonly asarPath: string; + readonly executablePath: string; + readonly verbose: boolean; + }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -2206,14 +2208,23 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node // and the bun adapters are covered by the shared runtime-external closure // and emitted-bundle checks. + const wine = Option.getOrUndefined( + yield* Config.String("T3CODE_DESKTOP_WINDOWS_RUNTIME").pipe(Config.option), + ); + const entry = wine === undefined ? entryPoint : `Z:${entryPoint.replaceAll("/", "\\")}`; yield* runCommand( ChildProcess.make( - process.execPath, + wine ?? process.execPath, // --no-global-search-paths because clearing NODE_PATH is not enough: // CommonJS resolution still falls back to $HOME/.node_modules, // $HOME/.node_libraries and the install prefix, so a globally installed // copy of a missing dependency would quietly satisfy this check. - ["--no-global-search-paths", entryPoint, "--version"], + [ + ...(wine === undefined ? [] : [input.executablePath]), + "--no-global-search-paths", + entry, + "--version", + ], { cwd: probeApp, stdout: "pipe", @@ -2221,7 +2232,7 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel // NODE_PATH would let a createRequire call inside the bundle resolve // a missing external from outside the packaged tree, which is the // whole thing this is trying to rule out. - env: { ...process.env, NODE_PATH: "" }, + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", NODE_PATH: "" }, }, ), { @@ -2800,8 +2811,12 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( }; const updateChannel = resolveDesktopUpdateChannel(version); if (!isDesktopPreviewVersion(version)) { + const updateUrl = yield* Config.URL("T3CODE_DESKTOP_UPDATE_URL").pipe(Config.option); const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); - if (publishConfig) { + if (Option.isSome(updateUrl)) { + updateUrl.value.searchParams.set("ls", ""); + buildConfig.publish = [{ provider: "generic", url: updateUrl.value.href }]; + } else if (publishConfig) { buildConfig.publish = [publishConfig]; } else if (mockUpdates) { buildConfig.publish = [ @@ -2816,6 +2831,14 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( if (platform === "mac") { const path = yield* Path.Path; const repoRoot = yield* RepoRoot; + const configuredIdentity = yield* Config.String("T3CODE_DESKTOP_MAC_SIGN_IDENTITY").pipe( + Config.option, + ); + const identity = Option.getOrUndefined(configuredIdentity)?.trim(); + const signingType = yield* Config.Literals( + ["development", "distribution"], + "T3CODE_DESKTOP_MAC_SIGN_TYPE", + ).pipe(Config.withDefault("development")); buildConfig.mac = { target: target === "dmg" ? [target, "zip"] : [target], icon: "icon.icns", @@ -2851,7 +2874,15 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( role: "Viewer", }, ], - ...(signed ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}), + ...(signed || identity ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}), + ...(identity + ? { + identity, + type: signingType, + hardenedRuntime: true, + notarize: signingType === "distribution", + } + : {}), ...(macPasskeySigning ? { entitlements: macPasskeySigning.entitlementsPath, @@ -2859,6 +2890,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( } : {}), }; + if (identity) buildConfig.forceCodeSigning = true; } if (platform === "mac" && target === "dmg") { @@ -3524,6 +3556,7 @@ export const validateWindowsPackagedPayload = Effect.fn( yield* verifyPackagedBundleIsSelfContained({ asarPath, + executablePath: path.join(packagedAppDir, input.appExecutableName), verbose: input.verbose ?? false, }); @@ -3533,6 +3566,53 @@ export const validateWindowsPackagedPayload = Effect.fn( return { packagedAppDir, fileCount, unpackedFiles } as const; }); +export const exportForkResources = Effect.fn("desktopArtifact.exportForkResources")( + function* (input: { + readonly stageDistDir: string; + readonly outputDir: string; + readonly platform: "linux" | "win"; + readonly arch: typeof BuildArch.Type; + readonly serverRuntime: string; + }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries = yield* fs.readDirectory(input.stageDistDir); + const unpacked = entries.find((entry) => entry.endsWith("-unpacked")); + const resources = path.join( + input.stageDistDir, + unpacked ?? `${input.platform}-unpacked`, + "resources", + ); + const required = input.platform === "win" ? ["app.asar", "server.asar"] : ["app.asar"]; + const members: string[] = []; + for (const file of required) { + yield* fs.stat(path.join(resources, file)); + members.push(file); + if (yield* fs.exists(path.join(resources, `${file}.unpacked`))) + members.push(`${file}.unpacked`); + } + const platform = input.platform === "win" ? "win32" : "linux"; + const archive = path.join(input.outputDir, `knife-resources-${platform}-${input.arch}.tar.gz`); + const metadata = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString(path.join(metadata, "knife-server-runtime"), input.serverRuntime); + yield* fs.makeDirectory(input.outputDir, { recursive: true }); + yield* runCommand( + ChildProcess.make("tar", [ + "-czf", + archive, + "-C", + resources, + ...members, + "-C", + metadata, + "knife-server-runtime", + ]), + { label: "Archive desktop resources for publishing", verbose: false }, + ); + return archive; + }, +); + const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options: ResolvedBuildOptions, ) { @@ -3611,7 +3691,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const appVersion = options.version ?? serverPackageJson.version; const iconAssets = resolveDesktopBuildIconAssets(appVersion); - const commitHash = yield* resolveGitCommitHash(repoRoot); + const commitHash = yield* resolveCommitHash(repoRoot); const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped; const stageRoot = yield* mkdir({ prefix: `t3code-desktop-${options.platform}-stage-`, @@ -3964,6 +4044,32 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( }); } + { + const runtime = yield* Effect.tryPromise(async () => { + const input = { + platform: options.platform, + arch: options.arch, + signingFiles: + options.platform === "mac" + ? [ + path.join(repoRoot, "scripts/sign-macos.ts"), + ...(macEntitlementsPath ? [macEntitlementsPath] : []), + ...(macPasskeySigning ? [macPasskeySigning.provisioningProfilePath] : []), + ] + : [], + ...(options.platform === "win" ? { serverDirectory: path.join(stageRoot, "server") } : {}), + }; + return { + sha256: await fingerprintForkRuntime(stageAppDir, input), + shell: await fingerprintForkRuntime(stageAppDir, { ...input, shellOnly: true }), + }; + }); + yield* fs.writeFileString( + path.join(stageAppDir, FORK_RUNTIME_MANIFEST_FILE), + yield* encodeJsonString({ version: appVersion, ...runtime }), + ); + } + // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") // as enabled, so copy the host env and scrub empty values instead of relying // on `extendEnv` merging. @@ -4076,6 +4182,20 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* fs.makeDirectory(options.outputDir, { recursive: true }); const copiedArtifacts: string[] = []; + if ( + options.platform !== "mac" && + (yield* Config.Boolean("T3CODE_DESKTOP_FORK_RESOURCES").pipe(Config.withDefault(false))) + ) { + copiedArtifacts.push( + yield* exportForkResources({ + stageDistDir, + outputDir: options.outputDir, + platform: options.platform, + arch: options.arch, + serverRuntime: yield* Effect.tryPromise(() => fingerprintForkServerRuntime(repoRoot)), + }), + ); + } for (const entry of stageEntries) { const from = path.join(stageDistDir, entry); const stat = yield* fs.stat(from).pipe(Effect.orElseSucceed(() => null)); diff --git a/scripts/container-runtime.test.ts b/scripts/container-runtime.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6fee9f8d2ee58a5128f54137d6dafc7a2d9c450c --- /dev/null +++ b/scripts/container-runtime.test.ts @@ -0,0 +1,460 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Exercises real HTTP bodies and tar archives, including deterministic connection interruption. +import * as NodeAssert from "node:assert/strict"; +import * as NodeCrypto from "node:crypto"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeHttp from "node:http"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; +import * as NodeURL from "node:url"; +import { afterEach, it, vi } from "vite-plus/test"; + +import { prepareRuntime } from "./container-runtime.ts"; + +const run = NodeUtil.promisify(NodeChildProcess.execFile); +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + vi.restoreAllMocks(); + for (const cleanup of cleanups.splice(0).toReversed()) await cleanup(); +}); + +async function fixture() { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "knife-container-")); + cleanups.push(() => NodeFSP.rm(root, { recursive: true, force: true })); + const baseDirectory = NodePath.join(root, "base"); + const cacheDirectory = NodePath.join(root, "cache"); + await NodeFSP.mkdir(NodePath.join(baseDirectory, "apps/server/node_modules/container-native"), { + recursive: true, + }); + await NodeFSP.writeFile(NodePath.join(baseDirectory, "knife-server-runtime"), "a".repeat(64)); + await NodeFSP.writeFile( + NodePath.join(baseDirectory, "apps/server/node_modules/container-native/package.json"), + '{"main":"index.js"}', + ); + await NodeFSP.writeFile( + NodePath.join(baseDirectory, "apps/server/node_modules/container-native/index.js"), + 'module.exports = "native dependency resolved";', + ); + const serverRoot = NodePath.join(root, "server"); + const uiRoot = NodePath.join(root, "ui"); + const assetsRoot = NodePath.join(root, "assets"); + await NodeFSP.mkdir(NodePath.join(serverRoot, "apps/server/dist"), { recursive: true }); + await NodeFSP.mkdir(uiRoot); + await NodeFSP.mkdir(NodePath.join(assetsRoot, "fonts"), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(assetsRoot, "fonts/ui.woff2"), "static font bytes"); + await NodeFSP.writeFile( + NodePath.join(serverRoot, "apps/server/dist/bin.mjs"), + 'import native from "container-native"; console.log(native);', + ); + await NodeFSP.writeFile(NodePath.join(uiRoot, "index.html"), "latest client"); + + const archive = async (name: string, directory: string, members: string[]) => { + const file = `${name}.tar.gz`; + const target = NodePath.join(root, file); + await run("tar", ["-czf", target, "-C", directory, ...members]); + const bytes = await NodeFSP.readFile(target); + return { + artifact: { + file, + size: bytes.length, + sha256: NodeCrypto.createHash("sha256").update(bytes).digest("hex"), + }, + bytes, + }; + }; + const server = await archive("server", serverRoot, ["."]); + const ui = await archive("ui", uiRoot, ["."]); + const assets = await archive("assets", assetsRoot, ["."]); + const release = { + version: "0.0.123", + server: { ...server.artifact, version: "0.0.123", runtime: "a".repeat(64) }, + ui: ui.artifact, + assets: assets.artifact, + desktop: {}, + }; + const requests: string[] = []; + const statuses = new Map(); + const interrupted = new Map(); + const responses = new Map([ + [ + "/?ls=", + Buffer.from( + JSON.stringify({ + files: [ + { href: "0.0.9.json" }, + { href: "0.0.123.json" }, + { href: "0.0.100-mac.yml" }, + { href: "notes.json" }, + ], + }), + ), + ], + ["/0.0.123.json", Buffer.from(JSON.stringify(release))], + ["/server.tar.gz", server.bytes], + ["/ui.tar.gz", ui.bytes], + ["/assets.tar.gz", assets.bytes], + ]); + const listener = NodeHttp.createServer((request, response) => { + requests.push(request.url ?? ""); + const bytes = responses.get(request.url ?? ""); + if (bytes && interrupted.has(request.url ?? "")) { + interrupted.set(request.url ?? "", response); + response.writeHead(200, { "Content-Length": bytes.length }); + response.write(bytes.subarray(0, Math.max(1, Math.floor(bytes.length / 2)))); + return; + } + response.writeHead(statuses.get(request.url ?? "") ?? (bytes ? 200 : 404)); + response.end(bytes); + }); + await new Promise((resolve) => listener.listen(0, "127.0.0.1", resolve)); + cleanups.push( + () => + new Promise((resolve, reject) => + listener.close((error) => (error ? reject(error) : resolve())), + ), + ); + const address = listener.address(); + if (!address || typeof address === "string") throw new Error("HTTP fixture has no port."); + return { + root, + input: { baseDirectory, cacheDirectory, feedUrl: `http://127.0.0.1:${address.port}` }, + release, + responses, + requests, + statuses, + interrupted, + archive, + }; +} + +it("installs the published server and client, resolving native modules from the base image", async () => { + const { input, requests } = await fixture(); + const entrypoint = await prepareRuntime(input); + const { stdout } = await run(process.execPath, [entrypoint]); + NodeAssert.equal(stdout.trim(), "native dependency resolved"); + NodeAssert.equal( + await NodeFSP.readFile( + NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/index.html"), + "utf8", + ), + "latest client", + ); + NodeAssert.equal( + await NodeFSP.readFile(NodePath.join(input.cacheDirectory, "current/version"), "utf8"), + "0.0.123", + ); + await prepareRuntime(input); + NodeAssert.deepEqual(requests, [ + "/?ls=", + "/0.0.123.json", + "/server.tar.gz", + "/assets.tar.gz", + "/ui.tar.gz", + "/?ls=", + "/0.0.123.json", + ]); +}); + +it("keeps the compiled server version when a newer feed manifest includes an older native build", async () => { + const { input, root, responses, release, archive } = await fixture(); + await NodeFSP.writeFile( + NodePath.join(root, "server/apps/server/dist/bin.mjs"), + 'if (process.env.T3CODE_VERSION_OVERRIDE !== "0.0.123") process.exit(23);', + ); + const server = await archive("versioned-server", NodePath.join(root, "server"), ["."]); + const newer = { + ...release, + version: "0.0.124", + server: { ...release.server, ...server.artifact }, + }; + responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] }))); + responses.set("/0.0.124.json", Buffer.from(JSON.stringify(newer))); + responses.set("/versioned-server.tar.gz", server.bytes); + + await prepareRuntime(input); + + NodeAssert.equal( + await NodeFSP.readFile(NodePath.join(input.cacheDirectory, "current/version"), "utf8"), + "0.0.123", + ); +}); + +it("keeps the installed runtime when a later archive fails its checksum", async () => { + const { input, responses, release } = await fixture(); + const original = await prepareRuntime(input); + const changed = { + ...release, + version: "0.0.124", + server: { ...release.server, sha256: "f".repeat(64) }, + }; + responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] }))); + responses.set("/0.0.124.json", Buffer.from(JSON.stringify(changed))); + await NodeAssert.rejects(prepareRuntime(input), /checksum verification/); + NodeAssert.equal( + await NodeFSP.realpath(NodePath.join(input.cacheDirectory, "current/apps/server/dist/bin.mjs")), + await NodeFSP.realpath(original), + ); + NodeAssert.equal( + (await NodeFSP.readdir(input.cacheDirectory)).some((name) => name.startsWith(".download-")), + false, + ); +}); + +it("rejects native dependency changes before downloading either archive", async () => { + const { input, responses, release, requests } = await fixture(); + responses.set( + "/0.0.123.json", + Buffer.from( + JSON.stringify({ ...release, server: { ...release.server, runtime: "b".repeat(64) } }), + ), + ); + await NodeAssert.rejects(prepareRuntime(input), /newer container image/); + NodeAssert.deepEqual(requests, ["/?ls=", "/0.0.123.json"]); +}); + +it("rejects artifact paths that leave the public release directory", async () => { + const { input, responses, release } = await fixture(); + responses.set( + "/0.0.123.json", + Buffer.from(JSON.stringify({ ...release, ui: { ...release.ui, file: "../ui.tar.gz" } })), + ); + await NodeAssert.rejects(prepareRuntime(input), /ForkReleaseManifest|Expected|file|Invalid/); +}); + +it("lets the launcher use its installed runtime while the release share is unavailable", async () => { + const { input, statuses } = await fixture(); + statuses.set("/?ls=", 503); + const result = await run( + process.execPath, + [NodeURL.fileURLToPath(new URL("./container-runtime.ts", import.meta.url))], + { + env: { ...process.env, KNIFE_UPDATE_URL: input.feedUrl }, + }, + ); + NodeAssert.match(result.stderr, /Starting the installed runtime/); +}); + +it("keeps the installed runtime when downloaded server code cannot start", async () => { + const { input, root, responses, release } = await fixture(); + const original = await prepareRuntime(input); + await NodeFSP.writeFile( + NodePath.join(root, "server/apps/server/dist/bin.mjs"), + 'throw new Error("broken release");', + ); + const archive = NodePath.join(root, "broken.tar.gz"); + await run("tar", ["-czf", archive, "-C", NodePath.join(root, "server"), "."]); + const bytes = await NodeFSP.readFile(archive); + const changed = { + ...release, + version: "0.0.124", + server: { + ...release.server, + size: bytes.length, + sha256: NodeCrypto.createHash("sha256").update(bytes).digest("hex"), + }, + }; + responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] }))); + responses.set("/0.0.124.json", Buffer.from(JSON.stringify(changed))); + responses.set("/server.tar.gz", bytes); + await NodeAssert.rejects(prepareRuntime(input), /broken release/); + NodeAssert.equal( + await NodeFSP.realpath(NodePath.join(input.cacheDirectory, "current/apps/server/dist/bin.mjs")), + await NodeFSP.realpath(original), + ); +}); + +it("downloads only changed UI or static assets and bounds the verified artifact cache", async () => { + const { input, root, responses, requests, release, archive } = await fixture(); + const original = await prepareRuntime(input); + await NodeFSP.writeFile(NodePath.join(root, "ui/index.html"), "updated client"); + const nextUi = await archive("next-ui", NodePath.join(root, "ui"), ["."]); + const next = { + ...release, + version: "0.0.124", + server: { ...release.server, version: "0.0.124" }, + ui: nextUi.artifact, + }; + responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] }))); + responses.set("/0.0.124.json", Buffer.from(JSON.stringify(next))); + responses.set("/next-ui.tar.gz", nextUi.bytes); + const nextEntrypoint = await prepareRuntime(input); + NodeAssert.notEqual(nextEntrypoint, original); + NodeAssert.equal(requests.filter((url) => url === "/server.tar.gz").length, 1); + NodeAssert.equal(requests.filter((url) => url === "/assets.tar.gz").length, 1); + NodeAssert.equal(requests.filter((url) => url === "/next-ui.tar.gz").length, 1); + NodeAssert.equal( + await NodeFSP.readFile( + NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/index.html"), + "utf8", + ), + "updated client", + ); + NodeAssert.equal( + await NodeFSP.readFile( + NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/fonts/ui.woff2"), + "utf8", + ), + "static font bytes", + ); + + await NodeFSP.writeFile(NodePath.join(root, "assets/fonts/ui.woff2"), "updated font bytes"); + const nextAssets = await archive("next-assets", NodePath.join(root, "assets"), ["."]); + const final = { + ...next, + version: "0.0.125", + server: { ...next.server, version: "0.0.125" }, + assets: nextAssets.artifact, + }; + responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.125.json" }] }))); + responses.set("/0.0.125.json", Buffer.from(JSON.stringify(final))); + responses.set("/next-assets.tar.gz", nextAssets.bytes); + const finalEntrypoint = await prepareRuntime(input); + NodeAssert.notEqual(finalEntrypoint, nextEntrypoint); + NodeAssert.equal(requests.filter((url) => url === "/server.tar.gz").length, 1); + NodeAssert.equal(requests.filter((url) => url === "/next-ui.tar.gz").length, 1); + NodeAssert.equal(requests.filter((url) => url === "/next-assets.tar.gz").length, 1); + NodeAssert.equal( + await NodeFSP.readFile( + NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/fonts/ui.woff2"), + "utf8", + ), + "updated font bytes", + ); + const cached = await NodeFSP.readdir(NodePath.join(input.cacheDirectory, "artifacts")); + NodeAssert.deepEqual( + cached.sort(), + [final.server, final.assets, final.ui].map(({ sha256 }) => `${sha256}.tar.gz`).sort(), + ); + NodeAssert.equal( + (await NodeFSP.readdir(input.cacheDirectory)).filter((name) => name.startsWith("release-")) + .length, + 1, + ); +}); + +it("prefetches updates without changing the running release, then activates on restart", async () => { + const { input, root, responses, requests, release, archive } = await fixture(); + const original = await prepareRuntime(input); + const current = NodePath.join(input.cacheDirectory, "current"); + const pointer = await NodeFSP.readlink(current); + let pending = original; + for (const version of ["0.0.124", "0.0.125"]) { + await NodeFSP.writeFile(NodePath.join(root, "ui/index.html"), `${version}`); + const nextUi = await archive(`ui-${version}`, NodePath.join(root, "ui"), ["."]); + const next = { + ...release, + version, + server: { ...release.server, version }, + ui: nextUi.artifact, + }; + responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: `${version}.json` }] }))); + responses.set(`/${version}.json`, Buffer.from(JSON.stringify(next))); + responses.set(`/${nextUi.artifact.file}`, nextUi.bytes); + const staged = await prepareRuntime({ ...input, activate: false }); + NodeAssert.notEqual(staged, original); + if (pending !== original) await NodeAssert.rejects(NodeFSP.access(pending)); + pending = staged; + NodeAssert.equal(await NodeFSP.readlink(current), pointer); + NodeAssert.equal(await NodeFSP.readFile(NodePath.join(current, "version"), "utf8"), "0.0.123"); + NodeAssert.equal( + await NodeFSP.readFile(NodePath.join(current, "apps/server/dist/client/index.html"), "utf8"), + "latest client", + ); + NodeAssert.equal( + (await run(process.execPath, [original])).stdout.trim(), + "native dependency resolved", + ); + NodeAssert.equal( + (await NodeFSP.readdir(input.cacheDirectory)).filter((name) => name.startsWith("release-")) + .length, + 2, + ); + } + const downloads = requests.length; + NodeAssert.equal(await prepareRuntime(input), pending); + NodeAssert.deepEqual(requests.slice(downloads), ["/?ls=", "/0.0.125.json"]); + NodeAssert.equal(await NodeFSP.readFile(NodePath.join(current, "version"), "utf8"), "0.0.125"); + await NodeAssert.rejects(NodeFSP.access(original)); +}); + +it("preserves the running version when prefetching identical artifacts under a newer release", async () => { + const { input, responses, release } = await fixture(); + await prepareRuntime(input); + responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] }))); + responses.set("/0.0.124.json", Buffer.from(JSON.stringify({ ...release, version: "0.0.124" }))); + await prepareRuntime({ ...input, activate: false }); + NodeAssert.equal( + await NodeFSP.readFile(NodePath.join(input.cacheDirectory, "current/version"), "utf8"), + "0.0.123", + ); +}); + +it("lets the actual serve --version command exit with the background downloader loaded", async () => { + const { input, root, requests } = await fixture(); + const result = await run( + process.execPath, + [ + "--import", + NodeURL.fileURLToPath(new URL("./container-runtime.ts", import.meta.url)), + NodeURL.fileURLToPath(new URL("../apps/server/src/bin.ts", import.meta.url)), + "serve", + "--version", + ], + { + env: { + ...process.env, + KNIFE_UPDATE_URL: input.feedUrl, + T3CODE_HOME: NodePath.join(root, "home"), + }, + timeout: 10_000, + }, + ); + NodeAssert.match(result.stdout, /^t3 v/); + NodeAssert.deepEqual(requests, []); +}); + +it.each(["/?ls=", "/0.0.124.json", "/server.tar.gz"])( + "classifies a mid-body disconnect at %s as a transient outage while preserving the installed release", + async (interruptedPath) => { + const { input, release, responses, interrupted } = await fixture(); + const original = await prepareRuntime(input); + responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] }))); + responses.set( + "/0.0.124.json", + Buffer.from( + JSON.stringify({ + ...release, + version: "0.0.124", + server: { ...release.server, sha256: "f".repeat(64) }, + }), + ), + ); + interrupted.set(interruptedPath, undefined); + const fetch = globalThis.fetch; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => { + const response = await fetch(url, options); + const requested = new URL(response.url); + interrupted.get(`${requested.pathname}${requested.search}`)?.destroy(); + return response; + }); + await NodeAssert.rejects(prepareRuntime(input), /release share is unavailable/); + NodeAssert.equal( + await NodeFSP.realpath( + NodePath.join(input.cacheDirectory, "current/apps/server/dist/bin.mjs"), + ), + await NodeFSP.realpath(original), + ); + NodeAssert.equal( + (await NodeFSP.readdir(input.cacheDirectory)).some((name) => name.startsWith(".download-")), + false, + ); + }, +); + +it("keeps malformed JSON fatal instead of treating it as a connection outage", async () => { + const { input, responses } = await fixture(); + responses.set("/0.0.123.json", Buffer.from("{")); + await NodeAssert.rejects(prepareRuntime(input), SyntaxError); +}); diff --git a/scripts/container-runtime.ts b/scripts/container-runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..08370701eb1c668dab587cfde53ce48c95835d1b --- /dev/null +++ b/scripts/container-runtime.ts @@ -0,0 +1,259 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off globalConsole:off globalTimers:off - Container bootstrap runs before the Effect server starts. +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeStreamPromises from "node:stream/promises"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeUtil from "node:util"; +import * as NodeURL from "node:url"; +import * as Schema from "effect/Schema"; +import { + ForkReleaseManifest, + ForkReleaseListing, + latestForkRelease, +} from "@t3tools/shared/forkUpdateDirectory"; + +const run = NodeUtil.promisify(NodeChildProcess.execFile); +const decodeManifest = Schema.decodeUnknownSync(ForkReleaseManifest); +const decodeListing = Schema.decodeUnknownSync(ForkReleaseListing); + +class FeedUnavailableError extends Error { + constructor(message = "The release share is unavailable. Starting the installed runtime.") { + super(message); + } +} + +function rethrowBodyError(error: unknown): never { + if ( + error instanceof TypeError || + (error instanceof DOMException && ["AbortError", "TimeoutError"].includes(error.name)) + ) { + throw new FeedUnavailableError(); + } + throw error; +} + +async function request(url: URL, timeout: number) { + const response = await fetch(url, { + signal: AbortSignal.timeout(timeout), + cache: "no-store", + }).catch(() => { + throw new FeedUnavailableError(); + }); + if (response.status >= 500) { + throw new FeedUnavailableError(); + } + if (!response.ok) throw new Error(`Release share returned HTTP ${response.status}.`); + return response; +} + +export async function prepareRuntime(input: { + feedUrl: string; + baseDirectory: string; + cacheDirectory: string; + activate?: boolean; +}): Promise { + const baseUrl = new URL(input.feedUrl.endsWith("/") ? input.feedUrl : `${input.feedUrl}/`); + if (baseUrl.protocol !== "https:" && baseUrl.hostname !== "127.0.0.1") { + throw new Error("KNIFE_UPDATE_URL must use HTTPS."); + } + + const listingUrl = new URL(baseUrl); + listingUrl.searchParams.set("ls", ""); + const listing = await request(listingUrl, 30_000); + const version = latestForkRelease(decodeListing(await listing.json().catch(rethrowBodyError))); + if (version === undefined) { + throw new FeedUnavailableError("No releases are published. Starting the installed runtime."); + } + const response = await request(new URL(`${version}.json`, baseUrl), 30_000); + const release = decodeManifest(await response.json().catch(rethrowBodyError)); + if (release.version !== version) + throw new Error("The published release version does not match its filename."); + const runtime = ( + await NodeFSP.readFile(NodePath.join(input.baseDirectory, "knife-server-runtime"), "utf8") + ).trim(); + if (runtime !== release.server.runtime) { + throw new Error( + "The release needs a newer container image. Rebuild the image before restarting.", + ); + } + + const artifacts = [ + ["server", release.server], + ["assets", release.assets], + ["ui", release.ui], + ] as const; + const identity = NodeCrypto.createHash("sha256") + .update(artifacts.map(([, { sha256 }]) => sha256).join("\0")) + .digest("hex"); + const blobs = NodePath.join(input.cacheDirectory, "artifacts"); + await NodeFSP.mkdir(blobs, { recursive: true }); + const directory = NodePath.join(input.cacheDirectory, `release-${identity}`); + const entrypoint = NodePath.join(directory, "apps/server/dist/bin.mjs"); + const ready = await NodeFSP.readFile(NodePath.join(directory, "version"), "utf8").catch( + () => null, + ); + if (ready === null) { + const staging = await NodeFSP.mkdtemp(NodePath.join(input.cacheDirectory, ".download-")); + try { + for (const [kind, artifact] of artifacts) { + const archive = NodePath.join(blobs, `${artifact.sha256}.tar.gz`); + const cached = await NodeFSP.stat(archive).then( + (stat) => stat.isFile() && stat.size === artifact.size, + () => false, + ); + if (!cached) { + const partial = NodePath.join(staging, `${kind}.tar.gz`); + const downloaded = await request(new URL(artifact.file, baseUrl), 180_000); + const body = downloaded.body; + if (!body) { + throw new Error(`Could not download the ${kind} archive (HTTP ${downloaded.status}).`); + } + const hash = NodeCrypto.createHash("sha256"); + let size = 0; + await NodeStreamPromises.pipeline( + async function* () { + try { + for await (const chunk of body) { + size += chunk.length; + if (size > artifact.size) + throw new Error(`The ${kind} archive exceeds its published size.`); + hash.update(chunk); + yield chunk; + } + } catch (error) { + rethrowBodyError(error); + } + }, + NodeFS.createWriteStream(partial, { flags: "wx" }), + ); + if (size !== artifact.size || hash.digest("hex") !== artifact.sha256) { + throw new Error(`The ${kind} archive failed checksum verification.`); + } + + await NodeFSP.rename(partial, archive); + } + + const { stdout } = await run("tar", ["-tzf", archive]); + const members = stdout.split(/\r?\n/).filter(Boolean); + if ( + members.some((member) => { + const normalized = member.replace(/^\.\//, "").replace(/\/$/, ""); + return ( + normalized.startsWith("/") || + normalized.split("/").includes("..") || + (kind === "server" && + !["", ".", "apps", "apps/server", "apps/server/dist"].includes(normalized) && + !normalized.startsWith("apps/server/dist/")) + ); + }) + ) { + throw new Error(`The ${kind} archive contains an unexpected path.`); + } + const destination = + kind === "server" ? staging : NodePath.join(staging, "apps/server/dist/client"); + await NodeFSP.mkdir(destination, { recursive: true }); + await run("tar", [ + "-xzf", + archive, + "-C", + destination, + "--no-same-owner", + "--no-same-permissions", + ]); + } + + await NodeFSP.access(NodePath.join(staging, "apps/server/dist/bin.mjs")); + await NodeFSP.access(NodePath.join(staging, "apps/server/dist/client/index.html")); + await NodeFSP.symlink( + NodePath.join(input.baseDirectory, "apps/server/node_modules"), + NodePath.join(staging, "node_modules"), + "dir", + ); + await run( + process.execPath, + [NodePath.join(staging, "apps/server/dist/bin.mjs"), "--version"], + { + env: { ...process.env, T3CODE_VERSION_OVERRIDE: release.server.version }, + timeout: 30_000, + }, + ); + await NodeFSP.writeFile( + NodePath.join(staging, "knife-server-runtime"), + release.server.runtime, + ); + await NodeFSP.writeFile(NodePath.join(staging, "version"), release.server.version); + await NodeFSP.rm(directory, { recursive: true, force: true }); + await NodeFSP.rename(staging, directory); + } finally { + await NodeFSP.rm(staging, { recursive: true, force: true }); + } + } + + const keepDirectories = new Set([NodePath.basename(directory)]); + if (input.activate !== false) { + await NodeFSP.writeFile(NodePath.join(directory, "version"), release.server.version); + const pointer = NodePath.join(input.cacheDirectory, ".current"); + await NodeFSP.rm(pointer, { force: true }); + await NodeFSP.symlink(directory, pointer, "dir"); + await NodeFSP.rename(pointer, NodePath.join(input.cacheDirectory, "current")); + } else { + const active = await NodeFSP.realpath(NodePath.join(input.cacheDirectory, "current")).catch( + () => null, + ); + if (active !== null) keepDirectories.add(NodePath.basename(active)); + } + for (const name of await NodeFSP.readdir(input.cacheDirectory)) { + if (/^release-[a-f0-9]{64}$/.test(name) && !keepDirectories.has(name)) { + await NodeFSP.rm(NodePath.join(input.cacheDirectory, name), { recursive: true, force: true }); + } + } + const keep = new Set(artifacts.map(([, { sha256 }]) => `${sha256}.tar.gz`)); + for (const name of await NodeFSP.readdir(blobs)) { + if (/^[a-f0-9]{64}\.tar\.gz$/.test(name) && !keep.has(name)) { + await NodeFSP.rm(NodePath.join(blobs, name)); + } + } + return entrypoint; +} + +if ( + process.argv[1] && + NodeURL.fileURLToPath(import.meta.url) === NodePath.resolve(process.argv[1]) +) { + const feedUrl = process.env.KNIFE_UPDATE_URL; + if (feedUrl) { + await prepareRuntime({ + feedUrl, + baseDirectory: "/opt/knife", + cacheDirectory: NodePath.join(process.env.T3CODE_HOME ?? "/data", "runtime/knife-container"), + }).catch((error: unknown) => { + console.error( + error instanceof Error ? error.message : "Could not prepare the container update.", + ); + if (!(error instanceof FeedUnavailableError)) process.exitCode = 1; + }); + } +} else if (process.argv[2] === "serve" && process.env.KNIFE_UPDATE_URL) { + const feedUrl = process.env.KNIFE_UPDATE_URL; + let downloading = false; + setInterval(() => { + if (downloading) return; + downloading = true; + void prepareRuntime({ + feedUrl, + baseDirectory: "/opt/knife", + cacheDirectory: NodePath.join(process.env.T3CODE_HOME ?? "/data", "runtime/knife-container"), + activate: false, + }) + .catch((error: unknown) => { + console.error( + error instanceof Error ? error.message : "Could not download the container update.", + ); + }) + .finally(() => { + downloading = false; + }); + }, 4 * 60_000).unref(); +} diff --git a/scripts/lib/fork-runtime.test.ts b/scripts/lib/fork-runtime.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9a346009e68ec3cff1abc50e0d9f296cb67bb4d --- /dev/null +++ b/scripts/lib/fork-runtime.test.ts @@ -0,0 +1,187 @@ +// @effect-diagnostics nodeBuiltinImport:off - Exercises actual staged build inputs. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, beforeEach, expect, it } from "vite-plus/test"; +import { fingerprintForkRuntime, fingerprintForkServerRuntime } from "./fork-runtime.ts"; + +let root: string; +let signing: string; +const fingerprint = () => + fingerprintForkRuntime(root, { platform: "mac", arch: "arm64", signingFiles: [signing] }); + +beforeEach(async () => { + root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "knife-runtime-")); + signing = NodePath.join(root, "sign.ts"); + await NodeFSP.mkdir(NodePath.join(root, "apps/server/dist/client"), { recursive: true }); + await NodeFSP.mkdir(NodePath.join(root, "apps/desktop/dist-electron"), { recursive: true }); + await NodeFSP.mkdir(NodePath.join(root, "node_modules/native"), { recursive: true }); + await NodeFSP.writeFile(signing, "signing policy"); + await NodeFSP.writeFile( + NodePath.join(root, "package.json"), + JSON.stringify({ + version: "0.0.1", + buildVersion: "0.0.1", + t3codeCommitHash: "one", + devDependencies: { electron: "44.4.5" }, + build: { mac: { sign: signing } }, + }), + ); + await NodeFSP.writeFile(NodePath.join(root, "apps/server/dist/bin.mjs"), "server"); + await NodeFSP.writeFile(NodePath.join(root, "apps/desktop/dist-electron/preload.cjs"), "preload"); + await NodeFSP.writeFile(NodePath.join(root, "apps/desktop/dist-electron/boot.cjs"), "bootstrap"); + await NodeFSP.writeFile(NodePath.join(root, "node_modules/native/binary.node"), "native"); + for (const file of [ + "scripts/container-runtime.ts", + "infra/container/knife", + "packages/shared/src/forkUpdateDirectory.ts", + ]) { + await NodeFSP.mkdir(NodePath.dirname(NodePath.join(root, file)), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(root, file), "stable bootstrap"); + } +}); + +it("keeps the installed shell for backend, renderer, and runtime dependency changes", async () => { + const input = { platform: "mac", arch: "arm64", signingFiles: [signing], shellOnly: true }; + const initial = await fingerprintForkRuntime(root, input); + for (const file of [ + "apps/server/dist/bin.mjs", + "apps/desktop/dist-electron/preload.cjs", + "node_modules/native/binary.node", + ]) + await NodeFSP.appendFile(NodePath.join(root, file), "new code"); + expect(await fingerprintForkRuntime(root, input)).toBe(initial); + await NodeFSP.appendFile( + NodePath.join(root, "apps/desktop/dist-electron/boot.cjs"), + "new bootstrap", + ); + expect(await fingerprintForkRuntime(root, input)).not.toBe(initial); +}); + +it("ignores package-manager launchers that are excluded from the packaged runtime", async () => { + const initial = await fingerprint(); + await NodeFSP.mkdir(NodePath.join(root, "node_modules/.bin")); + await NodeFSP.writeFile( + NodePath.join(root, "node_modules/.bin/yaml"), + `#!/bin/sh\nexport NODE_PATH=${root}/node_modules`, + ); + expect(await fingerprint()).toBe(initial); +}); + +it("fingerprints Windows server code without making renderer changes replace the shell", async () => { + const serverDirectory = NodePath.join(root, "server-sidecar"); + for (const directory of ["apps/server/dist/client", "node_modules/native"]) { + await NodeFSP.mkdir(NodePath.join(serverDirectory, directory), { recursive: true }); + } + await NodeFSP.writeFile(NodePath.join(serverDirectory, "apps/server/dist/bin.mjs"), "server"); + await NodeFSP.writeFile( + NodePath.join(serverDirectory, "node_modules/native/binary.node"), + "native", + ); + const sidecar = NodePath.join(root, "apps/desktop/prod-resources/windows-server/server.asar"); + await NodeFSP.mkdir(NodePath.dirname(sidecar), { recursive: true }); + await NodeFSP.writeFile(sidecar, "packed server and renderer"); + const input = { platform: "win", arch: "x64", signingFiles: [], serverDirectory }; + const initial = await fingerprintForkRuntime(root, input); + const shell = await fingerprintForkRuntime(root, { ...input, shellOnly: true }); + + await NodeFSP.writeFile( + NodePath.join(serverDirectory, "apps/server/dist/client/index.html"), + "new UI", + ); + await NodeFSP.writeFile(sidecar, "repacked server and new renderer"); + expect(await fingerprintForkRuntime(root, input)).toBe(initial); + expect(await fingerprintForkRuntime(root, { ...input, shellOnly: true })).toBe(shell); + + await NodeFSP.appendFile(NodePath.join(serverDirectory, "apps/server/dist/bin.mjs"), "new code"); + const changed = await fingerprintForkRuntime(root, input); + expect(changed).not.toBe(initial); + expect(await fingerprintForkRuntime(root, { ...input, shellOnly: true })).toBe(shell); + await NodeFSP.appendFile( + NodePath.join(serverDirectory, "node_modules/native/binary.node"), + "new native", + ); + expect(await fingerprintForkRuntime(root, input)).not.toBe(changed); +}); + +it("matches the image to locked external dependencies, including their transitives", async () => { + const lock = `importers: + apps/server: + dependencies: + node-pty: {version: 1.0.0} + effect: {version: 4.0.0} +packages: + node-pty@1.0.0: {resolution: {integrity: pty}} + native-helper@2.0.0: {resolution: {integrity: helper}} + effect@4.0.0: {resolution: {integrity: effect}} +snapshots: + node-pty@1.0.0: + dependencies: {native-helper: 2.0.0} + native-helper@2.0.0: {} + effect@4.0.0: {} +`; + const path = NodePath.join(root, "pnpm-lock.yaml"); + await NodeFSP.writeFile(path, lock); + const initial = await fingerprintForkServerRuntime(root); + await NodeFSP.writeFile(path, lock.replaceAll("4.0.0", "4.0.1")); + expect(await fingerprintForkServerRuntime(root)).toBe(initial); + await NodeFSP.writeFile(path, lock.replaceAll("2.0.0", "2.0.1")); + expect(await fingerprintForkServerRuntime(root)).not.toBe(initial); +}); + +it("refuses an incomplete external dependency closure", async () => { + await NodeFSP.writeFile( + NodePath.join(root, "pnpm-lock.yaml"), + `importers: + apps/server: + dependencies: + node-pty: {version: 1.0.0} +packages: {} +snapshots: {} +`, + ); + await expect(fingerprintForkServerRuntime(root)).rejects.toThrow( + "Missing locked runtime dependency", + ); +}); + +afterEach(async () => NodeFSP.rm(root, { recursive: true, force: true })); + +it("keeps the runtime identity across consecutive UI-only release versions", async () => { + const initial = await fingerprint(); + const metadata = JSON.parse(await NodeFSP.readFile(NodePath.join(root, "package.json"), "utf8")); + metadata.version = metadata.buildVersion = "0.0.2"; + metadata.t3codeCommitHash = "two"; + await NodeFSP.writeFile(NodePath.join(root, "package.json"), JSON.stringify(metadata)); + await NodeFSP.writeFile(NodePath.join(root, "apps/server/dist/client/index.html"), "new UI"); + await NodeFSP.writeFile( + NodePath.join(root, "apps/desktop/dist-electron/main.cjs.map"), + "new sources", + ); + expect(await fingerprint()).toBe(initial); +}); + +it.each([ + "apps/server/dist/bin.mjs", + "apps/desktop/dist-electron/preload.cjs", + "node_modules/native/binary.node", + "sign.ts", +])("requires a restart for changed runtime bytes in %s", async (file) => { + const initial = await fingerprint(); + await NodeFSP.appendFile(NodePath.join(root, file), "changed"); + expect(await fingerprint()).not.toBe(initial); +}); + +it("includes architecture, Electron and executable permissions", async () => { + const initial = await fingerprint(); + expect( + await fingerprintForkRuntime(root, { platform: "mac", arch: "x64", signingFiles: [signing] }), + ).not.toBe(initial); + const metadata = JSON.parse(await NodeFSP.readFile(NodePath.join(root, "package.json"), "utf8")); + metadata.devDependencies.electron = "45.0.0"; + await NodeFSP.writeFile(NodePath.join(root, "package.json"), JSON.stringify(metadata)); + const newerElectron = await fingerprint(); + expect(newerElectron).not.toBe(initial); + await NodeFSP.chmod(NodePath.join(root, "node_modules/native/binary.node"), 0o755); + expect(await fingerprint()).not.toBe(newerElectron); +}); diff --git a/scripts/lib/fork-runtime.ts b/scripts/lib/fork-runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..82a83b70a92c314df2ce7f9c7431f63c66eb220b --- /dev/null +++ b/scripts/lib/fork-runtime.ts @@ -0,0 +1,151 @@ +// @effect-diagnostics nodeBuiltinImport:off - Fingerprints unsigned release files at the packaging boundary. +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as Schema from "effect/Schema"; + +import { fromYaml } from "@t3tools/shared/schemaYaml"; + +import { isRuntimeExternalCliDependency } from "./cli-external-packages.ts"; + +/** Release bookkeeping and renderer files cannot change the running backend's compatibility. */ +export async function fingerprintForkRuntime( + directory: string, + input: { + platform: string; + arch: string; + signingFiles: readonly string[]; + shellOnly?: boolean; + serverDirectory?: string; + }, +): Promise { + const hash = NodeCrypto.createHash("sha256").update( + JSON.stringify({ platform: input.platform, arch: input.arch }), + ); + const visit = async ( + root: string, + relative: string, + ancestors: ReadonlySet, + ): Promise => { + if ( + relative === "apps/server/dist/client" || + relative === "apps/desktop/prod-resources/windows-server" || + /^apps\/(?:server\/dist|desktop\/dist-electron)\/.*\.(?:mjs|cjs|js)\.map$/.test(relative) + ) + return; + if ( + input.shellOnly && + (relative === "node_modules" || + relative === "apps/server" || + (relative.startsWith("apps/desktop/dist-electron/") && + !["boot.cjs", "compileCache.cjs"].includes(NodePath.basename(relative)))) + ) + return; + const file = NodePath.join(root, relative); + const real = await NodeFSP.realpath(file); + const stat = await NodeFSP.stat(real); + if (stat.isDirectory()) { + if (ancestors.has(real)) return; + const next = new Set([...ancestors, real]); + for (const name of (await NodeFSP.readdir(real)).sort()) { + if (name === ".bin" || name === ".modules.yaml" || name === ".pnpm-workspace-state-v1.json") + continue; + await visit(root, NodePath.posix.join(relative, name), next); + } + return; + } + hash + .update(relative) + .update("\0") + .update(String(stat.mode & 0o777)) + .update("\0") + .update(await NodeFSP.readFile(real)) + .update("\0"); + }; + + const metadata: Record = JSON.parse( + await NodeFSP.readFile(NodePath.join(directory, "package.json"), "utf8"), + ); + delete metadata.version; + delete metadata.buildVersion; + delete metadata.t3codeCommitHash; + if (input.shellOnly) delete metadata.dependencies; + let normalized = JSON.stringify(metadata).replaceAll(directory, ""); + for (const [index, file] of input.signingFiles.entries()) { + normalized = normalized.replaceAll(file, ``); + hash.update(await NodeFSP.readFile(file)); + } + hash.update(normalized); + for (const root of input.shellOnly ? ["apps"] : ["apps", "node_modules"]) + await visit(directory, root, new Set()); + if (!input.shellOnly && input.serverDirectory !== undefined) { + hash.update("server-sidecar\0"); + for (const root of ["apps", "node_modules"]) + await visit(input.serverDirectory, root, new Set()); + } + return hash.digest("hex"); +} + +const LockedDependencies = Schema.Record(Schema.String, Schema.String); +const RuntimeLock = fromYaml( + Schema.Struct({ + importers: Schema.Record( + Schema.String, + Schema.Struct({ + dependencies: Schema.optionalKey( + Schema.Record(Schema.String, Schema.Struct({ version: Schema.String })), + ), + }), + ), + packages: Schema.Record(Schema.String, Schema.Unknown), + snapshots: Schema.Record( + Schema.String, + Schema.Struct({ + dependencies: Schema.optionalKey(LockedDependencies), + optionalDependencies: Schema.optionalKey(LockedDependencies), + }), + ), + }), +); +const decodeRuntimeLock = Schema.decodeSync(RuntimeLock); + +/** Matches portable server code to the image's complete locked external dependency closure. */ +export async function fingerprintForkServerRuntime(directory: string): Promise { + const lock = decodeRuntimeLock( + await NodeFSP.readFile(NodePath.join(directory, "pnpm-lock.yaml"), "utf8"), + ); + const roots = Object.entries(lock.importers["apps/server"]?.dependencies ?? {}) + .filter(([name]) => isRuntimeExternalCliDependency(name)) + .map(([name, { version }]) => `${name}@${version}`) + .sort(); + if (roots.length === 0) throw new Error("The server runtime has no locked dependencies."); + + const visited = new Set(); + const visit = (key: string): void => { + if (visited.has(key)) return; + visited.add(key); + const snapshot = lock.snapshots[key]; + if (!snapshot) throw new Error(`Missing locked runtime dependency: ${key}.`); + for (const [name, version] of Object.entries({ + ...snapshot.dependencies, + ...snapshot.optionalDependencies, + })) { + visit(`${name}@${version}`); + } + }; + roots.forEach(visit); + + const closure = [...visited].sort().map((key) => { + const packageKey = key.replace(/\(.*$/, ""); + return [key, lock.packages[packageKey], lock.snapshots[key]]; + }); + const hash = NodeCrypto.createHash("sha256").update(JSON.stringify({ roots, closure })); + for (const file of [ + "scripts/container-runtime.ts", + "infra/container/knife", + "packages/shared/src/forkUpdateDirectory.ts", + ]) { + hash.update(await NodeFSP.readFile(NodePath.join(directory, file))); + } + return hash.digest("hex"); +} diff --git a/scripts/lib/update-manifest.ts b/scripts/lib/update-manifest.ts index 191a3c0e5353fcdd7997dd0b7118c2c98e7155b4..5c9d13917eb195a1aba7ec84b26cadcdaaa5c6fe 100644 --- a/scripts/lib/update-manifest.ts +++ b/scripts/lib/update-manifest.ts @@ -113,6 +113,8 @@ export function parseUpdateManifest( continue; } + if (currentFile !== null && /^ blockMapSize:\s*\d+$/.test(line)) continue; + if (line === "files:") { inFiles = true; continue; diff --git a/scripts/release-fork.test.ts b/scripts/release-fork.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f3d8abb4858a303d6bf5cb2edca22538a648f7b --- /dev/null +++ b/scripts/release-fork.test.ts @@ -0,0 +1,1210 @@ +// @effect-diagnostics nodeBuiltinImport:off - Exercises publishing against a real HTTP fixture. +import * as NodeCrypto from "node:crypto"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeHttp from "node:http"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { FetchHttpClient } from "effect/http"; +import { afterEach, beforeEach, expect } from "vite-plus/test"; +import { it } from "@effect/vitest"; +import { createPackage } from "@electron/asar"; + +import { ForkReleaseManifest, latestForkRelease } from "@t3tools/shared/forkUpdateDirectory"; + +import { + ingestForkRelease, + publishForkRelease, + resolveForkMacSigningType, +} from "./release-fork.ts"; +import { parseUpdateManifest } from "./lib/update-manifest.ts"; + +const decodeRelease = Schema.decodeEffect(Schema.fromJsonString(ForkReleaseManifest)); + +it("selects the configured certificate fingerprint and requires notarization for Developer ID", () => { + const developmentIdentity = "A".repeat(40); + const distributionIdentity = "B".repeat(40); + const identities = [ + ` 1) ${developmentIdentity} "Apple Development: Private Owner (TESTTEAM)"`, + ` 2) ${distributionIdentity} "Developer ID Application: Private Owner (TESTTEAM)"`, + " 2 valid identities found", + ].join("\n"); + + expect(resolveForkMacSigningType(developmentIdentity.toLowerCase(), identities, undefined)).toBe( + "development", + ); + expect(resolveForkMacSigningType(distributionIdentity, identities, "knife-notary")).toBe( + "distribution", + ); + expect(() => resolveForkMacSigningType(distributionIdentity, identities, undefined)).toThrow( + "Developer ID releases need notarization", + ); + expect(() => resolveForkMacSigningType(developmentIdentity, identities, "knife-notary")).toThrow( + "Notarization requires a Developer ID Application certificate", + ); + expect(() => resolveForkMacSigningType("C".repeat(40), identities, undefined)).toThrow( + "release signing identity is unavailable", + ); + expect(() => + resolveForkMacSigningType( + distributionIdentity, + ` 1) ${distributionIdentity} "Developer ID Installer: Private Owner (TESTTEAM)"`, + "knife-notary", + ), + ).toThrow("Notarization requires a Developer ID Application certificate"); +}); + +let directory: string; +let origin: string; +let release: Awaited>; +let deniedMethod: string | undefined; +let deniedRequest: ((method: string, name: string) => boolean) | undefined; +let beforeManifestPut: (() => void) | undefined; +let beforePut: ((name: string) => void) | undefined; +let beforeListing: (() => void) | undefined; +let afterReadmePut: (() => void) | undefined; +let beforeDelete: (() => void) | undefined; +let downloadFailure: "rejected" | "corrupted" | undefined; +let missingDeleteStatus = 400; +const stored = new Map(); +const requests: string[] = []; +const server = NodeHttp.createServer(async (request, response) => { + const url = new URL(request.url!, origin); + if (url.searchParams.has("ls")) { + beforeListing?.(); + requests.push("GET listing"); + response + .writeHead(200, { "Content-Type": "application/json" }) + .end(JSON.stringify({ files: [...stored.keys()].map((href) => ({ href })) })); + return; + } + const name = decodeURIComponent(url.pathname.split("/").at(-1)!); + requests.push(`${request.method} ${name}`); + if ( + request.method === deniedMethod || + deniedRequest?.(request.method!, name) || + (downloadFailure === "rejected" && request.method === "PUT" && name.endsWith(".PARTIAL")) || + (["PUT", "DELETE", "MOVE"].includes(request.method!) && request.headers.pw !== "fixture-key") + ) { + response.writeHead(401).end(); + return; + } + if (!["PUT", "DELETE", "MOVE"].includes(request.method!)) + expect(request.headers.pw).toBeUndefined(); + if (request.method === "DELETE") { + expect(url.pathname.startsWith("/write/")).toBe(true); + beforeDelete?.(); + response.writeHead(stored.delete(name) ? 200 : missingDeleteStatus).end(); + return; + } + if (request.method === "MOVE") { + expect(url.pathname.startsWith("/write/")).toBe(true); + const destination = new URL(request.headers.destination!); + expect(destination.pathname.startsWith("/write/")).toBe(true); + const target = decodeURIComponent(destination.pathname.split("/").at(-1)!); + const content = stored.get(name); + if (!content) { + response.writeHead(404).end(); + return; + } + if (stored.has(target) && request.headers.overwrite === "F") { + response.writeHead(412).end(); + return; + } + stored.set(target, content); + stored.delete(name); + response.writeHead(201).end(); + return; + } + if (request.method === "PUT") { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + beforePut?.(name); + const target = stored.has(name) && !request.headers.replace ? `${name}.1` : name; + stored.set( + target, + downloadFailure === "corrupted" && name.endsWith(".PARTIAL") + ? Buffer.alloc(Buffer.concat(chunks).length, 120) + : Buffer.concat(chunks), + ); + if (name === `${release.version}.json`) beforeManifestPut?.(); + if (name === "preadme.md") afterReadmePut?.(); + response.writeHead(201).end(); + return; + } + const content = stored.get(name); + if (!content) response.writeHead(404).end(); + else + response + .writeHead(200, { "Content-Length": content.length }) + .end(request.method === "HEAD" ? undefined : content); +}); + +async function makeArtifact(name: string, content: string, filename?: string) { + const bytes = Buffer.from(content); + const sha256 = NodeCrypto.createHash("sha256").update(bytes).digest("hex"); + const file = filename ?? `knife-${name}-${sha256}.tar.gz`; + await NodeFSP.writeFile(NodePath.join(directory, file), bytes); + return { file, sha256, size: bytes.length }; +} + +async function makeRelease(): Promise { + return { + version: "0.0.2000000000", + ui: await makeArtifact("ui", "UI fixture"), + assets: await makeArtifact("assets", "static asset fixture"), + server: { + ...(await makeArtifact("server", "backend fixture")), + version: "0.0.2000000000", + runtime: "a".repeat(64), + }, + desktop: {}, + }; +} + +async function makeDesktop(content = "new signed app bytes") { + const code = await makeArtifact("code", "new application code bytes"); + const app = await makeArtifact("app", content, "The-Knife-darwin-arm64.zip"); + const bytes = await NodeFSP.readFile(NodePath.join(directory, app.file)); + const sha512 = NodeCrypto.createHash("sha512").update(bytes).digest("base64"); + await NodeFSP.writeFile(NodePath.join(directory, `${app.file}.blockmap`), "blockmap fixture"); + await NodeFSP.writeFile( + NodePath.join(directory, "latest-mac.yml"), + `version: ${release.version}\nfiles:\n - url: ${app.file}\n sha512: ${sha512}\n size: ${app.size}\nreleaseDate: '2026-10-16T00:00:00.000Z'\n`, + ); + return { + version: release.version, + ui: release.ui, + assets: release.assets, + shell: "b".repeat(64), + runtime: "c".repeat(64), + code, + app: { ...app, version: release.version }, + }; +} + +async function writeRelease() { + await NodeFSP.writeFile(NodePath.join(directory, "release.json"), JSON.stringify(release)); +} + +beforeEach(async () => { + directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-fork-release-test-")); + stored.clear(); + requests.length = 0; + deniedMethod = undefined; + deniedRequest = undefined; + beforeManifestPut = undefined; + beforePut = undefined; + beforeListing = undefined; + afterReadmePut = undefined; + beforeDelete = undefined; + downloadFailure = undefined; + missingDeleteStatus = 400; + release = await makeRelease(); + await writeRelease(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing fixture address"); + origin = `http://127.0.0.1:${address.port}`; +}); + +afterEach(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await NodeFSP.rm(directory, { recursive: true, force: true }); +}); + +const publish = () => + publishForkRelease(directory, { + feedUrl: `${origin}/read/`, + uploadUrl: `${origin}/write/`, + uploadApiKey: "fixture-key", + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))); + +it.effect("publishes verified payloads before the immutable release manifest", () => + Effect.gen(function* () { + yield* publish(); + expect(JSON.parse(stored.get(`${release.version}.json`)!.toString())).toEqual(release); + expect(stored.has("latest.json")).toBe(false); + expect(requests.indexOf(`HEAD ${release.server.file}`)).toBeLessThan( + requests.indexOf(`PUT ${release.version}.json`), + ); + expect(requests.indexOf(`PUT ${release.ui.file}`)).toBeLessThan( + requests.indexOf(`PUT ${release.version}.json`), + ); + }), +); + +it.effect("a UI release reuses the published backend without uploading it again", () => + Effect.gen(function* () { + stored.set( + "0.0.1999999999.json", + Buffer.from(JSON.stringify({ ...release, version: "0.0.1999999999" })), + ); + stored.set( + release.server.file, + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, release.server.file))), + ); + yield* publish(); + expect(requests).not.toContain(`PUT ${release.server.file}`); + expect(requests).toContain(`PUT ${release.ui.file}`); + expect(JSON.parse(stored.get(`${release.version}.json`)!.toString()).version).toBe( + release.version, + ); + }), +); + +it.effect("rejects a corrupt local payload before touching the share", () => + Effect.gen(function* () { + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(directory, release.ui.file), "corrupt"), + ); + const error = yield* publish().pipe(Effect.flip); + expect(error.message).toContain("Artifact verification failed"); + expect(requests).toEqual([]); + }), +); + +it.effect("refuses to replace a newer published release", () => + Effect.gen(function* () { + stored.set( + "0.0.2000000001.json", + Buffer.from(JSON.stringify({ ...release, version: "0.0.2000000001" })), + ); + const error = yield* publish().pipe(Effect.flip); + expect(error.message).toContain("newer version"); + expect(requests).toEqual(["GET listing", "GET 0.0.2000000001.json"]); + }), +); + +it.effect("retries an identical completed publication without rewriting it", () => + Effect.gen(function* () { + yield* publish(); + requests.length = 0; + yield* publish(); + expect(requests.filter((value) => value.startsWith("PUT "))).toEqual([]); + }), +); + +it.effect( + "an equivalent completed manifest with different JSON formatting still refreshes the download page", + () => + Effect.gen(function* () { + const desktop = yield* Effect.promise(() => makeDesktop()); + release = { ...release, desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + const existing = Buffer.from( + JSON.stringify(Object.fromEntries(Object.entries(release).toReversed()), null, 2), + ); + stored.set(`${release.version}.json`, existing); + for (const artifact of [release.ui, release.assets, release.server, desktop.code]) + stored.set( + artifact.file, + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))), + ); + stored.set(`${desktop.app.file}.blockmap`, Buffer.from("blockmap fixture")); + stored.set( + desktop.app.file, + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, desktop.app.file))), + ); + yield* publish(); + expect(stored.get(`${release.version}.json`)).toEqual(existing); + expect(requests).not.toContain(`PUT ${release.version}.json`); + expect(stored.get("preadme.md")!.toString()).toContain('href="The-Knife-darwin-arm64.zip"'); + expect(requests.filter((entry) => entry.startsWith("PUT "))).toEqual(["PUT preadme.md"]); + expect(stored.get("The-Knife-darwin-arm64.zip")).toEqual(stored.get(desktop.app.file)); + }), +); + +it.effect("does not advertise a release whose payload upload is rejected", () => + Effect.gen(function* () { + deniedMethod = "PUT"; + const error = yield* publish().pipe(Effect.flip); + expect(error.message).toContain("HTTP 401"); + expect(stored.has("latest.json")).toBe(false); + expect(stored.has(`${release.version}.json`)).toBe(false); + }), +); + +it.effect("refuses conflicting contents at the same immutable version", () => + Effect.gen(function* () { + const conflicting = Buffer.from( + JSON.stringify({ ...release, server: { ...release.server, runtime: "b".repeat(64) } }), + ); + stored.set(`${release.version}.json`, conflicting); + const error = yield* publish().pipe(Effect.flip); + expect(error.message).toContain("different contents"); + expect(stored.get(`${release.version}.json`)).toEqual(conflicting); + }), +); + +it.effect("a concurrent newer publication remains newest after this release finishes", () => + Effect.gen(function* () { + beforeManifestPut = () => + stored.set( + "0.0.2000000001.json", + Buffer.from(JSON.stringify({ ...release, version: "0.0.2000000001" })), + ); + yield* publish(); + expect(stored.has(`${release.version}.json`)).toBe(true); + expect(stored.has("0.0.2000000001.json")).toBe(true); + expect(requests.filter((entry) => entry.startsWith("PUT "))).not.toContain( + "PUT 0.0.2000000002.json", + ); + }), +); + +it.effect("reconciles concurrent native targets and keeps the newest server tuple", () => + Effect.gen(function* () { + const desktop = yield* Effect.promise(() => makeDesktop()); + release = { ...release, desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + const windowsApp = yield* Effect.promise(() => + makeArtifact("app", "Windows installer", "The-Knife-0.0.2000000001-x64.exe"), + ); + const olderUi = yield* Effect.promise(() => makeArtifact("ui", "older native UI")); + const olderServer = yield* Effect.promise(() => makeArtifact("server", "older native backend")); + const concurrent = { + ...release, + version: "0.0.2000000001", + ui: olderUi, + server: { ...olderServer, version: "0.0.1999999999", runtime: release.server.runtime }, + desktop: { + "win32-x64": { + ...desktop, + version: "0.0.2000000001", + ui: olderUi, + app: { ...windowsApp, version: "0.0.2000000001" }, + }, + }, + }; + beforeManifestPut = () => { + stored.set(`${concurrent.version}.json`, Buffer.from(JSON.stringify(concurrent))); + stored.set(windowsApp.file, Buffer.from("Windows installer")); + }; + yield* publish(); + const newest = latestForkRelease({ files: [...stored.keys()].map((href) => ({ href })) })!; + const completed = yield* decodeRelease(stored.get(`${newest}.json`)!.toString()); + expect(newest).toBe("0.0.2000000002"); + expect(completed.desktop["darwin-arm64"]).toEqual(desktop); + expect(completed.desktop["win32-x64"]).toEqual(concurrent.desktop["win32-x64"]); + expect(completed.server).toEqual(release.server); + expect(completed.ui).toEqual(release.ui); + expect(completed.assets).toEqual(release.assets); + expect(stored.get("preadme.md")!.toString()).toContain("The-Knife-win32-x64.exe"); + expect(stored.get("preadme.md")!.toString()).toContain("The-Knife-darwin-arm64.zip"); + expect(stored.has(`${release.version}.json`)).toBe(false); + }), +); + +it.effect("retries a conflicting immutable repair without overwriting the other publisher", () => + Effect.gen(function* () { + const desktop = yield* Effect.promise(() => makeDesktop()); + release = { ...release, desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + const concurrent = { ...release, version: "0.0.2000000001", desktop: {} }; + beforeManifestPut = () => { + stored.set(`${concurrent.version}.json`, Buffer.from(JSON.stringify(concurrent))); + }; + const winner = { + ...concurrent, + version: "0.0.2000000002", + desktop: { "linux-x64": { ...desktop, version: "0.0.2000000002" } }, + }; + beforePut = (name) => { + if (name === `${winner.version}.json`) stored.set(name, Buffer.from(JSON.stringify(winner))); + }; + yield* publish(); + const newest = latestForkRelease({ files: [...stored.keys()].map((href) => ({ href })) })!; + const completed = yield* decodeRelease(stored.get(`${newest}.json`)!.toString()); + expect(newest).toBe("0.0.2000000003"); + expect(completed.desktop["darwin-arm64"]).toEqual(desktop); + expect(completed.desktop["linux-x64"]).toEqual(winner.desktop["linux-x64"]); + expect(JSON.parse(stored.get(`${winner.version}.json`)!.toString())).toEqual(winner); + expect(stored.has(`${winner.version}.json.1`)).toBe(true); + }), +); + +it.effect.each(["unchanged", "changed"])( + "reuses the published shell while application code is %s", + (state) => + Effect.gen(function* () { + const sameRuntime = state === "unchanged"; + const desktop = yield* Effect.promise(() => makeDesktop()); + release = { ...release, desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + const code = yield* Effect.promise(() => + makeArtifact("old-code", "old code archive metadata"), + ); + const app = yield* Effect.promise(() => + makeArtifact("old-app", "old signed app archive", "The-Knife-0.0.1999999999-arm64.zip"), + ); + const previous = { + ...release, + version: "0.0.1999999999", + desktop: { + "darwin-arm64": { + ...desktop, + runtime: sameRuntime ? desktop.runtime : "d".repeat(64), + code, + app: { ...app, file: desktop.app.file, version: "0.0.1999999999" }, + }, + }, + }; + stored.set(`${previous.version}.json`, Buffer.from(JSON.stringify(previous))); + for (const artifact of [code, app]) { + stored.set( + artifact === app ? desktop.app.file : artifact.file, + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))), + ); + yield* Effect.promise(() => NodeFSP.rm(NodePath.join(directory, artifact.file))); + } + yield* publish(); + const published = JSON.parse(stored.get(`${release.version}.json`)!.toString()); + expect(published.desktop["darwin-arm64"]).toEqual({ + ...desktop, + code: sameRuntime ? code : desktop.code, + app: previous.desktop["darwin-arm64"].app, + }); + expect(requests.includes(`PUT ${desktop.code.file}`)).toBe(!sameRuntime); + expect(requests).not.toContain(`PUT ${desktop.app.file}`); + expect(requests).not.toContain(`PUT ${desktop.app.file}.blockmap`); + expect(requests).not.toContain(`PUT ${release.version}-mac.yml`); + expect(requests).toContain(`PUT ${release.ui.file}`); + requests.length = 0; + yield* publish(); + expect(requests.filter((value) => value.startsWith("PUT "))).toEqual([]); + }), +); + +it.effect("uploads new shell blockmaps before publishing its updater manifest", () => + Effect.gen(function* () { + const desktop = yield* Effect.promise(() => makeDesktop()); + release = { ...release, desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + yield* publish(); + expect(stored.get(desktop.app.file)).toEqual(Buffer.from("new signed app bytes")); + expect(stored.get(`${desktop.app.file}.blockmap`)).toEqual(Buffer.from("blockmap fixture")); + const moves = requests.filter((entry) => entry.startsWith("MOVE ")); + expect(moves).toHaveLength(2); + expect(requests.indexOf(moves.at(-1)!)).toBeLessThan( + requests.indexOf(`PUT ${release.version}-mac.yml`), + ); + expect(requests.indexOf(`PUT ${release.version}-mac.yml`)).toBeLessThan( + requests.indexOf(`PUT ${release.version}.json`), + ); + }), +); + +it.effect("rejects a missing local built payload before touching the share", () => + Effect.gen(function* () { + yield* Effect.promise(() => NodeFSP.rm(NodePath.join(directory, release.server.file))); + const error = yield* publish().pipe(Effect.flip); + expect(error.message).toContain("Could not verify the local artifact"); + expect(requests).toEqual([]); + }), +); + +it.effect("publishes a root download page with only real platform installers", () => + Effect.gen(function* () { + const desktop = yield* Effect.promise(() => makeDesktop()); + release = { ...release, desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + stored.set("preadme.md", Buffer.from("An older download page")); + stored.set("The-Knife-darwin-arm64.html", Buffer.from("Old redirect page")); + yield* publish(); + const page = stored.get("preadme.md")!.toString(); + expect(page).toContain("macOS Apple silicon"); + expect(page).toContain('href="The-Knife-darwin-arm64.zip"'); + expect(page).not.toContain("Linux"); + expect(page).not.toContain("Windows"); + expect(stored.has("preadme.md.1")).toBe(false); + expect(stored.has("The-Knife-darwin-arm64.html")).toBe(false); + expect(requests.indexOf(`PUT ${release.version}.json`)).toBeLessThan( + requests.indexOf("PUT preadme.md"), + ); + expect(page).not.toContain("fixture-key"); + expect(page).not.toContain("/write/"); + expect(stored.get("The-Knife-darwin-arm64.zip")).toEqual(stored.get(desktop.app.file)); + expect(requests.findIndex((entry) => entry.startsWith("MOVE "))).toBeLessThan( + requests.indexOf(`PUT ${release.version}.json`), + ); + expect([...stored.keys()].some((name) => name.endsWith(".PARTIAL"))).toBe(false); + const manifest = JSON.parse(stored.get(`${release.version}.json`)!.toString()); + expect(manifest.desktop["darwin-arm64"].app.file).toBe(desktop.app.file); + requests.length = 0; + yield* publish(); + expect(requests.some((request) => request.startsWith("PUT "))).toBe(false); + }), +); + +it.effect.each(["rejected", "corrupted"] as const)( + "retries a stable installer whose upload was %s without advertising an incomplete release", + (failure) => + Effect.gen(function* () { + const desktop = yield* Effect.promise(() => makeDesktop()); + release = { ...release, desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + downloadFailure = failure; + + const error = yield* publish().pipe(Effect.flip); + expect(error.message).toContain( + failure === "rejected" ? "HTTP 401" : "uploaded installer did not match its checksum", + ); + expect(stored.has(`${release.version}.json`)).toBe(false); + expect(stored.has("The-Knife-darwin-arm64.zip")).toBe(false); + expect(stored.has("preadme.md")).toBe(false); + deniedMethod = undefined; + downloadFailure = undefined; + requests.length = 0; + yield* publish(); + expect(stored.get("The-Knife-darwin-arm64.zip")).toEqual(stored.get(desktop.app.file)); + expect(requests).toContain(`PUT ${release.version}.json`); + expect(stored.get("preadme.md")!.toString()).toContain("The-Knife-darwin-arm64.zip"); + }), +); + +it.effect("keeps a newer completion when it arrives during download page maintenance", () => + Effect.gen(function* () { + const desktop = yield* Effect.promise(() => makeDesktop()); + release = { ...release, desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + const newerApp = { + ...(yield* Effect.promise(() => + makeArtifact("app", "newer signed app", "The-Knife-0.0.2000000001-arm64.zip"), + )), + file: "The-Knife-darwin-arm64.zip", + version: "0.0.2000000001", + }; + afterReadmePut = () => { + afterReadmePut = undefined; + stored.set(newerApp.file, Buffer.from("newer signed app")); + stored.set( + "0.0.2000000001.json", + Buffer.from( + JSON.stringify({ + ...release, + version: "0.0.2000000001", + desktop: { "darwin-arm64": { ...desktop, version: "0.0.2000000001", app: newerApp } }, + }), + ), + ); + }; + yield* publish(); + const page = stored.get("preadme.md")!.toString(); + expect(page).toContain('href="The-Knife-darwin-arm64.zip"'); + expect(stored.get("The-Knife-darwin-arm64.zip")).toEqual(Buffer.from("newer signed app")); + expect(requests.filter((entry) => entry === "PUT preadme.md")).toHaveLength(1); + expect( + requests.filter((entry) => entry.startsWith("DELETE ") && !entry.endsWith(".PARTIAL")), + ).toEqual([]); + }), +); + +it.effect( + "retires conflicting installer manifests before replacement and retains matching releases", + () => + Effect.gen(function* () { + const oldDesktop = yield* Effect.promise(() => makeDesktop()); + release = { ...release, desktop: { "darwin-arm64": oldDesktop } }; + yield* Effect.promise(writeRelease); + yield* publish(); + const oldVersion = release.version; + + release = { ...release, version: "0.0.2000000001" }; + const desktop = { + ...(yield* Effect.promise(() => makeDesktop("new signed app byte!"))), + shell: "d".repeat(64), + }; + expect(desktop.app.size).toBe(oldDesktop.app.size); + yield* Effect.promise(() => + NodeFSP.writeFile( + NodePath.join(directory, `${desktop.app.file}.blockmap`), + "new blockmap fixture", + ), + ); + release = { ...release, desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + requests.length = 0; + yield* publish(); + const promoted = requests.findIndex((entry) => entry.startsWith("MOVE ")); + expect(requests.indexOf(`DELETE ${oldVersion}.json`)).toBeLessThan(promoted); + expect(requests.indexOf(`DELETE ${oldVersion}-mac.yml`)).toBeLessThan(promoted); + expect(requests.filter((entry) => entry === `DELETE ${oldVersion}-mac.yml`)).toHaveLength(1); + expect(requests.filter((entry) => entry.startsWith("MOVE "))).toHaveLength(2); + expect(stored.has(`${oldVersion}.json`)).toBe(false); + expect(stored.has(`${oldVersion}-mac.yml`)).toBe(false); + expect(stored.get(desktop.app.file)).toEqual(Buffer.from("new signed app byte!")); + expect(stored.get(`${desktop.app.file}.blockmap`)?.toString()).toBe("new blockmap fixture"); + const nativeManifest = parseUpdateManifest( + stored.get(`${release.version}-mac.yml`)!.toString(), + "native", + "macOS", + ); + expect(nativeManifest.version).toBe(desktop.app.version); + expect(nativeManifest.files).toEqual([ + { + url: desktop.app.file, + size: desktop.app.size, + sha512: NodeCrypto.createHash("sha512") + .update(stored.get(desktop.app.file)!) + .digest("base64"), + }, + ]); + + for (const version of ["0.0.2000000002", "0.0.2000000003"]) { + release = { + ...release, + version, + ui: yield* Effect.promise(() => makeArtifact("ui", version)), + }; + yield* Effect.promise(writeRelease); + yield* publish(); + } + const completions = [...stored.keys()].filter((name) => /^0\.0\.\d+\.json$/.test(name)); + expect(completions).toEqual(["0.0.2000000002.json", "0.0.2000000003.json"]); + for (const name of completions) { + const completed = yield* decodeRelease(stored.get(name)!.toString()); + expect(completed.desktop["darwin-arm64"]!.app.sha256).toBe( + NodeCrypto.createHash("sha256").update(stored.get(desktop.app.file)!).digest("hex"), + ); + } + expect([...stored.keys()].filter((name) => name.endsWith(".zip"))).toEqual([ + desktop.app.file, + ]); + }), +); + +it.effect( + "repairs same-sized stable installer corruption without rewriting a completed manifest", + () => + Effect.gen(function* () { + const desktop = yield* Effect.promise(() => makeDesktop()); + release = { ...release, desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + yield* publish(); + const completed = stored.get(`${release.version}.json`); + stored.set(desktop.app.file, Buffer.alloc(desktop.app.size, 120)); + requests.length = 0; + yield* publish(); + expect(stored.get(desktop.app.file)).toEqual(Buffer.from("new signed app bytes")); + expect(stored.get(`${release.version}.json`)).toBe(completed); + expect(requests).not.toContain(`PUT ${release.version}.json`); + expect(requests.filter((entry) => entry.startsWith("MOVE "))).toHaveLength(1); + }), +); + +it.effect.each(["MOVE", "retired-YML", "YML", "completion", "superseded"] as const)( + "recovers a three-platform publication after retirement fails at %s", + (failure) => + Effect.gen(function* () { + const oldDesktop = yield* Effect.promise(() => makeDesktop("old signed app bytes")); + const oldCode = yield* Effect.promise(() => makeArtifact("code", "retired Mac runtime")); + const windows = { + ...oldDesktop, + app: { + ...(yield* Effect.promise(() => + makeArtifact("app", "Windows", "The-Knife-win32-x64.exe"), + )), + version: release.version, + }, + }; + const linux = { + ...oldDesktop, + app: { + ...(yield* Effect.promise(() => + makeArtifact("app", "Linux", "The-Knife-linux-x64.AppImage"), + )), + version: release.version, + }, + }; + const ui = yield* Effect.promise(() => makeArtifact("ui", "newer portable UI")); + const assets = yield* Effect.promise(() => makeArtifact("assets", "newer portable assets")); + const server = { + ...(yield* Effect.promise(() => makeArtifact("server", "newer portable server"))), + version: "0.0.2000000002", + runtime: "e".repeat(64), + }; + const previous = { + ...release, + ui, + assets, + server, + desktop: { + "darwin-arm64": { ...oldDesktop, runtime: "f".repeat(64), code: oldCode }, + "win32-x64": windows, + "linux-x64": linux, + }, + }; + stored.set(`${previous.version}.json`, Buffer.from(JSON.stringify(previous))); + for (const artifact of [ + ui, + assets, + server, + oldCode, + oldDesktop.app, + oldDesktop.code, + windows.app, + linux.app, + ]) + stored.set( + artifact.file, + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))), + ); + stored.set(`${previous.version}-mac.yml`, Buffer.from("retired Mac YAML")); + stored.set(`${previous.version}.yml`, Buffer.from("retained Windows YAML")); + stored.set(`${previous.version}-linux.yml`, Buffer.from("retained Linux YAML")); + release = { ...release, version: "0.0.2000000003" }; + const desktop = { + ...(yield* Effect.promise(() => makeDesktop("new signed app byte!"))), + shell: "d".repeat(64), + }; + release = { ...release, desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + deniedRequest = (method, name) => + failure === "retired-YML" + ? method === "DELETE" && name === `${previous.version}-mac.yml` + : failure === "YML" + ? method === "PUT" && name === `${release.version}-mac.yml` + : failure === "completion" + ? method === "PUT" && name === `${release.version}.json` + : method === "MOVE"; + expect((yield* publish().pipe(Effect.flip)).message).toContain("HTTP 401"); + expect(stored.has(`${previous.version}.json`)).toBe(false); + const intentPath = NodePath.join(directory, "publication.json"); + const intent = JSON.parse(yield* Effect.promise(() => NodeFSP.readFile(intentPath, "utf8"))); + expect(intent.release.desktop["win32-x64"]).toEqual(windows); + expect(intent.release.desktop["linux-x64"]).toEqual(linux); + expect(intent.release.server).toEqual(server); + expect(intent.retiredFiles).toContain(oldCode.file); + deniedRequest = undefined; + requests.length = 0; + if (failure === "superseded") { + stored.set( + "0.0.2000000004.json", + Buffer.from(JSON.stringify({ ...previous, version: "0.0.2000000004" })), + ); + expect((yield* publish().pipe(Effect.flip)).message).toContain("newer version"); + expect(requests.some((entry) => /^(PUT|MOVE|DELETE) /.test(entry))).toBe(false); + expect(yield* Effect.promise(() => NodeFSP.stat(intentPath))).toBeDefined(); + return; + } + yield* publish(); + if (failure === "retired-YML") + expect(requests.indexOf(`DELETE ${previous.version}-mac.yml`)).toBeLessThan( + requests.findIndex((entry) => entry.startsWith("MOVE ")), + ); + const completed = yield* decodeRelease(stored.get(`${release.version}.json`)!.toString()); + expect(completed.desktop["win32-x64"]).toEqual(windows); + expect(completed.desktop["linux-x64"]).toEqual(linux); + expect(completed.server).toEqual(server); + expect(completed.ui).toEqual(ui); + expect(completed.assets).toEqual(assets); + expect(stored.get(desktop.app.file)).toEqual(Buffer.from("new signed app byte!")); + expect(stored.has(oldCode.file)).toBe(false); + expect(stored.has(`${previous.version}-mac.yml`)).toBe(false); + expect(stored.has(`${previous.version}.yml`)).toBe(true); + expect(stored.has(`${previous.version}-linux.yml`)).toBe(true); + expect( + yield* Effect.promise(() => + NodeFSP.stat(intentPath).then( + () => true, + () => false, + ), + ), + ).toBe(false); + }), +); + +async function storeOldRelease(version: string, content: string) { + const ui = await makeArtifact("ui", content); + const old = { ...release, version, ui }; + stored.set(`${version}.json`, Buffer.from(JSON.stringify(old))); + for (const entry of [old.ui, old.assets, old.server]) { + stored.set(entry.file, await NodeFSP.readFile(NodePath.join(directory, entry.file))); + } + return old; +} + +it.effect( + "bounds completed releases while preserving referenced, unknown, and incomplete files", + () => + Effect.gen(function* () { + const oldest = yield* Effect.promise(() => storeOldRelease("0.0.1999999997", "obsolete UI")); + const middle = yield* Effect.promise(() => + storeOldRelease("0.0.1999999998", "other obsolete UI"), + ); + const previous = yield* Effect.promise(() => + storeOldRelease("0.0.1999999999", "previous UI"), + ); + const orphan = `knife-code-${"d".repeat(64)}.tar.gz`; + stored.set(orphan, Buffer.from("An incomplete publication")); + stored.set("notes.txt", Buffer.from("Keep this unrelated file")); + stored.set("0.0.1999999996.json.PARTIAL", Buffer.from("Unfinished manifest")); + yield* publish(); + for (const entry of [oldest, middle]) { + expect(stored.has(`${entry.version}.json`)).toBe(false); + expect(stored.has(entry.ui.file)).toBe(false); + } + expect(stored.has(`${previous.version}.json`)).toBe(true); + expect(stored.has(previous.ui.file)).toBe(true); + expect(stored.has(release.assets.file)).toBe(true); + expect(stored.has(release.server.file)).toBe(true); + expect(stored.has(orphan)).toBe(true); + expect(stored.has("notes.txt")).toBe(true); + expect(stored.has("0.0.1999999996.json.PARTIAL")).toBe(true); + expect(requests.indexOf(`DELETE ${oldest.ui.file}`)).toBeLessThan( + requests.indexOf(`DELETE ${oldest.version}.json`), + ); + }), +); + +it.effect("treats HTTP 404 for an already deleted obsolete artifact as successful cleanup", () => + Effect.gen(function* () { + missingDeleteStatus = 404; + const oldest = yield* Effect.promise(() => storeOldRelease("0.0.1999999997", "obsolete UI")); + yield* Effect.promise(() => storeOldRelease("0.0.1999999998", "other obsolete UI")); + yield* Effect.promise(() => storeOldRelease("0.0.1999999999", "previous UI")); + beforeDelete = () => { + beforeDelete = undefined; + stored.delete(oldest.ui.file); + }; + yield* publish(); + expect(requests).toContain(`DELETE ${oldest.ui.file}`); + expect([...stored.keys()].filter((file) => /^0\.0\.\d+\.json$/.test(file))).toEqual([ + "0.0.1999999999.json", + `${release.version}.json`, + ]); + }), +); + +it.effect.each(["0.0.2000000001", "0.0.1999999996"])( + "stops pruning when publication %s completes during cleanup", + (version) => + Effect.gen(function* () { + yield* Effect.promise(() => storeOldRelease("0.0.1999999997", "obsolete UI")); + yield* Effect.promise(() => storeOldRelease("0.0.1999999998", "other obsolete UI")); + yield* Effect.promise(() => storeOldRelease("0.0.1999999999", "previous UI")); + beforeDelete = () => { + beforeDelete = undefined; + stored.set(`${version}.json`, Buffer.from(JSON.stringify({ ...release, version }))); + }; + yield* publish(); + const firstDelete = requests.findIndex((entry) => entry.startsWith("DELETE ")); + const observed = requests.indexOf(`GET ${version}.json`); + expect(observed).toBeGreaterThan(firstDelete); + expect( + requests.slice(firstDelete + 1, observed).some((entry) => entry.startsWith("DELETE ")), + ).toBe(false); + expect(stored.has(`${version}.json`)).toBe(version === "0.0.2000000001"); + }), +); + +it.effect("reconciles a late older aggregate before its native target can be pruned", () => + Effect.gen(function* () { + const desktop = yield* Effect.promise(() => makeDesktop()); + release = { ...release, desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + yield* Effect.promise(() => storeOldRelease("0.0.1999999999", "previous UI")); + const linux = { ...desktop, version: "0.0.1999999998" }; + let listings = 0; + beforeListing = () => { + listings++; + if (listings === 4) + stored.set( + "0.0.1999999998.json", + Buffer.from( + JSON.stringify({ + ...release, + version: "0.0.1999999998", + desktop: { "linux-x64": linux }, + }), + ), + ); + }; + yield* publish(); + const newest = latestForkRelease({ files: [...stored.keys()].map((href) => ({ href })) })!; + const completed = yield* decodeRelease(stored.get(`${newest}.json`)!.toString()); + expect(completed.desktop["linux-x64"]).toEqual(linux); + expect(completed.desktop["darwin-arm64"]).toEqual(desktop); + expect(requests.indexOf(`PUT ${newest}.json`)).toBeLessThan( + requests.indexOf("DELETE 0.0.1999999998.json"), + ); + }), +); + +it.effect("links the server bundle only after it is publicly available", () => + Effect.gen(function* () { + yield* publish(); + expect(stored.get("preadme.md")!.toString()).not.toContain("Docker bundle"); + stored.set("knife-server-linux-amd64.tar.gz", Buffer.from("Docker image")); + stored.set("knife-server-linux-amd64.tar.gz.sha256", Buffer.from("checksum")); + yield* publish(); + const page = stored.get("preadme.md")!.toString(); + expect(page).toContain("[Docker bundle](knife-server-linux-amd64.tar.gz)"); + expect(page).toContain("[SHA-256](knife-server-linux-amd64.tar.gz.sha256)"); + }), +); + +it.effect("preserves an older platform's UI and assets while pruning their original manifest", () => + Effect.gen(function* () { + const oldest = yield* Effect.promise(() => storeOldRelease("0.0.1999999997", "old Linux UI")); + yield* Effect.promise(() => storeOldRelease("0.0.1999999998", "obsolete UI")); + const previous = yield* Effect.promise(() => + storeOldRelease("0.0.1999999999", "previous root UI"), + ); + const code = yield* Effect.promise(() => makeArtifact("code", "Linux application code")); + const assets = yield* Effect.promise(() => makeArtifact("assets", "old Linux assets")); + const app = yield* Effect.promise(() => + makeArtifact("app", "Linux installer", "The-Knife-0.0.1999999997-x64.AppImage"), + ); + const target = { + version: oldest.version, + ui: oldest.ui, + assets, + code, + app: { ...app, version: oldest.version }, + shell: "b".repeat(64), + runtime: "c".repeat(64), + }; + stored.set( + `${previous.version}.json`, + Buffer.from(JSON.stringify({ ...previous, desktop: { "linux-x64": target } })), + ); + for (const artifact of [assets, code, app]) + stored.set( + artifact.file, + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))), + ); + yield* publish(); + expect(stored.has(`${oldest.version}.json`)).toBe(false); + for (const artifact of [oldest.ui, assets, code, app]) + expect(stored.has(artifact.file)).toBe(true); + const published = JSON.parse(stored.get(`${release.version}.json`)!.toString()); + expect(published.desktop["linux-x64"]).toEqual(target); + }), +); + +it.effect("refuses to replace a platform with an older native build", () => + Effect.gen(function* () { + const desktop = yield* Effect.promise(() => makeDesktop()); + release = { ...release, desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + stored.set( + "0.0.1999999999.json", + Buffer.from( + JSON.stringify({ + ...release, + version: "0.0.1999999999", + desktop: { "darwin-arm64": { ...desktop, version: "0.0.2000000001" } }, + }), + ), + ); + const error = yield* publish().pipe(Effect.flip); + expect(error.message).toContain("newer native build"); + expect(requests.some((entry) => entry.startsWith("PUT "))).toBe(false); + }), +); + +it.effect("an older native build keeps the newest published container code and matching UI", () => + Effect.gen(function* () { + const desktop = yield* Effect.promise(() => makeDesktop()); + release = { ...release, version: "0.0.2000000006", desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + const ui = yield* Effect.promise(() => makeArtifact("ui", "newer container UI")); + const assets = yield* Effect.promise(() => makeArtifact("assets", "newer container assets")); + const server = { + ...(yield* Effect.promise(() => makeArtifact("server", "newer container code"))), + runtime: "e".repeat(64), + version: "0.0.2000000001", + }; + const previous = { ...release, version: "0.0.2000000005", ui, assets, server, desktop: {} }; + stored.set(`${previous.version}.json`, Buffer.from(JSON.stringify(previous))); + for (const artifact of [ui, assets, server]) + stored.set( + artifact.file, + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))), + ); + yield* publish(); + const published = JSON.parse(stored.get(`${release.version}.json`)!.toString()); + expect(published.ui).toEqual(ui); + expect(published.assets).toEqual(assets); + expect(published.server).toEqual(server); + expect(published.desktop["darwin-arm64"]).toEqual(desktop); + expect(requests).not.toContain(`PUT ${release.server.file}`); + expect(requests).toContain(`PUT ${desktop.ui.file}`); + }), +); + +it.effect("retains another platform's complete target when publishing a single native build", () => + Effect.gen(function* () { + const desktop = yield* Effect.promise(() => makeDesktop()); + const prior = { ...desktop, version: "0.0.1999999999" }; + const previous = { ...release, version: "0.0.1999999999", desktop: { "linux-x64": prior } }; + stored.set(`${previous.version}.json`, Buffer.from(JSON.stringify(previous))); + for (const entry of [prior.ui, prior.assets, prior.code, prior.app]) + stored.set( + entry.file, + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, entry.file))), + ); + release = { ...release, desktop: { "darwin-arm64": desktop } }; + yield* Effect.promise(writeRelease); + yield* publish(); + const published = JSON.parse(stored.get(`${release.version}.json`)!.toString()); + expect(published.desktop["linux-x64"]).toEqual(prior); + expect(published.desktop["darwin-arm64"]).toEqual(desktop); + expect(stored.get("preadme.md")!.toString()).toContain("Linux x64"); + }), +); + +it.effect.each(["linux", "win32"] as const)( + "ingests native %s resources without mixing desktop and server dependencies", + (platform) => + Effect.gen(function* () { + const source = NodePath.join(directory, "native"); + const resources = NodePath.join(directory, "resources"); + const app = NodePath.join(directory, "app-source"); + const backend = platform === "win32" ? NodePath.join(directory, "server-source") : app; + const output = NodePath.join(directory, "ingested"); + const extension = platform === "linux" ? ".AppImage" : ".exe"; + const yaml = platform === "linux" ? "latest-linux.yml" : "latest.yml"; + const installer = `The-Knife-${release.version}-${platform === "linux" ? "x86_64" : "x64"}${extension}`; + yield* Effect.promise(async () => { + for (const path of [ + source, + resources, + NodePath.join(app, "apps/desktop/dist-electron"), + NodePath.join(app, "node_modules/same"), + NodePath.join(backend, "node_modules/same"), + NodePath.join(backend, "apps/server/dist/client/.vite"), + ]) + await NodeFSP.mkdir(path, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(app, "knife-runtime.json"), + JSON.stringify({ + version: release.version, + sha256: "c".repeat(64), + shell: "b".repeat(64), + }), + ); + await NodeFSP.writeFile( + NodePath.join(app, "apps/desktop/dist-electron/main.cjs"), + "desktop code", + ); + await NodeFSP.writeFile( + NodePath.join(app, "node_modules/same/index.js"), + "desktop dependency", + ); + await NodeFSP.writeFile( + NodePath.join(backend, "node_modules/same/index.js"), + "server dependency", + ); + await NodeFSP.writeFile(NodePath.join(backend, "apps/server/dist/bin.mjs"), "server code"); + await NodeFSP.writeFile( + NodePath.join(backend, "apps/server/dist/client/main.js"), + "UI code", + ); + await NodeFSP.writeFile( + NodePath.join(backend, "apps/server/dist/client/image.png"), + "static asset", + ); + await NodeFSP.writeFile( + NodePath.join(backend, "apps/server/dist/client/.vite/manifest.json"), + "{}", + ); + await createPackage(app, NodePath.join(resources, "app.asar")); + if (platform === "win32") + await createPackage(backend, NodePath.join(resources, "server.asar")); + await NodeFSP.writeFile(NodePath.join(resources, "knife-server-runtime"), "e".repeat(64)); + NodeChildProcess.execFileSync("tar", [ + "-czf", + NodePath.join(source, `knife-resources-${platform}-x64.tar.gz`), + "-C", + resources, + "app.asar", + ...(platform === "win32" ? ["server.asar"] : []), + "knife-server-runtime", + ]); + const bytes = Buffer.from("native installer fixture"); + await NodeFSP.writeFile(NodePath.join(source, installer), bytes); + const sha512 = NodeCrypto.createHash("sha512").update(bytes).digest("base64"); + const deb = `The-Knife-${release.version}-amd64.deb`; + if (platform === "linux") await NodeFSP.writeFile(NodePath.join(source, deb), bytes); + await NodeFSP.writeFile( + NodePath.join(source, yaml), + `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`, + ); + }); + yield* ingestForkRelease(`${platform}-x64`, source, output, "0.0.2000000001").pipe( + Effect.provide(NodeServices.layer), + ); + const ingested = yield* Effect.promise(() => + NodeFSP.readFile(NodePath.join(output, "release.json"), "utf8"), + ).pipe(Effect.flatMap(decodeRelease)); + const target = ingested.desktop[`${platform}-x64`]!; + expect(ingested.version).toBe("0.0.2000000001"); + expect(target.version).toBe(release.version); + expect(target.app.version).toBe(release.version); + expect(ingested.server.runtime).toBe("e".repeat(64)); + expect(ingested.server.version).toBe(release.version); + yield* Effect.promise(async () => { + const extracted = NodePath.join(directory, "extracted"); + await NodeFSP.mkdir(extracted); + NodeChildProcess.execFileSync("tar", [ + "-xzf", + NodePath.join(output, target.code.file), + "-C", + extracted, + ]); + expect( + await NodeFSP.readFile( + NodePath.join( + extracted, + platform === "win32" ? "server/apps/server/dist/bin.mjs" : "apps/server/dist/bin.mjs", + ), + "utf8", + ), + ).toBe("server code"); + if (platform === "win32") { + expect( + await NodeFSP.readFile(NodePath.join(extracted, "node_modules/same/index.js"), "utf8"), + ).toBe("desktop dependency"); + expect( + await NodeFSP.readFile( + NodePath.join(extracted, "server/node_modules/same/index.js"), + "utf8", + ), + ).toBe("server dependency"); + } + }); + yield* publishForkRelease(output, { + feedUrl: `${origin}/read/`, + uploadUrl: `${origin}/write/`, + uploadApiKey: "fixture-key", + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), + ); + expect(stored.get("preadme.md")!.toString()).toContain( + `href="The-Knife-${platform}-x64${extension}"`, + ); + expect(stored.has(`${target.app.version}${platform === "linux" ? "-linux" : ""}.yml`)).toBe( + true, + ); + const publicManifest = parseUpdateManifest( + stored.get(`${target.app.version}${platform === "linux" ? "-linux" : ""}.yml`)!.toString(), + yaml, + platform, + ); + expect(publicManifest.files.map((file) => file.url)).toEqual([ + `The-Knife-${platform}-x64${extension}`, + ]); + expect(stored.has(installer)).toBe(false); + expect(publicManifest.files.every((file) => stored.has(file.url))).toBe(true); + }), +); diff --git a/scripts/release-fork.ts b/scripts/release-fork.ts new file mode 100644 index 0000000000000000000000000000000000000000..c63bbec8e1d1ec4542f20c1955c0c61746521fc4 --- /dev/null +++ b/scripts/release-fork.ts @@ -0,0 +1,1215 @@ +// @effect-diagnostics nodeBuiltinImport:off - Release validation reads local archives at the CLI boundary. +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Data from "effect/Data"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import type { HttpMethod } from "effect/http/HttpMethod"; +import { extractAll } from "@electron/asar"; + +import { + ForkReleaseManifest, + ForkReleaseListing, + ForkRuntimeManifest, + latestForkRelease, +} from "@t3tools/shared/forkUpdateDirectory"; + +import { parseUpdateManifest, serializeUpdateManifest } from "./lib/update-manifest.ts"; +import { fingerprintForkServerRuntime } from "./lib/fork-runtime.ts"; +import { loadRepoEnv } from "./lib/public-config.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +const ReleaseConfig = Schema.Struct({ + feedUrl: Schema.String.check(Schema.isPattern(/^https:\/\//)), + uploadUrl: Schema.String.check(Schema.isPattern(/^https:\/\//)), + signingIdentity: Schema.String.check(Schema.isPattern(/^[A-Fa-f0-9]{40}$/)), + signingKeychain: Schema.NonEmptyString, + notarizationKeychainProfile: Schema.optionalKey(Schema.NonEmptyString), + uploadApiKey: Schema.optionalKey(Schema.NonEmptyString), +}); +type ReleaseConfig = typeof ReleaseConfig.Type; + +class ForkReleaseError extends Data.TaggedError("ForkReleaseError")<{ + readonly message: string; +}> {} + +class ForkReleaseCollision extends Data.TaggedError("ForkReleaseCollision")<{ + readonly message: string; +}> {} + +const repoRoot = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))); + +const run = Effect.fn("fork.release.run")(function* ( + command: string, + args: string[], + options: { env?: NodeJS.ProcessEnv; capture?: boolean } = {}, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(command, args, { + cwd: repoRoot, + env: options.env, + stdout: options.capture ? "pipe" : "inherit", + stderr: "inherit", + stdin: "ignore", + }), + ); + const [chunks, code] = yield* Effect.all([Stream.runCollect(child.stdout), child.exitCode], { + concurrency: "unbounded", + }); + if (Number(code) !== 0) + return yield* new ForkReleaseError({ message: `${command} exited with code ${code}.` }); + const decoder = new TextDecoder(); + return chunks.map((chunk) => decoder.decode(chunk, { stream: true })).join("") + decoder.decode(); +}); + +const request = Effect.fn("fork.release.request")(function* ( + input: HttpClientRequest.HttpClientRequest, +) { + const http = HttpClient.withScope(yield* HttpClient.HttpClient); + const response = yield* http.execute(input).pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.mapError(() => new ForkReleaseError({ message: "Could not reach the release share." })), + ); + if ( + (response.status < 200 || response.status >= 300) && + !(input.method === "DELETE" && response.status === 404) + ) { + return yield* new ForkReleaseError({ + message: `Release share returned HTTP ${response.status}.`, + }); + } + return response; +}); + +const desktopTargets = { + darwin: { manifest: "latest-mac.yml", channel: "-mac", label: "macOS", extension: ".zip" }, + linux: { + manifest: "latest-linux.yml", + channel: "-linux", + label: "Linux", + extension: ".AppImage", + }, + win32: { manifest: "latest.yml", channel: "", label: "Windows", extension: ".exe" }, +}; + +const installerSuffix = (platform: keyof typeof desktopTargets, arch: string) => + `-${platform === "linux" && arch === "x64" ? "x86_64" : arch}${desktopTargets[platform].extension}`; + +const verifyDesktopRelease = ( + directory: string, + platform: keyof typeof desktopTargets = "darwin", +) => + Effect.tryPromise({ + try: async () => { + const target = desktopTargets[platform]; + const raw = await NodeFSP.readFile(NodePath.join(directory, target.manifest), "utf8"); + const manifest = parseUpdateManifest(raw, target.manifest, target.label); + if (!/^0\.0\.\d+$/.test(manifest.version)) + throw new ForkReleaseError({ message: "Not a private fork release version." }); + if (!manifest.files.some((file) => file.url.endsWith(target.extension))) { + throw new ForkReleaseError({ message: `The release has no ${target.label} installer.` }); + } + const files: string[] = []; + for (const file of manifest.files) { + if ( + NodePath.basename(file.url) !== file.url || + (!file.url.startsWith(`The-Knife-${manifest.version}-`) && + !["arm64", "x64"].some( + (arch) => file.url === `The-Knife-${platform}-${arch}${target.extension}`, + )) + ) { + throw new ForkReleaseError({ + message: "The release manifest contains an unexpected artifact path.", + }); + } + const artifact = NodePath.join(directory, file.url); + const stat = await NodeFSP.stat(artifact); + const hash = NodeCrypto.createHash("sha512"); + for await (const chunk of NodeFS.createReadStream(artifact)) hash.update(chunk); + if (stat.size !== file.size || hash.digest("base64") !== file.sha512) { + throw new ForkReleaseError({ message: `Artifact verification failed: ${file.url}.` }); + } + files.push(file.url); + if ( + await NodeFSP.stat(`${artifact}.blockmap`).then( + () => true, + () => false, + ) + ) + files.push(`${file.url}.blockmap`); + } + return { manifest, raw, files }; + }, + catch: (cause) => + cause instanceof ForkReleaseError + ? cause + : new ForkReleaseError({ message: "Could not verify the release artifacts." }), + }); + +const decodeRelease = Schema.decodeUnknownEffect(Schema.fromJsonString(ForkReleaseManifest)); +const encodeRelease = Schema.encodeEffect(Schema.fromJsonString(ForkReleaseManifest)); +const PublicationIntentJson = Schema.fromJsonString( + Schema.Struct({ + release: ForkReleaseManifest, + retiredFiles: Schema.Array(Schema.String), + }), +); +const decodePublicationIntent = Schema.decodeUnknownEffect(PublicationIntentJson); +const encodePublicationIntent = Schema.encodeEffect(PublicationIntentJson); +const decodeListing = Schema.decodeUnknownEffect(ForkReleaseListing); + +const completedVersions = (listing: typeof ForkReleaseListing.Type) => + listing.files + .flatMap((file) => { + const version = latestForkRelease({ files: [file] }); + return version ? [version] : []; + }) + .sort((a, b) => (BigInt(a.slice(4)) > BigInt(b.slice(4)) ? -1 : 1)); + +const readReleaseListing = (feed: URL) => + Effect.gen(function* () { + const response = yield* request(HttpClientRequest.get(new URL("?ls", feed).href)); + return yield* decodeListing(yield* response.json); + }); + +const readPublishedRelease = (feed: URL, version: string) => + Effect.gen(function* () { + const manifest = yield* request(HttpClientRequest.get(new URL(`${version}.json`, feed).href)); + const release = yield* decodeRelease(yield* manifest.text); + if (release.version !== version) + return yield* new ForkReleaseError({ + message: "The published manifest has the wrong version.", + }); + return release; + }); + +const previousRelease = (feed: URL) => + Effect.scoped( + Effect.gen(function* () { + const version = latestForkRelease(yield* readReleaseListing(feed)); + return version ? yield* readPublishedRelease(feed, version) : undefined; + }), + ); + +const artifact = (directory: string, file: string) => + Effect.tryPromise(async () => { + const source = NodePath.join(directory, file); + const hash = NodeCrypto.createHash("sha256"); + for await (const chunk of NodeFS.createReadStream(source)) hash.update(chunk); + return { file, sha256: hash.digest("hex"), size: (await NodeFSP.stat(source)).size }; + }); + +const publicReadme = (release: typeof ForkReleaseManifest.Type, serverAvailable: boolean) => { + const instructions = new Set(); + const downloads = Object.keys(release.desktop).flatMap((key) => { + const [platform, arch] = key.split("-"); + if (platform !== "darwin" && platform !== "linux" && platform !== "win32") return []; + const architecture = + arch === "arm64" + ? platform === "darwin" + ? "Apple silicon" + : "ARM64" + : platform === "darwin" + ? "Intel" + : "x64"; + const install = + platform === "darwin" + ? "Unzip, then move The Knife to Applications. If macOS blocks the first launch, see [Apple’s opening instructions](https://support.apple.com/102445)." + : platform === "linux" + ? "Make the AppImage executable, then open it." + : "Run the installer."; + instructions.add(`**${desktopTargets[platform].label}:** ${install}`); + return [ + `${desktopTargets[platform].label} ${architecture}`, + ]; + }); + return [ + "## The Knife", + "", + "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.", + "", + ...(downloads.length > 0 + ? [ + `

${downloads.join("   ·   ")}

`, + "", + [...instructions].join(" "), + "", + ] + : []), + "Updates download automatically. The app asks before restarting; UI updates apply when your work can be preserved.", + "", + ...(serverAvailable + ? [ + "**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.", + "", + ] + : []), + ].join("\n"); +}; + +const releaseArtifacts = (release: typeof ForkReleaseManifest.Type) => [ + release.ui, + release.assets, + release.server, + ...Object.values(release.desktop).flatMap((desktop) => [ + desktop.ui, + desktop.assets, + desktop.code, + desktop.app, + ]), +]; + +const releaseFiles = (release: typeof ForkReleaseManifest.Type) => { + const files = [ + ...releaseArtifacts(release).map((artifact) => artifact.file), + `${release.version}.json`, + ]; + for (const [key, desktop] of Object.entries(release.desktop)) { + files.push(`${desktop.app.file}.blockmap`); + const platform = key.slice(0, key.lastIndexOf("-")); + if (platform === "darwin" || platform === "linux" || platform === "win32") + files.push(`${desktop.app.version}${desktopTargets[platform].channel}.yml`); + } + return files; +}; + +const maintainPublicFolder = Effect.fn("fork.release.maintainPublicFolder")(function* ( + release: typeof ForkReleaseManifest.Type, + feedBase: URL, + uploadBase: URL, + apiKey: string | undefined, + completed: ReadonlyArray, + retiredFiles: ReadonlySet, +) { + const authenticated = (input: HttpClientRequest.HttpClientRequest) => + input.pipe(HttpClientRequest.setHeaders(apiKey ? { PW: apiKey } : {})); + const http = HttpClient.withScope(yield* HttpClient.HttpClient); + const initialVersions = completedVersions(yield* readReleaseListing(feedBase)); + if (initialVersions.join("\n") !== completed.join("\n")) return false; + for (const key of Object.keys(release.desktop)) { + const oldPage = `The-Knife-${key}.html`; + const page = yield* http.head(new URL(oldPage, feedBase).href); + if (page.status !== 200 && page.status !== 404) + return yield* new ForkReleaseError({ + message: `Release share returned HTTP ${page.status}.`, + }); + if (page.status === 200) { + const removed = yield* request( + authenticated(HttpClientRequest.make("DELETE")(new URL(oldPage, uploadBase).href)), + ); + yield* removed.arrayBuffer; + } + } + + const server = yield* http.head(new URL("knife-server-linux-amd64.tar.gz", feedBase).href); + if (server.status !== 200 && server.status !== 404) + return yield* new ForkReleaseError({ + message: `Release share returned HTTP ${server.status}.`, + }); + const readme = publicReadme(release, server.status === 200); + const existing = yield* http.get(new URL("preadme.md", feedBase).href); + if (existing.status !== 200 || (yield* existing.text) !== readme) { + if (existing.status !== 200 && existing.status !== 404) + return yield* new ForkReleaseError({ + message: `Release share returned HTTP ${existing.status}.`, + }); + const uploaded = yield* request( + authenticated( + HttpClientRequest.put(new URL("preadme.md", uploadBase).href).pipe( + HttpClientRequest.setHeader("Replace", "1"), + HttpClientRequest.bodyText(readme, "text/markdown; charset=utf-8"), + ), + ), + ); + yield* uploaded.arrayBuffer; + const readable = yield* request(HttpClientRequest.get(new URL("preadme.md", feedBase).href)); + if ((yield* readable.text) !== readme) + return yield* new ForkReleaseError({ + message: "The download page could not be updated. Retry publication.", + }); + } + const listing = yield* readReleaseListing(feedBase); + let versions = completedVersions(listing); + if (versions.join("\n") !== completed.join("\n")) return false; + + const retained = new Set(releaseFiles(release)); + for (const version of versions.slice(0, 2)) { + for (const file of releaseFiles(yield* readPublishedRelease(feedBase, version))) + retained.add(file); + } + const obsolete = new Set([...retiredFiles].filter((file) => !retained.has(file))); + for (const version of versions.slice(2)) { + for (const file of releaseFiles(yield* readPublishedRelease(feedBase, version))) { + if ( + !retained.has(file) && + (/^knife-(?:ui|assets|server|code)-[a-f0-9]{64}\.tar\.gz$/.test(file) || + /^The-Knife-0\.0\.\d+-[\w.-]+\.(?:zip|exe|AppImage)(?:\.blockmap)?$/.test(file) || + /^0\.0\.\d+(?:-mac|-linux)?\.(?:json|yml)$/.test(file)) + ) + obsolete.add(file); + } + } + const visible = new Set(listing.files.map((file) => file.href)); + const deletions = [...obsolete].sort( + (a, b) => Number(a.endsWith(".json")) - Number(b.endsWith(".json")), + ); + for (const file of deletions) { + if (!visible.has(file)) continue; + const currentVersions = completedVersions(yield* readReleaseListing(feedBase)); + if (currentVersions.join("\n") !== versions.join("\n")) return false; + const deleted = yield* request( + authenticated( + HttpClientRequest.make("DELETE")(new URL(encodeURIComponent(file), uploadBase).href), + ), + ); + yield* deleted.arrayBuffer; + if (file.endsWith(".json")) versions = versions.filter((version) => `${version}.json` !== file); + } + return true; +}); + +export const publishForkRelease = Effect.fn("fork.release.publish")(function* ( + directory: string, + config: Pick, +) { + const raw = yield* Effect.tryPromise(() => + NodeFSP.readFile(NodePath.join(directory, "release.json"), "utf8"), + ); + const built = yield* decodeRelease(raw); + for (const expected of releaseArtifacts(built)) { + const actual = yield* artifact(directory, expected.file).pipe( + Effect.mapError( + () => + new ForkReleaseError({ + message: `Could not verify the local artifact: ${expected.file}.`, + }), + ), + ); + if (actual.sha256 !== expected.sha256 || actual.size !== expected.size) + return yield* new ForkReleaseError({ + message: `Artifact verification failed: ${expected.file}.`, + }); + } + const desktopReleases = new Map< + keyof typeof desktopTargets, + { + manifest: ReturnType; + raw: string; + files: string[]; + } + >(); + for (const [key, desktop] of Object.entries(built.desktop)) { + const platform = key.slice(0, key.lastIndexOf("-")); + if (platform !== "darwin" && platform !== "linux" && platform !== "win32") + return yield* new ForkReleaseError({ message: `Unsupported desktop platform: ${key}.` }); + let desktopRelease = desktopReleases.get(platform); + if (!desktopRelease) { + desktopRelease = yield* verifyDesktopRelease(directory, platform); + desktopReleases.set(platform, desktopRelease); + } + if ( + desktopRelease.manifest.version !== desktop.version || + desktop.app.version !== desktop.version || + !desktopRelease.files.includes(desktop.app.file) + ) + return yield* new ForkReleaseError({ + message: "The local app does not match its updater manifest.", + }); + } + + const intentPath = NodePath.join(directory, "publication.json"); + const intent = NodeFS.existsSync(intentPath) + ? yield* Effect.tryPromise(() => NodeFSP.readFile(intentPath, "utf8")).pipe( + Effect.flatMap(decodePublicationIntent), + ) + : undefined; + if (intent !== undefined && intent.release.version !== built.version) + return yield* new ForkReleaseError({ + message: "The publication intent belongs to a different release.", + }); + const uploadBase = new URL( + config.uploadUrl.endsWith("/") ? config.uploadUrl : `${config.uploadUrl}/`, + ); + const feedBase = new URL(config.feedUrl.endsWith("/") ? config.feedUrl : `${config.feedUrl}/`); + const previous = yield* previousRelease(feedBase); + if (previous && BigInt(previous.version.slice(4)) > BigInt(built.version.slice(4))) + return yield* new ForkReleaseError({ + message: "This release or a newer version is already published. Build a new version.", + }); + for (const [platform, desktop] of Object.entries(built.desktop)) { + const prior = previous?.desktop[platform]; + if (prior && BigInt(prior.version.slice(4)) > BigInt(desktop.version.slice(4))) + return yield* new ForkReleaseError({ + message: `A newer native build is already published for ${platform}.`, + }); + } + + const serverRelease = + previous && BigInt(previous.server.version.slice(4)) > BigInt(built.server.version.slice(4)) + ? previous + : built; + const release = intent?.release ?? { + ...built, + ui: serverRelease.ui, + assets: serverRelease.assets, + server: serverRelease.server, + desktop: { + ...previous?.desktop, + ...Object.fromEntries( + Object.entries(built.desktop).map(([platform, desktop]) => { + const prior = previous?.desktop[platform]; + const nativePlatform = platform.slice(0, platform.lastIndexOf("-")); + if ( + nativePlatform !== "darwin" && + nativePlatform !== "linux" && + nativePlatform !== "win32" + ) + throw new ForkReleaseError({ message: `Unsupported desktop platform: ${platform}.` }); + const file = `The-Knife-${platform}${desktopTargets[nativePlatform].extension}`; + return [ + platform, + { + ...desktop, + code: prior?.runtime === desktop.runtime ? prior.code : desktop.code, + app: { + ...(prior?.shell === desktop.shell && prior.app.file === file + ? prior.app + : desktop.app), + file, + }, + }, + ]; + }), + ), + }, + }; + const encoded = yield* encodeRelease(release); + if (previous?.version === release.version) { + if ((yield* encodeRelease(previous)) !== encoded) + return yield* new ForkReleaseError({ + message: `Published manifest has different contents: ${release.version}.json.`, + }); + } + const sidecars = []; + const sources = new Map(); + for (const [key, desktop] of Object.entries(built.desktop)) { + const target = release.desktop[key]!; + if (target.app.sha256 !== desktop.app.sha256) continue; + sources.set(target.app.file, desktop.app.file); + const file = `${desktop.app.file}.blockmap`; + if ( + yield* Effect.tryPromise(() => NodeFSP.stat(NodePath.join(directory, file))).pipe( + Effect.map((stat) => stat.isFile()), + Effect.orElseSucceed(() => false), + ) + ) { + const sidecar = yield* artifact(directory, file); + const name = `${target.app.file}.blockmap`; + sidecars.push({ ...sidecar, file: name }); + sources.set(name, file); + } + } + const files = new Map( + [...releaseArtifacts(release), ...sidecars].map((entry) => [entry.file, entry]), + ); + const mutableFiles = new Set([ + ...Object.values(release.desktop).map((desktop) => desktop.app.file), + ...sidecars.map((sidecar) => sidecar.file), + ]); + const retiredFiles = new Set(intent?.retiredFiles); + + const upload = (name: string, body: HttpClientRequest.HttpClientRequest) => + request( + body.pipe( + HttpClientRequest.setUrl(new URL(encodeURIComponent(name), uploadBase).href), + HttpClientRequest.setHeaders(config.uploadApiKey ? { PW: config.uploadApiKey } : {}), + ), + ).pipe( + Effect.flatMap((response) => response.arrayBuffer), + Effect.asVoid, + ); + + const http = HttpClient.withScope(yield* HttpClient.HttpClient); + const publishManifest = (name: string, contents: string, contentType: string) => + Effect.gen(function* () { + const existing = yield* http.get(new URL(name, feedBase).href); + if (existing.status === 200) { + if ((yield* existing.text) === contents) return; + return yield* new ForkReleaseCollision({ + message: `Published manifest has different contents: ${name}.`, + }); + } + if (existing.status !== 404) + return yield* new ForkReleaseError({ + message: `Release share returned HTTP ${existing.status}.`, + }); + yield* upload( + name, + HttpClientRequest.put("").pipe(HttpClientRequest.bodyText(contents, contentType)), + ); + const readable = yield* request(HttpClientRequest.get(new URL(name, feedBase).href)); + if ((yield* readable.text) !== contents) + return yield* new ForkReleaseCollision({ + message: `Published manifest has different contents: ${name}.`, + }); + }); + const matchesDownload = Effect.fn("fork.release.matchesDownload")(function* ( + name: string, + expected: { readonly size: number; readonly sha256: string }, + ) { + const response = yield* http.get(new URL(name, feedBase).href); + if (response.status === 404) return false; + if (response.status !== 200) + return yield* new ForkReleaseError({ + message: `Release share returned HTTP ${response.status}.`, + }); + const hash = NodeCrypto.createHash("sha256"); + let size = 0; + yield* Stream.runForEach(response.stream, (bytes) => + Effect.sync(() => { + hash.update(bytes); + size += bytes.length; + }), + ); + return size === expected.size && hash.digest("hex") === expected.sha256; + }); + const remove = (name: string) => upload(name, HttpClientRequest.make("DELETE")("")); + const retireConflictingReleases = Effect.fn("fork.release.retireConflictingReleases")( + function* () { + const listing = yield* readReleaseListing(feedBase); + const visible = new Set(listing.files.map((file) => file.href)); + const completed = []; + for (const version of completedVersions(listing)) + completed.push(yield* readPublishedRelease(feedBase, version)); + if ( + completed.some( + (completed) => BigInt(completed.version.slice(4)) > BigInt(release.version.slice(4)), + ) + ) + return yield* new ForkReleaseError({ + message: + "A newer release completed during installer publication. Retry with a new version.", + }); + const retained = completed.filter((completed) => + Object.entries(completed.desktop).every(([key, target]) => { + const current = release.desktop[key]; + return ( + current === undefined || + (current.app.file === target.app.file && + current.app.sha256 === target.app.sha256 && + current.app.size === target.app.size) + ); + }), + ); + const protectedFiles = new Set([...releaseFiles(release), ...retained.flatMap(releaseFiles)]); + const conflicting = completed.filter((completed) => !retained.includes(completed)); + if (conflicting.length > 0) { + const retired = new Set([...retiredFiles, ...conflicting.flatMap(releaseFiles)]); + const encodedIntent = yield* encodePublicationIntent({ + release, + retiredFiles: [...retired], + }); + yield* Effect.tryPromise(async () => { + await NodeFSP.writeFile(`${intentPath}.tmp`, encodedIntent + "\n"); + await NodeFSP.rename(`${intentPath}.tmp`, intentPath); + }); + for (const file of retired) retiredFiles.add(file); + } + for (const completedRelease of conflicting) yield* remove(`${completedRelease.version}.json`); + for (const file of retiredFiles) { + if (file.endsWith(".yml") && !protectedFiles.has(file) && visible.has(file)) + yield* remove(file); + } + }, + ); + const publicationOrder = [...files.values()].toSorted( + (left, right) => Number(mutableFiles.has(left.file)) - Number(mutableFiles.has(right.file)), + ); + for (const expected of publicationOrder) { + const mutable = mutableFiles.has(expected.file); + if (mutable) { + if (yield* matchesDownload(expected.file, expected)) continue; + const source = sources.get(expected.file); + if (source === undefined) + return yield* new ForkReleaseError({ + message: `The installer is unavailable: ${expected.file}.`, + }); + const actual = yield* artifact(directory, source); + if (actual.sha256 !== expected.sha256 || actual.size !== expected.size) + return yield* new ForkReleaseError({ message: `Artifact verification failed: ${source}.` }); + const temporary = `${expected.file}.${NodeCrypto.randomUUID()}.PARTIAL`; + yield* Effect.gen(function* () { + const put = yield* HttpClientRequest.put("").pipe( + HttpClientRequest.bodyFile(NodePath.join(directory, source)), + ); + yield* upload(temporary, put); + if (!(yield* matchesDownload(temporary, expected))) + return yield* new ForkReleaseError({ + message: `The uploaded installer did not match its checksum: ${expected.file}.`, + }); + yield* retireConflictingReleases(); + // Effect's method union omits the WebDAV MOVE supported by the share. + yield* upload( + temporary, + HttpClientRequest.make("MOVE" as HttpMethod)("").pipe( + HttpClientRequest.setHeaders({ + Destination: new URL(expected.file, uploadBase).href, + Overwrite: "T", + }), + ), + ); + if (!(yield* matchesDownload(expected.file, expected))) + return yield* new ForkReleaseError({ + message: `The promoted installer did not match its checksum: ${expected.file}.`, + }); + }).pipe(Effect.ensuring(remove(temporary).pipe(Effect.ignore))); + continue; + } + const visible = yield* http.head(new URL(expected.file, feedBase).href); + if (visible.status === 200 && Number(visible.headers["content-length"]) === expected.size) + continue; + if (visible.status !== 404) + return yield* new ForkReleaseError({ + message: `Published artifact does not match: ${expected.file}.`, + }); + const put = yield* HttpClientRequest.put("").pipe( + HttpClientRequest.bodyFile(NodePath.join(directory, expected.file)), + ); + yield* upload(expected.file, put); + const readable = yield* request(HttpClientRequest.head(new URL(expected.file, feedBase).href)); + if (Number(readable.headers["content-length"]) !== expected.size) + return yield* new ForkReleaseError({ + message: `Published artifact is not readable: ${expected.file}.`, + }); + yield* Effect.log(`Published ${expected.file} (${expected.size} bytes)`); + } + if (previous?.version !== release.version) { + yield* retireConflictingReleases(); + for (const [platform, desktopRelease] of desktopReleases) { + const [key, desktop] = Object.entries(built.desktop).find(([key]) => + key.startsWith(`${platform}-`), + )!; + const target = release.desktop[key]!; + if (target.app.version !== desktopRelease.manifest.version) continue; + const name = `${desktopRelease.manifest.version}${desktopTargets[platform].channel}.yml`; + const manifest = { + ...desktopRelease.manifest, + files: desktopRelease.manifest.files + .filter((file) => file.url === desktop.app.file) + .map((file) => ({ ...file, url: target.app.file })), + }; + yield* publishManifest( + name, + serializeUpdateManifest(manifest, { + platformLabel: desktopTargets[platform].label, + }), + "text/yaml", + ); + } + yield* publishManifest(`${release.version}.json`, encoded + "\n", "application/json"); + } + + for (let attempt = 0; attempt < 8; attempt++) { + const versions = completedVersions(yield* readReleaseListing(feedBase)); + const newest = versions[0]; + if (!newest || BigInt(newest.slice(4)) < BigInt(release.version.slice(4))) + return yield* new ForkReleaseError({ + message: "The release is missing from the feed. Retry publication.", + }); + const latest = yield* readPublishedRelease(feedBase, newest); + let serverRelease = latest; + const desktop = { ...latest.desktop }; + for (const version of versions.slice(1)) { + const completed = yield* readPublishedRelease(feedBase, version); + if (BigInt(completed.server.version.slice(4)) > BigInt(serverRelease.server.version.slice(4))) + serverRelease = completed; + for (const [platform, target] of Object.entries(completed.desktop)) { + const current = desktop[platform]; + if (!current || BigInt(target.version.slice(4)) > BigInt(current.version.slice(4))) + desktop[platform] = target; + } + } + const merged = { + ...latest, + ui: serverRelease.ui, + assets: serverRelease.assets, + server: serverRelease.server, + desktop, + }; + if ((yield* encodeRelease(merged)) === (yield* encodeRelease(latest))) { + const currentVersions = completedVersions(yield* readReleaseListing(feedBase)); + if (currentVersions.join("\n") !== versions.join("\n")) continue; + const maintained = yield* maintainPublicFolder( + latest, + feedBase, + uploadBase, + config.uploadApiKey, + versions, + retiredFiles, + ); + if (!maintained) continue; + if (NodeFS.existsSync(intentPath)) { + const completed = yield* readPublishedRelease(feedBase, release.version); + if ((yield* encodeRelease(completed)) !== encoded) + return yield* new ForkReleaseError({ + message: "The completed release differs from the publication intent.", + }); + yield* Effect.tryPromise(() => NodeFSP.rm(intentPath)); + } + yield* Effect.log(`Fork release ${latest.version} is available to all machines.`); + return; + } + const timestamp = BigInt(Math.floor((yield* Clock.currentTimeMillis) / 1000)); + const next = BigInt(newest.slice(4)) + 1n; + merged.version = `0.0.${timestamp > next ? timestamp : next}`; + yield* publishManifest( + `${merged.version}.json`, + (yield* encodeRelease(merged)) + "\n", + "application/json", + ).pipe(Effect.catchTags({ ForkReleaseCollision: () => Effect.void })); + } + return yield* new ForkReleaseError({ + message: "Concurrent releases are still completing. Retry publication.", + }); +}); + +const archiveComponent = Effect.fn("fork.release.archive")(function* ( + directory: string, + source: string, + name: string, +) { + const entries = yield* Effect.tryPromise(async () => { + const files: string[] = []; + const visit = async (relative: string): Promise => { + const file = NodePath.join(source, relative); + const stat = await NodeFSP.lstat(file); + if (stat.isDirectory()) { + for (const child of (await NodeFSP.readdir(file)).sort()) + await visit(NodePath.join(relative, child)); + } + if (!stat.isSymbolicLink()) await NodeFSP.utimes(file, 0, 0); + files.push(relative); + }; + await visit("."); + return files; + }); + const list = NodePath.join(directory, `${name}.files`); + yield* Effect.tryPromise(() => NodeFSP.writeFile(list, entries.join("\n") + "\n")); + const temporary = `${name}.tar.gz`; + const platform = yield* HostProcess.Platform; + const owner = + platform === "darwin" + ? [ + "--uid", + "0", + "--gid", + "0", + "--uname", + "root", + "--gname", + "root", + "--options", + "gzip:!timestamp", + ] + : ["--owner=0", "--group=0", "--numeric-owner"]; + yield* run("tar", [ + "--format=gnutar", + ...owner, + "--no-recursion", + "-czf", + NodePath.join(directory, temporary), + "-C", + source, + "-T", + list, + ]); + yield* Effect.tryPromise(() => NodeFSP.rm(list)); + const result = yield* artifact(directory, temporary); + const file = `knife-${name}-${result.sha256}.tar.gz`; + yield* Effect.tryPromise(() => + NodeFSP.rename(NodePath.join(directory, temporary), NodePath.join(directory, file)), + ); + return { ...result, file }; +}); + +const prepareForkComponents = Effect.fn("fork.release.prepareComponents")(function* ( + directory: string, + resources: string, + platform: keyof typeof desktopTargets, + arch: string, + version: string, + serverRuntime: string, +) { + const desktopRelease = yield* verifyDesktopRelease(directory, platform); + const components = NodePath.join(directory, "components"); + const codeRoot = NodePath.join(components, "code"); + yield* Effect.tryPromise(async () => { + await NodeFSP.mkdir(components); + extractAll(NodePath.join(resources, "app.asar"), codeRoot); + if (platform === "win32") + extractAll(NodePath.join(resources, "server.asar"), NodePath.join(codeRoot, "server")); + }); + const runtime = yield* Effect.tryPromise(() => + NodeFSP.readFile(NodePath.join(codeRoot, "knife-runtime.json"), "utf8"), + ).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ForkRuntimeManifest)))); + if (runtime.version !== desktopRelease.manifest.version) + return yield* new ForkReleaseError({ + message: "The packaged app version does not match its installer.", + }); + const serverCodeRoot = platform === "win32" ? NodePath.join(codeRoot, "server") : codeRoot; + const archive = desktopRelease.files.find((name) => + name.endsWith(installerSuffix(platform, arch)), + ); + if (!archive) + return yield* new ForkReleaseError({ + message: "The release has no installer for this architecture.", + }); + const uiRoot = NodePath.join(components, "ui"); + const assetsRoot = NodePath.join(components, "assets"); + const serverRoot = NodePath.join(components, "server"); + yield* Effect.tryPromise(async () => { + await NodeFSP.rename(NodePath.join(serverCodeRoot, "apps/server/dist/client"), uiRoot); + const splitAssets = async (relative: string): Promise => { + const file = NodePath.join(uiRoot, relative); + if ((await NodeFSP.stat(file)).isDirectory()) { + for (const child of await NodeFSP.readdir(file)) + await splitAssets(NodePath.join(relative, child)); + } else if (relative !== ".vite/manifest.json" && !/\.(?:html|m?js|css)$/.test(relative)) { + const target = NodePath.join(assetsRoot, relative); + await NodeFSP.mkdir(NodePath.dirname(target), { recursive: true }); + await NodeFSP.rename(file, target); + } + }; + await splitAssets(""); + await NodeFSP.mkdir(NodePath.join(serverRoot, "apps/server"), { recursive: true }); + await NodeFSP.cp( + NodePath.join(serverCodeRoot, "apps/server/dist"), + NodePath.join(serverRoot, "apps/server/dist"), + { recursive: true }, + ); + for (const dist of [ + NodePath.join(serverCodeRoot, "apps/server/dist"), + NodePath.join(codeRoot, "apps/desktop/dist-electron"), + NodePath.join(serverRoot, "apps/server/dist"), + ]) { + for (const name of await NodeFSP.readdir(dist)) { + if (name.endsWith(".map")) await NodeFSP.rm(NodePath.join(dist, name)); + } + } + }); + const code = yield* archiveComponent(directory, codeRoot, "code"); + const app = { ...(yield* artifact(directory, archive)), version: runtime.version }; + const ui = yield* archiveComponent(directory, uiRoot, "ui"); + const assets = yield* archiveComponent(directory, assetsRoot, "assets"); + const manifest = { + version, + ui, + assets, + server: { + ...(yield* archiveComponent(directory, serverRoot, "server")), + version: runtime.version, + runtime: serverRuntime, + }, + desktop: { + [`${platform}-${arch}`]: { + version: runtime.version, + ui, + assets, + shell: runtime.shell, + runtime: runtime.sha256, + code, + app, + }, + }, + }; + const encoded = yield* encodeRelease(manifest); + yield* Effect.tryPromise(() => + NodeFSP.writeFile(NodePath.join(directory, "release.json"), encoded + "\n"), + ); + yield* Effect.tryPromise(() => NodeFSP.rm(components, { recursive: true })); + yield* Effect.log( + `Built ${version}: UI ${manifest.ui.size} bytes, assets ${manifest.assets.size} bytes, server ${manifest.server.size} bytes.`, + ); + return directory; +}); + +export function resolveForkMacSigningType( + identity: string, + identities: string, + notarizationKeychainProfile: string | undefined, +): "development" | "distribution" { + const certificate = identities + .split("\n") + .map((line) => /^\s*\d+\) ([A-Fa-f0-9]{40}) "([^"]+)"/.exec(line)) + .find((match) => match?.[1]?.toUpperCase() === identity.toUpperCase())?.[2]; + if (!certificate) + throw new ForkReleaseError({ + message: + "The release signing identity is unavailable. Import its certificate and private key into the signing keychain.", + }); + if (certificate.startsWith("Developer ID Application:")) { + if (!notarizationKeychainProfile) + throw new ForkReleaseError({ + message: + "Developer ID releases need notarization. Set notarizationKeychainProfile in the private release config.", + }); + return "distribution"; + } + if (certificate.startsWith("Apple Development:") && !notarizationKeychainProfile) + return "development"; + throw new ForkReleaseError({ + message: + "Notarization requires a Developer ID Application certificate. Update signingIdentity in the private release config.", + }); +} + +const buildForkRelease = Effect.fn("fork.release.build")(function* (config: ReleaseConfig) { + const keychainPassword = yield* Effect.tryPromise(() => + NodeFSP.readFile(`${config.signingKeychain}.password`, "utf8"), + ); + yield* run("security", [ + "unlock-keychain", + "-p", + keychainPassword.replace(/[\r\n]+$/, ""), + config.signingKeychain, + ]); + const identities = yield* run( + "security", + ["find-identity", "-v", "-p", "codesigning", config.signingKeychain], + { capture: true }, + ); + const signingType = yield* Effect.try({ + try: () => + resolveForkMacSigningType( + config.signingIdentity, + identities, + config.notarizationKeychainProfile, + ), + catch: (cause) => + cause instanceof ForkReleaseError + ? cause + : new ForkReleaseError({ message: "Could not verify the release signing identity." }), + }); + if (config.notarizationKeychainProfile) { + yield* run( + "xcrun", + [ + "notarytool", + "history", + "--keychain-profile", + config.notarizationKeychainProfile, + "--keychain", + config.signingKeychain, + ], + { capture: true }, + ); + } else { + yield* Effect.logWarning( + "Apple Development signing does not clear Gatekeeper. Configure Developer ID Application signing and notarization for direct downloads.", + ); + } + const version = `0.0.${Math.floor((yield* Clock.currentTimeMillis) / 1000)}`; + const directory = NodePath.join(repoRoot, "release", "fork", version); + yield* Effect.tryPromise(() => NodeFSP.mkdir(NodePath.dirname(directory), { recursive: true })); + yield* Effect.tryPromise(() => NodeFSP.mkdir(directory)); + const commit = (yield* run("jj", ["log", "-r", "@", "--no-graph", "-T", "commit_id.short(12)"], { + capture: true, + })).trim(); + if (!/^[a-f0-9]{12}$/.test(commit)) + return yield* new ForkReleaseError({ message: "Could not read the current Jujutsu revision." }); + const env = { ...loadRepoEnv(), ...process.env }; + env.T3CODE_COMMIT_HASH = commit; + env.APP_VERSION = version; + env.T3CODE_DESKTOP_UPDATE_URL = config.feedUrl; + env.T3CODE_DESKTOP_MAC_SIGN_IDENTITY = config.signingIdentity; + env.T3CODE_DESKTOP_MAC_SIGN_TYPE = signingType; + env.CSC_KEYCHAIN = config.signingKeychain; + if (config.notarizationKeychainProfile) { + env.APPLE_KEYCHAIN_PROFILE = config.notarizationKeychainProfile; + env.APPLE_KEYCHAIN = config.signingKeychain; + delete env.APPLE_ID; + delete env.APPLE_APP_SPECIFIC_PASSWORD; + delete env.APPLE_TEAM_ID; + } + env.T3CODE_DESKTOP_SIGNED = "false"; + env.T3CODE_DESKTOP_SKIP_BUILD = "false"; + delete env.T3CODE_DISABLE_AUTO_UPDATE; + delete env.T3CODE_DESKTOP_MOCK_UPDATES; + yield* run( + process.execPath, + [ + "scripts/build-desktop-artifact.ts", + "--platform", + "mac", + "--target", + "zip", + "--arch", + "arm64", + "--build-version", + version, + "--output-dir", + directory, + "--verbose", + ], + { env }, + ); + const release = yield* verifyDesktopRelease(directory); + const archive = release.files.find((name) => name.endsWith(".zip"))!; + yield* run("ditto", ["-x", "-k", NodePath.join(directory, archive), directory]); + const entries = yield* Effect.tryPromise(() => NodeFSP.readdir(directory)); + const appName = entries.find((name) => name.endsWith(".app")); + if (!appName) + return yield* new ForkReleaseError({ message: "The update ZIP did not contain an app." }); + yield* run("codesign", ["--verify", "--deep", "--strict", NodePath.join(directory, appName)]); + if (signingType === "distribution") { + yield* run("xcrun", ["stapler", "validate", NodePath.join(directory, appName)]); + yield* run("spctl", ["--assess", "--type", "execute", NodePath.join(directory, appName)]); + } + yield* run( + NodePath.join(directory, appName, "Contents", "MacOS", appName.slice(0, -4)), + ["-p", "process.versions.node"], + { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } }, + ); + return yield* prepareForkComponents( + directory, + NodePath.join(directory, appName, "Contents/Resources"), + "darwin", + "arm64", + version, + yield* Effect.tryPromise(() => fingerprintForkServerRuntime(repoRoot)), + ); +}); + +export const ingestForkRelease = Effect.fn("fork.release.ingest")(function* ( + platformKey: string, + source: string, + directory: string, + version: string, +) { + const match = /^(linux|win32)-(x64|arm64)$/.exec(platformKey); + if (!match) + return yield* new ForkReleaseError({ + message: "Choose linux-x64, linux-arm64, win32-x64, or win32-arm64.", + }); + const platform = match[1] === "linux" ? "linux" : "win32"; + const arch = match[2]!; + const verified = yield* verifyDesktopRelease(source, platform); + const installer = verified.manifest.files.find((file) => + file.url.endsWith(installerSuffix(platform, arch)), + ); + if (!installer) + return yield* new ForkReleaseError({ + message: "The release has no installer for this architecture.", + }); + const archive = NodePath.join(source, `knife-resources-${platformKey}.tar.gz`); + const entries = (yield* run("tar", ["-tzf", archive], { capture: true })).trim().split(/\r?\n/); + if ( + entries.some((entry) => { + const path = entry.replace(/^\.\//, ""); + return ( + path.startsWith("/") || + path.includes("\\") || + path.split("/").includes("..") || + !/^(?:app\.asar(?:\.unpacked(?:\/.*)?)?|server\.asar(?:\.unpacked(?:\/.*)?)?|knife-server-runtime)\/?$/.test( + path, + ) + ); + }) + ) + return yield* new ForkReleaseError({ + message: "The native resource archive contains an unexpected path.", + }); + yield* Effect.tryPromise(() => NodeFSP.mkdir(directory, { recursive: true })); + const resources = yield* Effect.tryPromise(() => + NodeFSP.mkdtemp(NodePath.join(directory, "native-resources-")), + ); + yield* run("tar", ["-xzf", archive, "-C", resources]); + const serverRuntime = (yield* Effect.tryPromise(() => + NodeFSP.readFile(NodePath.join(resources, "knife-server-runtime"), "utf8"), + )).trim(); + yield* Effect.tryPromise(async () => { + for (const name of verified.files.filter( + (name) => name === installer.url || name === `${installer.url}.blockmap`, + )) + await NodeFSP.copyFile(NodePath.join(source, name), NodePath.join(directory, name)); + await NodeFSP.writeFile( + NodePath.join(directory, desktopTargets[platform].manifest), + serializeUpdateManifest( + { ...verified.manifest, files: [installer] }, + { platformLabel: desktopTargets[platform].label }, + ), + ); + }); + yield* prepareForkComponents(directory, resources, platform, arch, version, serverRuntime); + yield* Effect.tryPromise(() => NodeFSP.rm(resources, { recursive: true })); + return directory; +}); + +if (import.meta.main) { + Effect.gen(function* () { + const mode = process.argv[2] ?? "release"; + if (!["build", "publish", "release", "ingest"].includes(mode)) + return yield* new ForkReleaseError({ + message: + "Use build, publish , release, or ingest .", + }); + const config = yield* Effect.tryPromise(() => + NodeFSP.readFile(NodePath.join(NodeOS.homedir(), ".config/t3-fork-release.json"), "utf8"), + ).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ReleaseConfig))), + Effect.mapError( + () => + new ForkReleaseError({ + message: + "Configure ~/.config/t3-fork-release.json with feedUrl, uploadUrl, signingIdentity, and signingKeychain.", + }), + ), + ); + let directory: string | undefined; + if (mode === "ingest") { + const platform = process.argv[3]; + const source = process.argv[4]; + if (!platform || !source) + return yield* new ForkReleaseError({ + message: "Pass the platform and native build directory to ingest.", + }); + const version = `0.0.${Math.floor((yield* Clock.currentTimeMillis) / 1000)}`; + directory = yield* ingestForkRelease( + platform, + NodePath.resolve(source), + NodePath.join(repoRoot, "release/fork", version), + version, + ); + } else { + directory = mode === "publish" ? process.argv[3] : yield* buildForkRelease(config); + } + if (!directory) + return yield* new ForkReleaseError({ message: "Pass the release directory to publish." }); + if (mode !== "build") yield* publishForkRelease(NodePath.resolve(directory), config); + }).pipe( + Effect.scoped, + Effect.catch((error) => + Effect.logError(error instanceof Error ? error.message : "Fork release failed.").pipe( + Effect.andThen( + Effect.sync(() => { + process.exitCode = 1; + }), + ), + ), + ), + Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), + NodeRuntime.runMain, + ); +} -- 2.54.0