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, + ); +}