| author | |
| committer | |
| log | 44b17c3a0daa9b95d2b15f3d87ff07cf8c3b3d64 |
| tree | 5013542b44f00e0438aeeb37fc9ce066a33ae71b |
| parent | 97b13b7697f982a8dfb6ebe7fd7b540861e75dd9 |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
Clover wants every self-tested change available to her machines without uploading Electron for each UI revision, plus a public download page she can share. Publish separate checksum-addressed UI, static asset, application-code, and portable-server archives. Reuse unchanged code and shell artifacts by their runtime identities. Publish immutable completion manifests and discover releases through the public Copyparty listing; authenticated writes and signing credentials stay in host configuration.
Download updates automatically. Apply UI updates after one minute unfocused or ten minutes idle only after saving durable drafts, composer selection, and timeline anchors; defer while browser guests, terminal views, dialogs, image processing, or other work cannot survive refresh. Backend and shell changes retain explicit restart confirmation. Select staged code through the signed bootstrap, verify it before activation, retain bundled recovery, and keep UI-only updates out of backend restart plans.
Build macOS, Linux, and Windows installers with their native dependencies. Ingest verified platform resources without replacing another platform's backend, UI, or assets. Keep the portable server and its matching UI together when native builds finish in a different order. Merge platform publications by each native build version before generating the root download page. Retain up to two nonconflicting completed manifests with their full referenced artifact closure, and stop cleanup when the completion set changes.
Use the same feed for the persistent server container. Cache independent server/UI/assets archives, check the locked native dependency and bootstrap contract, and prefetch without activating code. A confirmed restart selects the staged release; interrupted downloads and feed outages retain the installed runtime. Derive server identity from its selected compiled release.
Give each desktop platform one stable installer filename in the README and updater metadata. Verify staged installer bytes before promotion, retire completed releases whose installer checksums would no longer match, and keep exact merged publication intent for retries. Keep one active installer per platform, preserve native versions and signatures, and serialize publication.
Select development or distribution signing from the actual Mac certificate. Developer ID builds require notarization credentials, hardened runtime, notarization, stapling, and Gatekeeper verification before publication; private signing configuration stays outside the checkout.
Assisted-by: gpt-6.1-sol76 files changed, 7086 insertions(+), 158 deletions(-)
apps/desktop/scripts/main-process-bundle.test.mjs+58-10| ... | @@ -113,28 +113,23 @@ void import("./linux.ts").then(({ result }) => process.emit("ready", result));`, | ... | @@ -113,28 +113,23 @@ void import("./linux.ts").then(({ result }) => process.emit("ready", result));`, |
| 113 | it("loads the emitted packaged boot entry and backend cache preload", async () => { | 113 | it("loads the emitted packaged boot entry and backend cache preload", async () => { |
| 114 | const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-boot-")); | 114 | const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-boot-")); |
| 115 | try { | 115 | try { |
| 116 | const entries = ["src/boot.ts", "src/compileCache.ts"]; | 116 | const entries = new Set(["src/boot.ts", "src/compileCache.ts"]); |
| 117 | await NodeFSP.mkdir(NodePath.join(directory, "src")); | 117 | const outputDirectory = NodePath.join(directory, "bundle/apps/desktop/dist-electron"); |
| 118 | await Promise.all( | ||
| 119 | entries.map((entry) => | ||
| 120 | NodeFSP.copyFile(new URL(`../${entry}`, import.meta.url), NodePath.join(directory, entry)), | ||
| 121 | ), | ||
| 122 | ); | ||
| 123 | assert.ok(Array.isArray(desktopConfig.pack)); | 118 | assert.ok(Array.isArray(desktopConfig.pack)); |
| 124 | for (const packConfig of desktopConfig.pack) { | 119 | for (const packConfig of desktopConfig.pack) { |
| 125 | if (!Array.isArray(packConfig.entry)) continue; | 120 | if (!Array.isArray(packConfig.entry)) continue; |
| 126 | if (!packConfig.entry.some((entry) => entries.includes(entry))) continue; | 121 | if (!packConfig.entry.some((entry) => entries.has(entry))) continue; |
| 127 | await build({ | 122 | await build({ |
| 128 | ...packConfig, | 123 | ...packConfig, |
| 129 | config: false, | 124 | config: false, |
| 130 | cwd: directory, | 125 | cwd: NodePath.resolve(import.meta.dirname, ".."), |
| 126 | outDir: outputDirectory, | ||
| 131 | tsconfig: false, | 127 | tsconfig: false, |
| 132 | sourcemap: false, | 128 | sourcemap: false, |
| 133 | onSuccess: undefined, | 129 | onSuccess: undefined, |
| 134 | logLevel: "silent", | 130 | logLevel: "silent", |
| 135 | }); | 131 | }); |
| 136 | } | 132 | } |
| 137 | const outputDirectory = NodePath.join(directory, "dist-electron"); | ||
| 138 | const fixture = `console.log(require('node:module').getCompileCacheDir() ? 'cached' : 'uncached');`; | 133 | const fixture = `console.log(require('node:module').getCompileCacheDir() ? 'cached' : 'uncached');`; |
| 139 | await NodeFSP.writeFile(NodePath.join(outputDirectory, "main.cjs"), fixture); | 134 | await NodeFSP.writeFile(NodePath.join(outputDirectory, "main.cjs"), fixture); |
| 140 | await NodeFSP.writeFile( | 135 | await NodeFSP.writeFile( |
| ... | @@ -167,6 +162,59 @@ it("loads the emitted packaged boot entry and backend cache preload", async () = | ... | @@ -167,6 +162,59 @@ it("loads the emitted packaged boot entry and backend cache preload", async () = |
| 167 | assert.equal(child.stdout.trim(), disabled ? "uncached" : "cached"); | 162 | assert.equal(child.stdout.trim(), disabled ? "uncached" : "cached"); |
| 168 | } | 163 | } |
| 169 | } | 164 | } |
| 165 | const shell = "a".repeat(64); | ||
| 166 | const payload = NodePath.join(directory, "data/userdata/fork-releases/release-test"); | ||
| 167 | for (const name of ["apps/desktop/dist-electron", "apps/server/dist/client"]) { | ||
| 168 | await NodeFSP.mkdir(NodePath.join(payload, name), { recursive: true }); | ||
| 169 | } | ||
| 170 | await NodeFSP.cp(outputDirectory, NodePath.join(payload, "apps/desktop/dist-electron"), { | ||
| 171 | recursive: true, | ||
| 172 | }); | ||
| 173 | await NodeFSP.writeFile( | ||
| 174 | NodePath.join(payload, "apps/desktop/dist-electron/main.cjs"), | ||
| 175 | "console.log('payload:' + JSON.parse(process.env.T3CODE_DESKTOP_PAYLOAD).version);", | ||
| 176 | ); | ||
| 177 | await NodeFSP.writeFile(NodePath.join(payload, "apps/desktop/dist-electron/preload.cjs"), ""); | ||
| 178 | await NodeFSP.writeFile(NodePath.join(payload, "apps/server/dist/bin.mjs"), ""); | ||
| 179 | await NodeFSP.writeFile( | ||
| 180 | NodePath.join(payload, "apps/server/dist/client/index.html"), | ||
| 181 | "fixture", | ||
| 182 | ); | ||
| 183 | await NodeFSP.writeFile( | ||
| 184 | NodePath.join(directory, "bundle/knife-runtime.json"), | ||
| 185 | JSON.stringify({ shell }), | ||
| 186 | ); | ||
| 187 | await NodeFSP.writeFile( | ||
| 188 | NodePath.join(payload, "../current.json"), | ||
| 189 | JSON.stringify({ | ||
| 190 | directory: "release-test", | ||
| 191 | shell, | ||
| 192 | runtime: "b".repeat(64), | ||
| 193 | version: "0.0.2000000000", | ||
| 194 | }), | ||
| 195 | ); | ||
| 196 | const electronStub = NodePath.join(directory, "electron.cjs"); | ||
| 197 | await NodeFSP.writeFile( | ||
| 198 | electronStub, | ||
| 199 | ` | ||
| 200 | Object.defineProperty(process.versions, 'electron', { value: '44.4.5' }); | ||
| 201 | const Module = require('node:module'); | ||
| 202 | const load = Module._load; | ||
| 203 | Module._load = function(name, ...args) { | ||
| 204 | return name === 'electron' ? { app: { isPackaged: true } } : load.call(this, name, ...args); | ||
| 205 | }; | ||
| 206 | `, | ||
| 207 | ); | ||
| 208 | const selected = NodeChildProcess.spawnSync( | ||
| 209 | process.execPath, | ||
| 210 | ["--require", electronStub, NodePath.join(outputDirectory, "boot.cjs")], | ||
| 211 | { | ||
| 212 | encoding: "utf8", | ||
| 213 | env: { ...process.env, T3CODE_HOME: NodePath.join(directory, "data") }, | ||
| 214 | }, | ||
| 215 | ); | ||
| 216 | assert.equal(selected.status, 0, selected.stderr); | ||
| 217 | assert.equal(selected.stdout.trim(), "payload:0.0.2000000000"); | ||
| 170 | } finally { | 218 | } finally { |
| 171 | await NodeFSP.rm(directory, { recursive: true, force: true }); | 219 | await NodeFSP.rm(directory, { recursive: true, force: true }); |
| 172 | } | 220 | } |
apps/desktop/src/app/DesktopApp.ts+3-3| ... | @@ -32,6 +32,7 @@ import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; | ... | @@ -32,6 +32,7 @@ import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; |
| 32 | import * as DesktopState from "./DesktopState.ts"; | 32 | import * as DesktopState from "./DesktopState.ts"; |
| 33 | import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts"; | 33 | import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts"; |
| 34 | import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; | 34 | import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; |
| 35 | import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts"; | ||
| 35 | import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; | 36 | import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; |
| 36 | import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; | 37 | import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; |
| 37 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; | 38 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; |
| ... | @@ -171,14 +172,13 @@ const bootstrap = Effect.gen(function* () { | ... | @@ -171,14 +172,13 @@ const bootstrap = Effect.gen(function* () { |
| 171 | yield* logBootstrapInfo("bootstrap start"); | 172 | yield* logBootstrapInfo("bootstrap start"); |
| 172 | 173 | ||
| 173 | const settings = yield* desktopSettings.get; | 174 | const settings = yield* desktopSettings.get; |
| 174 | // The renderer is served from the bundled client (or Vite in development) | 175 | // The renderer loads independently of the chat server. |
| 175 | // rather than through the local backend, so the window can open without one. | ||
| 176 | const electronProtocol = yield* ElectronProtocol.ElectronProtocol; | 176 | const electronProtocol = yield* ElectronProtocol.ElectronProtocol; |
| 177 | yield* electronProtocol.registerDesktopProtocol({ | 177 | yield* electronProtocol.registerDesktopProtocol({ |
| 178 | scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), | 178 | scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), |
| 179 | ...(environment.isDevelopment | 179 | ...(environment.isDevelopment |
| 180 | ? { targetOrigin: Option.getOrThrow(environment.devServerUrl) } | 180 | ? { targetOrigin: Option.getOrThrow(environment.devServerUrl) } |
| 181 | : { assetDirectory: environment.clientAssetsDir }), | 181 | : { assetDirectories: (yield* DesktopLocalUi.DesktopLocalUi).assetDirectories }), |
| 182 | clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, | 182 | clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, |
| 183 | }); | 183 | }); |
| 184 | yield* installDesktopIpcHandlers(); | 184 | yield* installDesktopIpcHandlers(); |
apps/desktop/src/app/DesktopEnvironment.test.ts+26| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import * as NodePath from "@effect/platform-node/NodePath"; | 1 | import * as NodePath from "@effect/platform-node/NodePath"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { assert, describe, it } from "@effect/vitest"; | 3 | import { assert, describe, it } from "@effect/vitest"; |
| 4 | import { vi } from "vite-plus/test"; | ||
| 4 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| ... | @@ -141,6 +142,31 @@ describe("DesktopEnvironment", () => { | ... | @@ -141,6 +142,31 @@ describe("DesktopEnvironment", () => { |
| 141 | }), | 142 | }), |
| 142 | ); | 143 | ); |
| 143 | 144 | ||
| 145 | it.effect("keeps downloaded Windows desktop and server dependencies separate", () => | ||
| 146 | Effect.gen(function* () { | ||
| 147 | vi.stubEnv("T3CODE_DESKTOP_PAYLOAD", JSON.stringify({ directory: "/updates/release-new" })); | ||
| 148 | try { | ||
| 149 | const environment = yield* makeEnvironment({ | ||
| 150 | platform: "win32", | ||
| 151 | isPackaged: true, | ||
| 152 | appPath: "/updates/release-new", | ||
| 153 | resourcesPath: "/install/resources", | ||
| 154 | }); | ||
| 155 | assert.equal(environment.serverRoot, "/updates/release-new/server"); | ||
| 156 | assert.equal( | ||
| 157 | environment.backendEntryPath, | ||
| 158 | "/updates/release-new/server/apps/server/dist/bin.mjs", | ||
| 159 | ); | ||
| 160 | assert.equal( | ||
| 161 | environment.clientAssetsDir, | ||
| 162 | "/updates/release-new/server/apps/server/dist/client", | ||
| 163 | ); | ||
| 164 | } finally { | ||
| 165 | vi.unstubAllEnvs(); | ||
| 166 | } | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | |||
| 144 | it.effect("uses the stable desktop entry as the packaged Linux portal identity", () => | 170 | it.effect("uses the stable desktop entry as the packaged Linux portal identity", () => |
| 145 | Effect.gen(function* () { | 171 | Effect.gen(function* () { |
| 146 | const environment = yield* makeEnvironment({ | 172 | const environment = yield* makeEnvironment({ |
apps/desktop/src/app/DesktopEnvironment.ts+6-7| ... | @@ -11,6 +11,8 @@ import * as Layer from "effect/Layer"; | ... | @@ -11,6 +11,8 @@ import * as Layer from "effect/Layer"; |
| 11 | import * as Option from "effect/Option"; | 11 | import * as Option from "effect/Option"; |
| 12 | import * as Path from "effect/Path"; | 12 | import * as Path from "effect/Path"; |
| 13 | 13 | ||
| 14 | import { activeForkRuntime } from "../updates/forkRuntime.ts"; | ||
| 15 | |||
| 14 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | 16 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 15 | import * as DesktopConfig from "./DesktopConfig.ts"; | 17 | import * as DesktopConfig from "./DesktopConfig.ts"; |
| 16 | import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts"; | 18 | import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts"; |
| ... | @@ -54,12 +56,7 @@ export class DesktopEnvironment extends Context.Service< | ... | @@ -54,12 +56,7 @@ export class DesktopEnvironment extends Context.Service< |
| 54 | readonly browserArtifactsDir: string; | 56 | readonly browserArtifactsDir: string; |
| 55 | readonly rootDir: string; | 57 | readonly rootDir: string; |
| 56 | readonly appRoot: string; | 58 | readonly appRoot: string; |
| 57 | // Root of the tree containing apps/server/dist and node_modules for the | 59 | // Windows keeps separate desktop and server dependency trees. |
| 58 | // backend. Equals appRoot everywhere except packaged Windows, where the | ||
| 59 | // server tree ships as the resources/server.asar sidecar (see | ||
| 60 | // scripts/build-desktop-artifact.ts) that the asar-aware | ||
| 61 | // ELECTRON_RUN_AS_NODE primary reads in place and the WSL backend | ||
| 62 | // extracts on demand (see DesktopWslServerTree). | ||
| 63 | readonly serverRoot: string; | 60 | readonly serverRoot: string; |
| 64 | readonly backendEntryPath: string; | 61 | readonly backendEntryPath: string; |
| 65 | // Built web client the packaged renderer is served from over t3code://app. | 62 | // Built web client the packaged renderer is served from over t3code://app. |
| ... | @@ -173,7 +170,9 @@ const make = Effect.fn("desktop.environment.make")(function* ( | ... | @@ -173,7 +170,9 @@ const make = Effect.fn("desktop.environment.make")(function* ( |
| 173 | const appRoot = input.isPackaged ? input.appPath : rootDir; | 170 | const appRoot = input.isPackaged ? input.appPath : rootDir; |
| 174 | const serverRoot = | 171 | const serverRoot = |
| 175 | input.isPackaged && input.platform === "win32" | 172 | input.isPackaged && input.platform === "win32" |
| 176 | ? path.join(input.resourcesPath, "server.asar") | 173 | ? activeForkRuntime() === undefined |
| 174 | ? path.join(input.resourcesPath, "server.asar") | ||
| 175 | : path.join(appRoot, "server") | ||
| 177 | : appRoot; | 176 | : appRoot; |
| 178 | const branding = resolveDesktopAppBranding({ | 177 | const branding = resolveDesktopAppBranding({ |
| 179 | isDevelopment, | 178 | isDevelopment, |
apps/desktop/src/app/DesktopPreReadyPlatform.test.ts+1-1| ... | @@ -175,7 +175,7 @@ describe("DesktopPreReadyPlatform", () => { | ... | @@ -175,7 +175,7 @@ describe("DesktopPreReadyPlatform", () => { |
| 175 | const identity = yield* Effect.promise(() => portalIdentity); | 175 | const identity = yield* Effect.promise(() => portalIdentity); |
| 176 | assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); | 176 | assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); |
| 177 | assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); | 177 | assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); |
| 178 | assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)"); | 178 | assert.include(identity.desktopEntry ?? "", "Name=The Knife"); |
| 179 | assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); | 179 | assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); |
| 180 | assert.include( | 180 | assert.include( |
| 181 | identity.desktopEntry ?? "", | 181 | identity.desktopEntry ?? "", |
apps/desktop/src/app/DesktopPreReadyPlatform.ts+3-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { activeForkRuntime } from "../updates/forkRuntime.ts"; | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings, prepares the Linux desktop entry, and asks Hyprland for its version synchronously before app services are available. | 2 | // @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings, prepares the Linux desktop entry, and asks Hyprland for its version synchronously before app services are available. |
| 2 | import * as NodeChildProcess from "node:child_process"; | 3 | import * as NodeChildProcess from "node:child_process"; |
| 3 | import * as NodeFS from "node:fs"; | 4 | import * as NodeFS from "node:fs"; |
| ... | @@ -84,7 +85,7 @@ export const make = Effect.gen(function* () { | ... | @@ -84,7 +85,7 @@ export const make = Effect.gen(function* () { |
| 84 | NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true }); | 85 | NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true }); |
| 85 | NodeFS.copyFileSync( | 86 | NodeFS.copyFileSync( |
| 86 | NodePath.posix.join( | 87 | NodePath.posix.join( |
| 87 | Electron.app.getAppPath(), | 88 | activeForkRuntime()?.directory ?? Electron.app.getAppPath(), |
| 88 | "apps/desktop/prod-resources/icon.png", | 89 | "apps/desktop/prod-resources/icon.png", |
| 89 | ), | 90 | ), |
| 90 | iconPath, | 91 | iconPath, |
| ... | @@ -98,7 +99,7 @@ export const make = Effect.gen(function* () { | ... | @@ -98,7 +99,7 @@ export const make = Effect.gen(function* () { |
| 98 | renderUrlHandlerDesktopEntry({ | 99 | renderUrlHandlerDesktopEntry({ |
| 99 | displayName: resolveDesktopAppBranding({ | 100 | displayName: resolveDesktopAppBranding({ |
| 100 | isDevelopment: linux.isDevelopment, | 101 | isDevelopment: linux.isDevelopment, |
| 101 | appVersion: Electron.app.getVersion(), | 102 | appVersion: activeForkRuntime()?.version ?? Electron.app.getVersion(), |
| 102 | }).displayName, | 103 | }).displayName, |
| 103 | execTarget: process.env.APPIMAGE?.trim() || process.execPath, | 104 | execTarget: process.env.APPIMAGE?.trim() || process.execPath, |
| 104 | scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), | 105 | scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), |
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+2| ... | @@ -246,6 +246,7 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -246,6 +246,7 @@ describe("DesktopBackendConfiguration", () => { |
| 246 | assert.equal(first.cwd, environment.backendCwd); | 246 | assert.equal(first.cwd, environment.backendCwd); |
| 247 | assert.equal(first.captureOutput, true); | 247 | assert.equal(first.captureOutput, true); |
| 248 | assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1"); | 248 | assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1"); |
| 249 | assert.equal(first.env.T3CODE_VERSION_OVERRIDE, environment.appVersion); | ||
| 249 | assert.isUndefined(first.env.T3CODE_PORT); | 250 | assert.isUndefined(first.env.T3CODE_PORT); |
| 250 | assert.isUndefined(first.env.T3CODE_MODE); | 251 | assert.isUndefined(first.env.T3CODE_MODE); |
| 251 | assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST); | 252 | assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST); |
| ... | @@ -964,6 +965,7 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -964,6 +965,7 @@ describe("DesktopBackendConfiguration", () => { |
| 964 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | 965 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| 965 | const config = yield* configuration.resolvePrimary; | 966 | const config = yield* configuration.resolvePrimary; |
| 966 | assert.equal(config.captureOutput, true); | 967 | assert.equal(config.captureOutput, true); |
| 968 | assert.isUndefined(config.env.T3CODE_VERSION_OVERRIDE); | ||
| 967 | // Dev never shares the prod compile cache. | 969 | // Dev never shares the prod compile cache. |
| 968 | assert.notInclude(config.args, "--require"); | 970 | assert.notInclude(config.args, "--require"); |
| 969 | }).pipe( | 971 | }).pipe( |
apps/desktop/src/backend/DesktopBackendConfiguration.ts+1| ... | @@ -603,6 +603,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv | ... | @@ -603,6 +603,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv |
| 603 | env: { | 603 | env: { |
| 604 | ...backendChildEnvPatch(), | 604 | ...backendChildEnvPatch(), |
| 605 | ELECTRON_RUN_AS_NODE: "1", | 605 | ELECTRON_RUN_AS_NODE: "1", |
| 606 | T3CODE_VERSION_OVERRIDE: environment.isPackaged ? environment.appVersion : undefined, | ||
| 606 | // The server names this launcher in commands it asks a person to run. | 607 | // The server names this launcher in commands it asks a person to run. |
| 607 | T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), | 608 | T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), |
| 608 | }, | 609 | }, |
apps/desktop/src/boot.ts+34-4| ... | @@ -1,4 +1,34 @@ | ... | @@ -1,4 +1,34 @@ |
| 1 | // Packaged app entry. Enables the compile cache before the main bundle loads, | 1 | // @effect-diagnostics nodeBuiltinImport:off - The synchronous signed bootstrap runs before Effect services exist. |
| 2 | // so the cache also covers main.cjs itself. | 2 | import * as NodeOS from "node:os"; |
| 3 | require("./compileCache.cjs"); | 3 | import * as NodePath from "node:path"; |
| 4 | require("./main.cjs"); | 4 | import * as Option from "effect/Option"; |
| 5 | |||
| 6 | import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./app/DesktopStatePaths.ts"; | ||
| 7 | import { readActiveForkRuntime } from "./updates/forkRuntime.ts"; | ||
| 8 | |||
| 9 | delete process.env.T3CODE_DESKTOP_PAYLOAD; | ||
| 10 | let entry = __dirname; | ||
| 11 | const app = process.versions.electron | ||
| 12 | ? (require("electron") as typeof import("electron")).app | ||
| 13 | : undefined; | ||
| 14 | if (app?.isPackaged) { | ||
| 15 | const t3Home = Option.fromNullishOr(process.env.T3CODE_HOME); | ||
| 16 | const baseDir = resolveDesktopBaseDir({ | ||
| 17 | homeDirectory: NodeOS.homedir(), | ||
| 18 | joinPath: NodePath.join, | ||
| 19 | t3Home, | ||
| 20 | }); | ||
| 21 | const stateDir = resolveDesktopStateDir({ | ||
| 22 | baseDir, | ||
| 23 | isDevelopment: false, | ||
| 24 | joinPath: NodePath.join, | ||
| 25 | t3Home, | ||
| 26 | }); | ||
| 27 | const active = readActiveForkRuntime(stateDir, NodePath.resolve(__dirname, "../../..")); | ||
| 28 | if (active !== undefined) { | ||
| 29 | process.env.T3CODE_DESKTOP_PAYLOAD = JSON.stringify(active); | ||
| 30 | entry = NodePath.join(active.directory, "apps/desktop/dist-electron"); | ||
| 31 | } | ||
| 32 | } | ||
| 33 | require(NodePath.join(entry, "compileCache.cjs")); | ||
| 34 | require(NodePath.join(entry, "main.cjs")); |
apps/desktop/src/electron/ElectronApp.test.ts+20| ... | @@ -84,6 +84,7 @@ import * as ElectronApp from "./ElectronApp.ts"; | ... | @@ -84,6 +84,7 @@ import * as ElectronApp from "./ElectronApp.ts"; |
| 84 | 84 | ||
| 85 | describe("ElectronApp", () => { | 85 | describe("ElectronApp", () => { |
| 86 | beforeEach(() => { | 86 | beforeEach(() => { |
| 87 | vi.unstubAllEnvs(); | ||
| 87 | appendSwitchMock.mockClear(); | 88 | appendSwitchMock.mockClear(); |
| 88 | autoUpdaterOnMock.mockClear(); | 89 | autoUpdaterOnMock.mockClear(); |
| 89 | autoUpdaterRemoveListenerMock.mockClear(); | 90 | autoUpdaterRemoveListenerMock.mockClear(); |
| ... | @@ -111,6 +112,25 @@ describe("ElectronApp", () => { | ... | @@ -111,6 +112,25 @@ describe("ElectronApp", () => { |
| 111 | }).pipe(Effect.provide(ElectronApp.layer)), | 112 | }).pipe(Effect.provide(ElectronApp.layer)), |
| 112 | ); | 113 | ); |
| 113 | 114 | ||
| 115 | it.effect("reports the selected payload version and path while retaining signed resources", () => | ||
| 116 | Effect.gen(function* () { | ||
| 117 | vi.stubEnv( | ||
| 118 | "T3CODE_DESKTOP_PAYLOAD", | ||
| 119 | JSON.stringify({ | ||
| 120 | version: "0.0.2000000000", | ||
| 121 | directory: "/staged/release", | ||
| 122 | shell: "a".repeat(64), | ||
| 123 | runtime: "b".repeat(64), | ||
| 124 | }), | ||
| 125 | ); | ||
| 126 | const metadata = yield* (yield* ElectronApp.ElectronApp).metadata; | ||
| 127 | assert.equal(metadata.appVersion, "0.0.2000000000"); | ||
| 128 | assert.equal(metadata.appPath, "/staged/release"); | ||
| 129 | assert.equal(metadata.resourcesPath, process.resourcesPath); | ||
| 130 | vi.unstubAllEnvs(); | ||
| 131 | }).pipe(Effect.provide(ElectronApp.layer)), | ||
| 132 | ); | ||
| 133 | |||
| 114 | it.effect("reads the OS locale through the service", () => | 134 | it.effect("reads the OS locale through the service", () => |
| 115 | Effect.gen(function* () { | 135 | Effect.gen(function* () { |
| 116 | const electronApp = yield* ElectronApp.ElectronApp; | 136 | const electronApp = yield* ElectronApp.ElectronApp; |
apps/desktop/src/electron/ElectronApp.ts+3-2| ... | @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; |
| 5 | import * as Scope from "effect/Scope"; | 5 | import * as Scope from "effect/Scope"; |
| 6 | 6 | ||
| 7 | import * as Electron from "electron"; | 7 | import * as Electron from "electron"; |
| 8 | import { activeForkRuntime } from "../updates/forkRuntime.ts"; | ||
| 8 | 9 | ||
| 9 | export interface ElectronAppMetadata { | 10 | export interface ElectronAppMetadata { |
| 10 | readonly appVersion: string; | 11 | readonly appVersion: string; |
| ... | @@ -101,7 +102,7 @@ const addScopedAppListener = <Args extends ReadonlyArray<unknown>>( | ... | @@ -101,7 +102,7 @@ const addScopedAppListener = <Args extends ReadonlyArray<unknown>>( |
| 101 | export const make = ElectronApp.of({ | 102 | export const make = ElectronApp.of({ |
| 102 | metadata: Effect.gen(function* () { | 103 | metadata: Effect.gen(function* () { |
| 103 | const appVersion = yield* Effect.try({ | 104 | const appVersion = yield* Effect.try({ |
| 104 | try: () => Electron.app.getVersion(), | 105 | try: () => activeForkRuntime()?.version ?? Electron.app.getVersion(), |
| 105 | catch: (cause) => | 106 | catch: (cause) => |
| 106 | new ElectronAppMetadataReadError({ | 107 | new ElectronAppMetadataReadError({ |
| 107 | property: "app-version", | 108 | property: "app-version", |
| ... | @@ -109,7 +110,7 @@ export const make = ElectronApp.of({ | ... | @@ -109,7 +110,7 @@ export const make = ElectronApp.of({ |
| 109 | }), | 110 | }), |
| 110 | }); | 111 | }); |
| 111 | const appPath = yield* Effect.try({ | 112 | const appPath = yield* Effect.try({ |
| 112 | try: () => Electron.app.getAppPath(), | 113 | try: () => activeForkRuntime()?.directory ?? Electron.app.getAppPath(), |
| 113 | catch: (cause) => | 114 | catch: (cause) => |
| 114 | new ElectronAppMetadataReadError({ | 115 | new ElectronAppMetadataReadError({ |
| 115 | property: "app-path", | 116 | property: "app-path", |
apps/desktop/src/electron/ElectronProtocol.test.ts+1-1| ... | @@ -43,7 +43,7 @@ describe("ElectronProtocol", () => { | ... | @@ -43,7 +43,7 @@ describe("ElectronProtocol", () => { |
| 43 | const protocol = yield* ElectronProtocol.ElectronProtocol; | 43 | const protocol = yield* ElectronProtocol.ElectronProtocol; |
| 44 | yield* protocol.registerDesktopProtocol({ | 44 | yield* protocol.registerDesktopProtocol({ |
| 45 | scheme: "t3code", | 45 | scheme: "t3code", |
| 46 | assetDirectory: directory, | 46 | assetDirectories: Effect.succeed([directory]), |
| 47 | clerkFrontendApiHostname: undefined, | 47 | clerkFrontendApiHostname: undefined, |
| 48 | }); | 48 | }); |
| 49 | const request = (pathname: string, init?: RequestInit) => | 49 | const request = (pathname: string, init?: RequestInit) => |
apps/desktop/src/electron/ElectronProtocol.ts+23-10| ... | @@ -53,11 +53,14 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError<Elec | ... | @@ -53,11 +53,14 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError<Elec |
| 53 | } | 53 | } |
| 54 | 54 | ||
| 55 | // The scheme either proxies to a dev server (`targetOrigin`) or serves the | 55 | // The scheme either proxies to a dev server (`targetOrigin`) or serves the |
| 56 | // built client from disk (`assetDirectory`). | 56 | // built client from disk (`assetDirectories`). |
| 57 | export type DesktopProtocolRegistrationInput = { | 57 | export type DesktopProtocolRegistrationInput = { |
| 58 | readonly scheme: string; | 58 | readonly scheme: string; |
| 59 | readonly clerkFrontendApiHostname: string | undefined; | 59 | readonly clerkFrontendApiHostname: string | undefined; |
| 60 | } & ({ readonly targetOrigin: URL } | { readonly assetDirectory: string }); | 60 | } & ( |
| 61 | | { readonly targetOrigin: URL } | ||
| 62 | | { readonly assetDirectories: Effect.Effect<readonly [string, ...string[]]> } | ||
| 63 | ); | ||
| 61 | 64 | ||
| 62 | export class ElectronProtocol extends Context.Service< | 65 | export class ElectronProtocol extends Context.Service< |
| 63 | ElectronProtocol, | 66 | ElectronProtocol, |
| ... | @@ -213,12 +216,9 @@ const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* ( | ... | @@ -213,12 +216,9 @@ const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* ( |
| 213 | return withContentSecurityPolicy(response, contentSecurityPolicy); | 216 | return withContentSecurityPolicy(response, contentSecurityPolicy); |
| 214 | }); | 217 | }); |
| 215 | 218 | ||
| 216 | // Serves the packaged web client without a backend: files resolve within the | ||
| 217 | // asset directory, and any other path falls back to index.html so the SPA | ||
| 218 | // router handles it, except for asset-shaped misses (`/missing.js`) which 404. | ||
| 219 | const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( | 219 | const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( |
| 220 | request: Request, | 220 | request: Request, |
| 221 | assetDirectory: string, | 221 | assetDirectories: readonly [string, ...string[]], |
| 222 | ) { | 222 | ) { |
| 223 | const fileSystem = yield* FileSystem.FileSystem; | 223 | const fileSystem = yield* FileSystem.FileSystem; |
| 224 | const path = yield* Path.Path; | 224 | const path = yield* Path.Path; |
| ... | @@ -231,13 +231,21 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( | ... | @@ -231,13 +231,21 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( |
| 231 | Effect.orElseSucceed(() => null), | 231 | Effect.orElseSucceed(() => null), |
| 232 | ); | 232 | ); |
| 233 | if (pathname === null || pathname.includes("\0")) return new Response(null, { status: 400 }); | 233 | if (pathname === null || pathname.includes("\0")) return new Response(null, { status: 400 }); |
| 234 | const root = path.resolve(assetDirectory); | 234 | const root = path.resolve(assetDirectories[0]); |
| 235 | const assetPath = path.resolve(root, `.${pathname}`); | 235 | const assetPath = path.resolve(root, `.${pathname}`); |
| 236 | if (assetPath !== root && !assetPath.startsWith(root + path.sep)) { | 236 | if (assetPath !== root && !assetPath.startsWith(root + path.sep)) { |
| 237 | return new Response(null, { status: 404 }); | 237 | return new Response(null, { status: 404 }); |
| 238 | } | 238 | } |
| 239 | const stat = yield* fileSystem.stat(assetPath).pipe(Effect.orElseSucceed(() => null)); | ||
| 240 | let filePath = assetPath; | 239 | let filePath = assetPath; |
| 240 | let stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); | ||
| 241 | // Old pages can still request lazy chunks while the renderer reloads. | ||
| 242 | if (path.extname(assetPath) !== "" && path.basename(assetPath) !== "index.html") { | ||
| 243 | for (const previous of assetDirectories.slice(1)) { | ||
| 244 | if (stat?.type === "File") break; | ||
| 245 | filePath = path.resolve(previous, `.${pathname}`); | ||
| 246 | stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); | ||
| 247 | } | ||
| 248 | } | ||
| 241 | if (stat?.type !== "File") { | 249 | if (stat?.type !== "File") { |
| 242 | const wantsHtml = request.headers.get("accept")?.includes("text/html") ?? false; | 250 | const wantsHtml = request.headers.get("accept")?.includes("text/html") ?? false; |
| 243 | if (path.extname(assetPath) !== "" && !wantsHtml) { | 251 | if (path.extname(assetPath) !== "" && !wantsHtml) { |
| ... | @@ -250,6 +258,7 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( | ... | @@ -250,6 +258,7 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( |
| 250 | return new Response(request.method === "HEAD" ? null : new Uint8Array(contents), { | 258 | return new Response(request.method === "HEAD" ? null : new Uint8Array(contents), { |
| 251 | headers: { | 259 | headers: { |
| 252 | "content-type": Option.getOrElse(Mime.getType(filePath), () => "application/octet-stream"), | 260 | "content-type": Option.getOrElse(Mime.getType(filePath), () => "application/octet-stream"), |
| 261 | ...(path.basename(filePath) === "index.html" ? { "cache-control": "no-store" } : {}), | ||
| 253 | }, | 262 | }, |
| 254 | }); | 263 | }); |
| 255 | }); | 264 | }); |
| ... | @@ -270,9 +279,13 @@ export const make = Effect.gen(function* () { | ... | @@ -270,9 +279,13 @@ export const make = Effect.gen(function* () { |
| 270 | Effect.try({ | 279 | Effect.try({ |
| 271 | try: () => { | 280 | try: () => { |
| 272 | Electron.protocol.handle(input.scheme, async (request) => { | 281 | Electron.protocol.handle(input.scheme, async (request) => { |
| 273 | if ("assetDirectory" in input) { | 282 | if ("assetDirectories" in input) { |
| 274 | return withContentSecurityPolicy( | 283 | return withContentSecurityPolicy( |
| 275 | await runPromise(serveDesktopAsset(request, input.assetDirectory)), | 284 | await runPromise( |
| 285 | Effect.flatMap(input.assetDirectories, (directories) => | ||
| 286 | serveDesktopAsset(request, directories), | ||
| 287 | ), | ||
| 288 | ), | ||
| 276 | contentSecurityPolicy, | 289 | contentSecurityPolicy, |
| 277 | ); | 290 | ); |
| 278 | } | 291 | } |
apps/desktop/src/electron/ElectronUpdater.ts+5-2| ... | @@ -68,7 +68,10 @@ export class ElectronUpdater extends Context.Service< | ... | @@ -68,7 +68,10 @@ export class ElectronUpdater extends Context.Service< |
| 68 | readonly setFullChangelog: (value: boolean) => Effect.Effect<void>; | 68 | readonly setFullChangelog: (value: boolean) => Effect.Effect<void>; |
| 69 | readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect<void>; | 69 | readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect<void>; |
| 70 | readonly checkForUpdates: Effect.Effect<void, ElectronUpdaterCheckForUpdatesError>; | 70 | readonly checkForUpdates: Effect.Effect<void, ElectronUpdaterCheckForUpdatesError>; |
| 71 | readonly downloadUpdate: Effect.Effect<void, ElectronUpdaterDownloadUpdateError>; | 71 | readonly downloadUpdate: Effect.Effect< |
| 72 | ReadonlyArray<string>, | ||
| 73 | ElectronUpdaterDownloadUpdateError | ||
| 74 | >; | ||
| 72 | readonly quitAndInstall: (options: { | 75 | readonly quitAndInstall: (options: { |
| 73 | readonly isSilent: boolean; | 76 | readonly isSilent: boolean; |
| 74 | readonly isForceRunAfter: boolean; | 77 | readonly isForceRunAfter: boolean; |
| ... | @@ -135,7 +138,7 @@ export const make = ElectronUpdater.of({ | ... | @@ -135,7 +138,7 @@ export const make = ElectronUpdater.of({ |
| 135 | return Effect.tryPromise({ | 138 | return Effect.tryPromise({ |
| 136 | try: () => autoUpdater.downloadUpdate(), | 139 | try: () => autoUpdater.downloadUpdate(), |
| 137 | catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), | 140 | catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), |
| 138 | }).pipe(Effect.asVoid); | 141 | }); |
| 139 | }), | 142 | }), |
| 140 | quitAndInstall: ({ isSilent, isForceRunAfter }) => | 143 | quitAndInstall: ({ isSilent, isForceRunAfter }) => |
| 141 | Effect.suspend(() => { | 144 | Effect.suspend(() => { |
apps/desktop/src/main.ts+9-1| ... | @@ -68,6 +68,9 @@ import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts"; | ... | @@ -68,6 +68,9 @@ import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts"; |
| 68 | import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; | 68 | import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; |
| 69 | import * as DesktopRendererHistory from "./telemetry/DesktopRendererHistory.ts"; | 69 | import * as DesktopRendererHistory from "./telemetry/DesktopRendererHistory.ts"; |
| 70 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; | 70 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 71 | import * as DesktopLocalUi from "./updates/DesktopLocalUi.ts"; | ||
| 72 | import * as DesktopForkUpdates from "./updates/DesktopForkUpdates.ts"; | ||
| 73 | import { activeForkRuntime } from "./updates/forkRuntime.ts"; | ||
| 71 | import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; | 74 | import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; |
| 72 | import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; | 75 | import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; |
| 73 | import * as BrowserSession from "./preview/BrowserSession.ts"; | 76 | import * as BrowserSession from "./preview/BrowserSession.ts"; |
| ... | @@ -81,7 +84,10 @@ import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts"; | ... | @@ -81,7 +84,10 @@ import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts"; |
| 81 | 84 | ||
| 82 | if (process.argv.includes("--version")) { | 85 | if (process.argv.includes("--version")) { |
| 83 | try { | 86 | try { |
| 84 | NodeFS.writeSync(process.stdout.fd, `${Electron.app.getVersion()}\n`); | 87 | NodeFS.writeSync( |
| 88 | process.stdout.fd, | ||
| 89 | `${activeForkRuntime()?.version ?? Electron.app.getVersion()}\n`, | ||
| 90 | ); | ||
| 85 | } catch (error) { | 91 | } catch (error) { |
| 86 | if (!(error instanceof Error) || !("code" in error) || error.code !== "EPIPE") throw error; | 92 | if (!(error instanceof Error) || !("code" in error) || error.code !== "EPIPE") throw error; |
| 87 | } | 93 | } |
| ... | @@ -228,6 +234,8 @@ const layerDesktopApplication = Layer.mergeAll( | ... | @@ -228,6 +234,8 @@ const layerDesktopApplication = Layer.mergeAll( |
| 228 | ).pipe( | 234 | ).pipe( |
| 229 | Layer.provideMerge(layerDesktopSnapShot), | 235 | Layer.provideMerge(layerDesktopSnapShot), |
| 230 | Layer.provideMerge(DesktopUpdates.layer), | 236 | Layer.provideMerge(DesktopUpdates.layer), |
| 237 | Layer.provideMerge(DesktopForkUpdates.layer), | ||
| 238 | Layer.provideMerge(DesktopLocalUi.layer), | ||
| 231 | Layer.provideMerge(layerDesktopWslBackend), | 239 | Layer.provideMerge(layerDesktopWslBackend), |
| 232 | Layer.provideMerge(layerDesktopLocalEnvironmentAuth), | 240 | Layer.provideMerge(layerDesktopLocalEnvironmentAuth), |
| 233 | ); | 241 | ); |
apps/desktop/src/updates/DesktopForkUpdates.test.ts created+320| ... | @@ -0,0 +1,320 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Fixtures create real archives to exercise extraction and atomic activation. | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | import * as NodeFSP from "node:fs/promises"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | import * as NodeChildProcess from "node:child_process"; | ||
| 6 | import * as NodeHttp from "node:http"; | ||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 8 | import { assert, it } from "@effect/vitest"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as FileSystem from "effect/FileSystem"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import { FetchHttpClient } from "effect/http"; | ||
| 13 | import { ForkReleaseManifest } from "@t3tools/shared/forkUpdateDirectory"; | ||
| 14 | |||
| 15 | import * as DesktopConfig from "../app/DesktopConfig.ts"; | ||
| 16 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ||
| 17 | import * as DesktopLocalUi from "./DesktopLocalUi.ts"; | ||
| 18 | import * as DesktopForkUpdates from "./DesktopForkUpdates.ts"; | ||
| 19 | import { readActiveForkRuntime } from "./forkRuntime.ts"; | ||
| 20 | |||
| 21 | const shell = "a".repeat(64); | ||
| 22 | const runtime = "b".repeat(64); | ||
| 23 | const nextRuntime = "c".repeat(64); | ||
| 24 | const version = "0.0.2000000000"; | ||
| 25 | |||
| 26 | const withUpdater = <A, E>( | ||
| 27 | test: (input: { | ||
| 28 | updater: DesktopForkUpdates.DesktopForkUpdates["Service"]; | ||
| 29 | release: typeof ForkReleaseManifest.Type; | ||
| 30 | nextUi: (typeof ForkReleaseManifest.Type)["ui"]; | ||
| 31 | base: URL; | ||
| 32 | requested: string[]; | ||
| 33 | applied: string[]; | ||
| 34 | files: Map<string, Uint8Array>; | ||
| 35 | environment: DesktopEnvironment.DesktopEnvironment["Service"]; | ||
| 36 | }) => Effect.Effect<A, E>, | ||
| 37 | platform: NodeJS.Platform = "darwin", | ||
| 38 | ) => | ||
| 39 | Effect.gen(function* () { | ||
| 40 | const fs = yield* FileSystem.FileSystem; | ||
| 41 | const home = yield* fs.makeTempDirectoryScoped(); | ||
| 42 | const resources = NodePath.join(home, "resources"); | ||
| 43 | const bundled = NodePath.join(resources, "app.asar"); | ||
| 44 | yield* fs.makeDirectory(bundled, { recursive: true }); | ||
| 45 | yield* fs.writeFileString( | ||
| 46 | NodePath.join(bundled, "knife-runtime.json"), | ||
| 47 | JSON.stringify({ version: "0.0.1", sha256: runtime, shell }), | ||
| 48 | ); | ||
| 49 | const files = new Map<string, Uint8Array>(); | ||
| 50 | const archive = (kind: string, contents: Record<string, string>) => | ||
| 51 | Effect.promise(async () => { | ||
| 52 | const directory = NodePath.join(home, kind); | ||
| 53 | await NodeFSP.mkdir(directory); | ||
| 54 | for (const [name, content] of Object.entries(contents)) { | ||
| 55 | const file = NodePath.join(directory, name); | ||
| 56 | await NodeFSP.mkdir(NodePath.dirname(file), { recursive: true }); | ||
| 57 | await NodeFSP.writeFile(file, content); | ||
| 58 | } | ||
| 59 | const file = `${kind}.tar.gz`; | ||
| 60 | NodeChildProcess.execFileSync("tar", [ | ||
| 61 | "-czf", | ||
| 62 | NodePath.join(home, file), | ||
| 63 | "-C", | ||
| 64 | directory, | ||
| 65 | ".", | ||
| 66 | ]); | ||
| 67 | const bytes = await NodeFSP.readFile(NodePath.join(home, file)); | ||
| 68 | files.set(file, bytes); | ||
| 69 | return { | ||
| 70 | file, | ||
| 71 | size: bytes.length, | ||
| 72 | sha256: NodeCrypto.createHash("sha256").update(bytes).digest("hex"), | ||
| 73 | }; | ||
| 74 | }); | ||
| 75 | const ui = yield* archive("ui", { | ||
| 76 | "index.html": '<script src="/app.js"></script>', | ||
| 77 | "app.js": "new UI", | ||
| 78 | ".vite/manifest.json": '{"app":{"file":"app.js"}}', | ||
| 79 | }); | ||
| 80 | const assets = yield* archive("assets", { "images/logo.svg": "static asset" }); | ||
| 81 | const nextUi = yield* archive("ui-next", { | ||
| 82 | "index.html": '<script src="/app-next.js"></script>', | ||
| 83 | "app-next.js": "next UI", | ||
| 84 | ".vite/manifest.json": '{"app":{"file":"app-next.js"}}', | ||
| 85 | }); | ||
| 86 | const code = yield* archive("code", { | ||
| 87 | "apps/desktop/dist-electron/main.cjs": "new main", | ||
| 88 | "apps/desktop/dist-electron/preload.cjs": "new preload", | ||
| 89 | "apps/desktop/dist-electron/compileCache.cjs": "cache", | ||
| 90 | [`${platform === "win32" ? "server/" : ""}apps/server/dist/bin.mjs`]: | ||
| 91 | 'console.log("fixture");', | ||
| 92 | }); | ||
| 93 | const platformKey = `${platform}-${platform === "darwin" ? "arm64" : "x64"}`; | ||
| 94 | const release = { | ||
| 95 | version, | ||
| 96 | ui, | ||
| 97 | assets, | ||
| 98 | server: { ...code, version, runtime: nextRuntime }, | ||
| 99 | desktop: { | ||
| 100 | [platformKey]: { | ||
| 101 | version, | ||
| 102 | ui, | ||
| 103 | assets, | ||
| 104 | shell, | ||
| 105 | runtime: nextRuntime, | ||
| 106 | code, | ||
| 107 | app: { ...code, version }, | ||
| 108 | }, | ||
| 109 | }, | ||
| 110 | }; | ||
| 111 | files.set( | ||
| 112 | "?ls", | ||
| 113 | new TextEncoder().encode(JSON.stringify({ files: [{ href: `${version}.json` }] })), | ||
| 114 | ); | ||
| 115 | const requested: string[] = []; | ||
| 116 | const applied: string[] = []; | ||
| 117 | const server = NodeHttp.createServer((request, response) => { | ||
| 118 | const url = new URL(request.url ?? "/", "http://127.0.0.1"); | ||
| 119 | const file = url.searchParams.has("ls") ? "?ls" : url.pathname.split("/").at(-1)!; | ||
| 120 | requested.push(file); | ||
| 121 | const body = file === `${version}.json` ? JSON.stringify(release) : files.get(file); | ||
| 122 | response.writeHead(body === undefined ? 404 : 200).end(body); | ||
| 123 | }); | ||
| 124 | yield* Effect.acquireRelease( | ||
| 125 | Effect.promise(() => new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))), | ||
| 126 | () => | ||
| 127 | Effect.promise( | ||
| 128 | () => | ||
| 129 | new Promise<void>((resolve, reject) => | ||
| 130 | server.close((error) => (error ? reject(error) : resolve())), | ||
| 131 | ), | ||
| 132 | ), | ||
| 133 | ); | ||
| 134 | const address = server.address(); | ||
| 135 | if (address === null || typeof address === "string") | ||
| 136 | return yield* Effect.die("Missing fixture server address"); | ||
| 137 | const base = new URL(`http://127.0.0.1:${address.port}/`); | ||
| 138 | const environmentLayer = DesktopEnvironment.layer({ | ||
| 139 | dirname: NodePath.join(bundled, "apps/desktop/dist-electron"), | ||
| 140 | homeDirectory: home, | ||
| 141 | platform, | ||
| 142 | processArch: platform === "darwin" ? "arm64" : "x64", | ||
| 143 | appVersion: "0.0.1", | ||
| 144 | appPath: bundled, | ||
| 145 | resourcesPath: resources, | ||
| 146 | isPackaged: true, | ||
| 147 | runningUnderArm64Translation: false, | ||
| 148 | }).pipe(Layer.provideMerge(DesktopConfig.layerTest({ T3CODE_HOME: home }))); | ||
| 149 | return yield* Effect.gen(function* () { | ||
| 150 | const updater = yield* DesktopForkUpdates.make; | ||
| 151 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 152 | return yield* test({ | ||
| 153 | updater, | ||
| 154 | release, | ||
| 155 | nextUi, | ||
| 156 | base, | ||
| 157 | requested, | ||
| 158 | applied, | ||
| 159 | files, | ||
| 160 | environment, | ||
| 161 | }); | ||
| 162 | }).pipe( | ||
| 163 | Effect.provide(FetchHttpClient.layer), | ||
| 164 | Effect.provideService(DesktopLocalUi.DesktopLocalUi, { | ||
| 165 | assetDirectories: Effect.succeed(["/client"] as const), | ||
| 166 | currentVersion: Effect.succeed("0.0.1"), | ||
| 167 | reset: Effect.void, | ||
| 168 | apply: (source, version) => | ||
| 169 | Effect.promise(async () => { | ||
| 170 | assert.equal( | ||
| 171 | await NodeFSP.readFile(NodePath.join(source, "images/logo.svg"), "utf8"), | ||
| 172 | "static asset", | ||
| 173 | ); | ||
| 174 | applied.push(`${source}:${version}`); | ||
| 175 | }), | ||
| 176 | }), | ||
| 177 | Effect.provide(environmentLayer), | ||
| 178 | ); | ||
| 179 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); | ||
| 180 | |||
| 181 | it.effect("downloads verified code and UI before atomically activating an exact shell match", () => | ||
| 182 | withUpdater(({ updater, base, release, requested, environment }) => | ||
| 183 | Effect.gen(function* () { | ||
| 184 | assert.deepEqual(yield* updater.check(base), release); | ||
| 185 | assert.equal(yield* updater.prepare(base, release), "code"); | ||
| 186 | assert.deepEqual(requested, [ | ||
| 187 | "?ls", | ||
| 188 | `${version}.json`, | ||
| 189 | "code.tar.gz", | ||
| 190 | "assets.tar.gz", | ||
| 191 | "ui.tar.gz", | ||
| 192 | ]); | ||
| 193 | assert.equal(readActiveForkRuntime(environment.stateDir, environment.appRoot), undefined); | ||
| 194 | assert.equal(yield* updater.activateCode("0.0.1999999999"), false); | ||
| 195 | assert.equal(yield* updater.activateCode(version), true); | ||
| 196 | const active = readActiveForkRuntime(environment.stateDir, environment.appRoot); | ||
| 197 | assert.equal(active?.version, version); | ||
| 198 | assert.equal(active?.runtime, nextRuntime); | ||
| 199 | assert.equal(active?.shell, shell); | ||
| 200 | }), | ||
| 201 | ), | ||
| 202 | ); | ||
| 203 | |||
| 204 | it.effect("downloads only new UI on a second release with unchanged code and static assets", () => | ||
| 205 | withUpdater(({ updater, base, release, nextUi, requested, applied }) => | ||
| 206 | Effect.gen(function* () { | ||
| 207 | const target = release.desktop["darwin-arm64"]!; | ||
| 208 | const matching = { ...release, desktop: { "darwin-arm64": { ...target, runtime } } }; | ||
| 209 | assert.equal(yield* updater.prepare(base, matching), "ui"); | ||
| 210 | assert.equal(yield* updater.installUi(version), true); | ||
| 211 | assert.equal(applied.length, 1); | ||
| 212 | const next = { | ||
| 213 | ...matching, | ||
| 214 | version: "0.0.2000000001", | ||
| 215 | ui: nextUi, | ||
| 216 | desktop: { | ||
| 217 | "darwin-arm64": { | ||
| 218 | ...matching.desktop["darwin-arm64"]!, | ||
| 219 | version: "0.0.2000000001", | ||
| 220 | ui: nextUi, | ||
| 221 | }, | ||
| 222 | }, | ||
| 223 | }; | ||
| 224 | assert.equal(yield* updater.prepare(base, next), "ui"); | ||
| 225 | assert.equal(yield* updater.activateCode(next.version), false); | ||
| 226 | assert.deepEqual(requested, ["assets.tar.gz", "ui.tar.gz", "ui-next.tar.gz"]); | ||
| 227 | }), | ||
| 228 | ), | ||
| 229 | ); | ||
| 230 | |||
| 231 | it.effect("leaves the active pointer unchanged after corrupt or incomplete downloads", () => | ||
| 232 | withUpdater(({ updater, base, release, files, environment }) => | ||
| 233 | Effect.gen(function* () { | ||
| 234 | files.set("code.tar.gz", new TextEncoder().encode("corrupt")); | ||
| 235 | const error = yield* updater.prepare(base, release).pipe(Effect.flip); | ||
| 236 | assert.equal(error._tag, "DesktopForkUpdateError"); | ||
| 237 | assert.equal(yield* updater.activateCode(version), false); | ||
| 238 | assert.equal(readActiveForkRuntime(environment.stateDir, environment.appRoot), undefined); | ||
| 239 | }), | ||
| 240 | ), | ||
| 241 | ); | ||
| 242 | |||
| 243 | it.effect("requires a signed shell update without downloading code when the shell differs", () => | ||
| 244 | withUpdater(({ updater, base, release, requested }) => | ||
| 245 | Effect.gen(function* () { | ||
| 246 | const mismatch = { | ||
| 247 | ...release, | ||
| 248 | desktop: { "darwin-arm64": { ...release.desktop["darwin-arm64"]!, shell: "d".repeat(64) } }, | ||
| 249 | }; | ||
| 250 | assert.equal(yield* updater.prepare(base, mismatch), "shell"); | ||
| 251 | assert.deepEqual(requested, []); | ||
| 252 | }), | ||
| 253 | ), | ||
| 254 | ); | ||
| 255 | |||
| 256 | it.effect("reports an empty public release directory without fetching an unfinished manifest", () => | ||
| 257 | withUpdater(({ updater, base, files, requested }) => | ||
| 258 | Effect.gen(function* () { | ||
| 259 | files.set( | ||
| 260 | "?ls", | ||
| 261 | new TextEncoder().encode(JSON.stringify({ files: [{ href: "unfinished.json" }] })), | ||
| 262 | ); | ||
| 263 | const error = yield* updater.check(base).pipe(Effect.flip); | ||
| 264 | assert.equal(error.message, "No updates have been published. Try again later."); | ||
| 265 | assert.deepEqual(requested, ["?ls"]); | ||
| 266 | }), | ||
| 267 | ), | ||
| 268 | ); | ||
| 269 | |||
| 270 | it.effect( | ||
| 271 | "preserves the platform's compatible renderer and version when the aggregate is newer", | ||
| 272 | () => | ||
| 273 | withUpdater(({ updater, base, release, nextUi, requested }) => | ||
| 274 | Effect.gen(function* () { | ||
| 275 | const aggregate = { ...release, version: "0.0.2000000001", ui: nextUi }; | ||
| 276 | assert.equal(yield* updater.prepare(base, aggregate), "code"); | ||
| 277 | assert.equal(yield* updater.activateCode(aggregate.version), false); | ||
| 278 | assert.equal(yield* updater.activateCode(version), true); | ||
| 279 | assert.deepEqual(requested, ["code.tar.gz", "assets.tar.gz", "ui.tar.gz"]); | ||
| 280 | }), | ||
| 281 | ), | ||
| 282 | ); | ||
| 283 | |||
| 284 | it.effect.each(["linux", "win32"] as const)( | ||
| 285 | "activates a verified %s code tree and preserves its shell", | ||
| 286 | (platform) => | ||
| 287 | withUpdater( | ||
| 288 | ({ updater, release, base, environment, requested }) => | ||
| 289 | Effect.gen(function* () { | ||
| 290 | assert.equal(yield* updater.prepare(base, release), "code"); | ||
| 291 | assert.equal(yield* updater.activateCode(version), true); | ||
| 292 | assert.equal( | ||
| 293 | readActiveForkRuntime(environment.stateDir, environment.appRoot, platform)?.version, | ||
| 294 | version, | ||
| 295 | ); | ||
| 296 | assert.deepEqual(requested, ["code.tar.gz", "assets.tar.gz", "ui.tar.gz"]); | ||
| 297 | }), | ||
| 298 | platform, | ||
| 299 | ), | ||
| 300 | ); | ||
| 301 | |||
| 302 | it.effect.each(["linux", "win32"] as const)( | ||
| 303 | "downloads only renderer assets for an unchanged %s runtime", | ||
| 304 | (platform) => | ||
| 305 | withUpdater( | ||
| 306 | ({ updater, release, base, requested, applied }) => | ||
| 307 | Effect.gen(function* () { | ||
| 308 | const key = `${platform}-x64`; | ||
| 309 | const matching = { | ||
| 310 | ...release, | ||
| 311 | desktop: { [key]: { ...release.desktop[key]!, runtime } }, | ||
| 312 | }; | ||
| 313 | assert.equal(yield* updater.prepare(base, matching), "ui"); | ||
| 314 | assert.equal(yield* updater.installUi(version), true); | ||
| 315 | assert.equal(applied.length, 1); | ||
| 316 | assert.deepEqual(requested, ["assets.tar.gz", "ui.tar.gz"]); | ||
| 317 | }), | ||
| 318 | platform, | ||
| 319 | ), | ||
| 320 | ); | ||
apps/desktop/src/updates/DesktopForkUpdates.ts created+265| ... | @@ -0,0 +1,265 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Release downloads are hashed before extraction. | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | import { | ||
| 4 | ForkReleaseManifest, | ||
| 5 | ForkRuntimeManifest, | ||
| 6 | ForkReleaseListing, | ||
| 7 | latestForkRelease, | ||
| 8 | } from "@t3tools/shared/forkUpdateDirectory"; | ||
| 9 | import * as Context from "effect/Context"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as FileSystem from "effect/FileSystem"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as Ref from "effect/Ref"; | ||
| 14 | import * as Schema from "effect/Schema"; | ||
| 15 | import { HttpClient } from "effect/http"; | ||
| 16 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ||
| 17 | |||
| 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ||
| 19 | import * as DesktopLocalUi from "./DesktopLocalUi.ts"; | ||
| 20 | import { activeForkRuntime } from "./forkRuntime.ts"; | ||
| 21 | |||
| 22 | export class DesktopForkUpdateError extends Schema.TaggedError<DesktopForkUpdateError>()( | ||
| 23 | "DesktopForkUpdateError", | ||
| 24 | { message: Schema.String, cause: Schema.Defect() }, | ||
| 25 | ) {} | ||
| 26 | |||
| 27 | const isForkUpdateError = Schema.is(DesktopForkUpdateError); | ||
| 28 | const decodeReleaseListing = Schema.decodeUnknownEffect(ForkReleaseListing); | ||
| 29 | const decodeRelease = Schema.decodeUnknownEffect(ForkReleaseManifest); | ||
| 30 | const decodeRuntime = Schema.decodeUnknownEffect(Schema.fromJsonString(ForkRuntimeManifest)); | ||
| 31 | |||
| 32 | type Release = typeof ForkReleaseManifest.Type; | ||
| 33 | type Artifact = Release["ui"]; | ||
| 34 | type Prepared = { | ||
| 35 | version: string; | ||
| 36 | directory: string; | ||
| 37 | shell: string; | ||
| 38 | runtime: string; | ||
| 39 | kind: "ui" | "code"; | ||
| 40 | }; | ||
| 41 | |||
| 42 | export class DesktopForkUpdates extends Context.Service< | ||
| 43 | DesktopForkUpdates, | ||
| 44 | { | ||
| 45 | readonly check: (base: URL) => Effect.Effect<Release, DesktopForkUpdateError>; | ||
| 46 | readonly kind: ( | ||
| 47 | release: Release, | ||
| 48 | ) => Effect.Effect<"ui" | "code" | "shell", DesktopForkUpdateError>; | ||
| 49 | readonly prepare: ( | ||
| 50 | base: URL, | ||
| 51 | release: Release, | ||
| 52 | ) => Effect.Effect<"ui" | "code" | "shell", DesktopForkUpdateError>; | ||
| 53 | readonly installUi: (version: string) => Effect.Effect<boolean, DesktopForkUpdateError>; | ||
| 54 | readonly activateCode: (version: string) => Effect.Effect<boolean, DesktopForkUpdateError>; | ||
| 55 | } | ||
| 56 | >()("@t3tools/desktop/updates/DesktopForkUpdates") {} | ||
| 57 | |||
| 58 | export const make = Effect.gen(function* () { | ||
| 59 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 60 | const fs = yield* FileSystem.FileSystem; | ||
| 61 | const path = environment.path; | ||
| 62 | const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient); | ||
| 63 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 64 | const ui = yield* DesktopLocalUi.DesktopLocalUi; | ||
| 65 | const prepared = yield* Ref.make<Prepared | null>(null); | ||
| 66 | const encodePointer = Schema.encodeEffect( | ||
| 67 | Schema.fromJsonString( | ||
| 68 | Schema.Struct({ | ||
| 69 | version: Schema.String, | ||
| 70 | directory: Schema.String, | ||
| 71 | shell: Schema.String, | ||
| 72 | runtime: Schema.String, | ||
| 73 | }), | ||
| 74 | ), | ||
| 75 | ); | ||
| 76 | const releasesDir = path.join(environment.stateDir, "fork-releases"); | ||
| 77 | const fail = (cause: unknown) => | ||
| 78 | isForkUpdateError(cause) | ||
| 79 | ? cause | ||
| 80 | : new DesktopForkUpdateError({ | ||
| 81 | message: "Could not prepare the update. Try again.", | ||
| 82 | cause, | ||
| 83 | }); | ||
| 84 | |||
| 85 | const check = (base: URL) => | ||
| 86 | Effect.scoped( | ||
| 87 | Effect.gen(function* () { | ||
| 88 | const listingUrl = new URL(base); | ||
| 89 | listingUrl.searchParams.set("ls", ""); | ||
| 90 | const client = HttpClient.withScope(http); | ||
| 91 | const response = yield* client.get(listingUrl.href); | ||
| 92 | const listing = yield* decodeReleaseListing(yield* response.json); | ||
| 93 | const version = latestForkRelease(listing); | ||
| 94 | if (version === undefined) | ||
| 95 | return yield* new DesktopForkUpdateError({ | ||
| 96 | message: "No updates have been published. Try again later.", | ||
| 97 | cause: "Empty release share", | ||
| 98 | }); | ||
| 99 | const manifest = yield* client.get(new URL(`${version}.json`, base).href); | ||
| 100 | const release = yield* decodeRelease(yield* manifest.json); | ||
| 101 | if (release.version !== version) | ||
| 102 | return yield* fail("The published manifest has a different version."); | ||
| 103 | return release; | ||
| 104 | }), | ||
| 105 | ).pipe(Effect.mapError(fail)); | ||
| 106 | |||
| 107 | const extract = (base: URL, artifact: Artifact, directory: string) => | ||
| 108 | Effect.scoped( | ||
| 109 | Effect.gen(function* () { | ||
| 110 | const archive = path.join(releasesDir, artifact.file); | ||
| 111 | const valid = (bytes: Uint8Array) => | ||
| 112 | bytes.byteLength === artifact.size && | ||
| 113 | NodeCrypto.createHash("sha256").update(bytes).digest("hex") === artifact.sha256; | ||
| 114 | if (!(yield* fs.exists(archive)) || !valid(yield* fs.readFile(archive))) { | ||
| 115 | const response = yield* HttpClient.withScope(http).get(new URL(artifact.file, base).href); | ||
| 116 | const bytes = new Uint8Array(yield* response.arrayBuffer); | ||
| 117 | if (!valid(bytes)) | ||
| 118 | return yield* fail("The downloaded update did not match its checksum."); | ||
| 119 | yield* fs.writeFile(`${archive}.tmp`, bytes); | ||
| 120 | yield* fs.rename(`${archive}.tmp`, archive); | ||
| 121 | } | ||
| 122 | yield* fs.makeDirectory(directory, { recursive: true }); | ||
| 123 | const child = yield* spawner.spawn( | ||
| 124 | ChildProcess.make("tar", ["-xzf", archive, "-C", directory]), | ||
| 125 | ); | ||
| 126 | if (Number(yield* child.exitCode) !== 0) | ||
| 127 | return yield* fail("Could not extract the update."); | ||
| 128 | }), | ||
| 129 | ); | ||
| 130 | |||
| 131 | const kind = (release: Release) => | ||
| 132 | Effect.gen(function* () { | ||
| 133 | const target = release.desktop[`${environment.platform}-${environment.processArch}`]; | ||
| 134 | if (target === undefined) return yield* fail("This release is unavailable for this machine."); | ||
| 135 | const baseline = yield* fs | ||
| 136 | .readFileString(path.join(environment.resourcesPath, "app.asar", "knife-runtime.json")) | ||
| 137 | .pipe(Effect.flatMap(decodeRuntime)); | ||
| 138 | if (target.shell !== baseline.shell) return "shell" as const; | ||
| 139 | const runtime = activeForkRuntime()?.runtime ?? baseline.sha256; | ||
| 140 | return target.runtime === runtime ? ("ui" as const) : ("code" as const); | ||
| 141 | }).pipe(Effect.mapError(fail)); | ||
| 142 | |||
| 143 | const prepare = (base: URL, release: Release) => | ||
| 144 | Effect.gen(function* () { | ||
| 145 | const previous = yield* Ref.getAndSet(prepared, null); | ||
| 146 | if (previous !== null) yield* fs.remove(previous.directory, { recursive: true, force: true }); | ||
| 147 | const installKind = yield* kind(release); | ||
| 148 | if (installKind === "shell") return installKind; | ||
| 149 | const target = release.desktop[`${environment.platform}-${environment.processArch}`]!; | ||
| 150 | const serverRoot = environment.platform === "win32" ? "server" : "."; | ||
| 151 | yield* fs.makeDirectory(releasesDir, { recursive: true }); | ||
| 152 | const directory = yield* fs.makeTempDirectory({ directory: releasesDir, prefix: "release-" }); | ||
| 153 | return yield* Effect.gen(function* () { | ||
| 154 | if (installKind === "code") yield* extract(base, target.code, directory); | ||
| 155 | const client = | ||
| 156 | installKind === "ui" | ||
| 157 | ? directory | ||
| 158 | : path.join(directory, serverRoot, "apps/server/dist/client"); | ||
| 159 | yield* extract(base, target.assets, client); | ||
| 160 | yield* extract(base, target.ui, client); | ||
| 161 | if (!(yield* fs.exists(path.join(client, "index.html")))) | ||
| 162 | return yield* fail("The update has no completed UI build."); | ||
| 163 | if (installKind === "code") { | ||
| 164 | for (const file of [ | ||
| 165 | "apps/desktop/dist-electron/main.cjs", | ||
| 166 | "apps/desktop/dist-electron/preload.cjs", | ||
| 167 | "apps/desktop/dist-electron/compileCache.cjs", | ||
| 168 | path.join(serverRoot, "apps/server/dist/bin.mjs"), | ||
| 169 | ]) { | ||
| 170 | if (!(yield* fs.exists(path.join(directory, file)))) | ||
| 171 | return yield* fail("The update is incomplete."); | ||
| 172 | } | ||
| 173 | const probe = yield* spawner.spawn( | ||
| 174 | ChildProcess.make( | ||
| 175 | process.execPath, | ||
| 176 | [path.join(directory, serverRoot, "apps/server/dist/bin.mjs"), "--version"], | ||
| 177 | { | ||
| 178 | env: { ELECTRON_RUN_AS_NODE: "1", T3CODE_VERSION_OVERRIDE: target.version }, | ||
| 179 | extendEnv: true, | ||
| 180 | stdout: "ignore", | ||
| 181 | stderr: "ignore", | ||
| 182 | }, | ||
| 183 | ), | ||
| 184 | ); | ||
| 185 | if (Number(yield* probe.exitCode) !== 0) | ||
| 186 | return yield* fail("The downloaded server could not start."); | ||
| 187 | } | ||
| 188 | yield* fs.readDirectory(releasesDir).pipe( | ||
| 189 | Effect.flatMap((entries) => | ||
| 190 | Effect.forEach(entries, (name) => | ||
| 191 | name.endsWith(".tar.gz") && | ||
| 192 | name !== target.code.file && | ||
| 193 | name !== target.ui.file && | ||
| 194 | name !== target.assets.file | ||
| 195 | ? fs.remove(path.join(releasesDir, name), { force: true }) | ||
| 196 | : Effect.void, | ||
| 197 | ), | ||
| 198 | ), | ||
| 199 | Effect.ignore, | ||
| 200 | ); | ||
| 201 | yield* Ref.set(prepared, { | ||
| 202 | version: target.version, | ||
| 203 | directory, | ||
| 204 | runtime: target.runtime, | ||
| 205 | shell: target.shell, | ||
| 206 | kind: installKind, | ||
| 207 | }); | ||
| 208 | return installKind; | ||
| 209 | }).pipe( | ||
| 210 | Effect.onError(() => | ||
| 211 | fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), | ||
| 212 | ), | ||
| 213 | ); | ||
| 214 | }).pipe(Effect.scoped, Effect.mapError(fail)); | ||
| 215 | |||
| 216 | const installUi = (version: string) => | ||
| 217 | Effect.gen(function* () { | ||
| 218 | const candidate = yield* Ref.get(prepared); | ||
| 219 | if (candidate?.version !== version || candidate.kind !== "ui") return false; | ||
| 220 | yield* ui.apply(candidate.directory, version); | ||
| 221 | yield* Ref.set(prepared, null); | ||
| 222 | yield* fs.remove(candidate.directory, { recursive: true, force: true }).pipe(Effect.ignore); | ||
| 223 | return true; | ||
| 224 | }).pipe(Effect.mapError(fail)); | ||
| 225 | |||
| 226 | const activateCode = (version: string) => | ||
| 227 | Effect.gen(function* () { | ||
| 228 | const candidate = yield* Ref.get(prepared); | ||
| 229 | if (candidate?.version !== version || candidate.kind !== "code") return false; | ||
| 230 | const pointer = path.join(releasesDir, "current.json"); | ||
| 231 | yield* Effect.uninterruptible( | ||
| 232 | Effect.gen(function* () { | ||
| 233 | yield* fs.writeFileString( | ||
| 234 | `${pointer}.tmp`, | ||
| 235 | yield* encodePointer({ | ||
| 236 | version, | ||
| 237 | directory: path.basename(candidate.directory), | ||
| 238 | shell: candidate.shell, | ||
| 239 | runtime: candidate.runtime, | ||
| 240 | }), | ||
| 241 | ); | ||
| 242 | yield* fs.rename(`${pointer}.tmp`, pointer); | ||
| 243 | yield* Ref.set(prepared, null); | ||
| 244 | }), | ||
| 245 | ); | ||
| 246 | yield* fs.readDirectory(releasesDir).pipe( | ||
| 247 | Effect.flatMap((entries) => | ||
| 248 | Effect.forEach(entries, (name) => { | ||
| 249 | const directory = path.join(releasesDir, name); | ||
| 250 | return name.startsWith("release-") && | ||
| 251 | directory !== candidate.directory && | ||
| 252 | directory !== environment.appRoot | ||
| 253 | ? fs.remove(directory, { recursive: true, force: true }) | ||
| 254 | : Effect.void; | ||
| 255 | }), | ||
| 256 | ), | ||
| 257 | Effect.ignore, | ||
| 258 | ); | ||
| 259 | return true; | ||
| 260 | }).pipe(Effect.mapError(fail)); | ||
| 261 | |||
| 262 | return DesktopForkUpdates.of({ check, kind, prepare, installUi, activateCode }); | ||
| 263 | }); | ||
| 264 | |||
| 265 | export const layer = Layer.effect(DesktopForkUpdates, make); | ||
apps/desktop/src/updates/DesktopLocalUi.test.ts created+305| ... | @@ -0,0 +1,305 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, describe, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as PlatformError from "effect/PlatformError"; | ||
| 9 | import * as Ref from "effect/Ref"; | ||
| 10 | import { beforeEach, vi } from "vite-plus/test"; | ||
| 11 | |||
| 12 | import * as DesktopConfig from "../app/DesktopConfig.ts"; | ||
| 13 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ||
| 14 | import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; | ||
| 15 | import * as DesktopLocalUi from "./DesktopLocalUi.ts"; | ||
| 16 | |||
| 17 | const { handle } = vi.hoisted(() => ({ handle: vi.fn() })); | ||
| 18 | vi.mock("electron", () => ({ | ||
| 19 | net: { fetch: vi.fn() }, | ||
| 20 | protocol: { handle, unhandle: vi.fn() }, | ||
| 21 | })); | ||
| 22 | |||
| 23 | const withUi = <A, E, R>( | ||
| 24 | test: ( | ||
| 25 | source: string, | ||
| 26 | ) => Effect.Effect<A, E, R | FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment>, | ||
| 27 | ) => | ||
| 28 | Effect.gen(function* () { | ||
| 29 | const fs = yield* FileSystem.FileSystem; | ||
| 30 | const home = yield* fs.makeTempDirectoryScoped(); | ||
| 31 | const source = `${home}/dev/t3code/apps/web/dist`; | ||
| 32 | const bundled = `${home}/bundle/apps/server/dist/client`; | ||
| 33 | yield* fs.makeDirectory(source, { recursive: true }); | ||
| 34 | yield* fs.makeDirectory(bundled, { recursive: true }); | ||
| 35 | yield* fs.writeFileString(`${bundled}/index.html`, "bundled"); | ||
| 36 | |||
| 37 | return yield* test(source).pipe( | ||
| 38 | Effect.provide( | ||
| 39 | DesktopEnvironment.layer({ | ||
| 40 | dirname: `${home}/bundle/apps/desktop/dist-electron`, | ||
| 41 | homeDirectory: home, | ||
| 42 | platform: "darwin", | ||
| 43 | processArch: "arm64", | ||
| 44 | appVersion: "0.0.45", | ||
| 45 | appPath: `${home}/bundle`, | ||
| 46 | isPackaged: true, | ||
| 47 | resourcesPath: `${home}/resources`, | ||
| 48 | runningUnderArm64Translation: false, | ||
| 49 | }).pipe(Layer.provideMerge(DesktopConfig.layerTest({ T3CODE_HOME: home }))), | ||
| 50 | ), | ||
| 51 | ); | ||
| 52 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); | ||
| 53 | |||
| 54 | const writeBuild = (source: string, version: string) => | ||
| 55 | Effect.gen(function* () { | ||
| 56 | const fs = yield* FileSystem.FileSystem; | ||
| 57 | yield* fs.makeDirectory(`${source}/.vite`, { recursive: true }); | ||
| 58 | yield* fs.writeFileString( | ||
| 59 | `${source}/index.html`, | ||
| 60 | `<html>${version}<script type="module" src="/app-${version}.js"></script></html>`, | ||
| 61 | ); | ||
| 62 | yield* fs.writeFileString(`${source}/app-${version}.js`, `export default "${version}";`); | ||
| 63 | yield* fs.writeFileString(`${source}/lazy-${version}.js`, `export default "lazy-${version}";`); | ||
| 64 | yield* fs.writeFileString( | ||
| 65 | `${source}/.vite/manifest.json`, | ||
| 66 | JSON.stringify({ | ||
| 67 | app: { file: `app-${version}.js` }, | ||
| 68 | lazy: { file: `lazy-${version}.js` }, | ||
| 69 | }), | ||
| 70 | ); | ||
| 71 | }); | ||
| 72 | |||
| 73 | describe("DesktopLocalUi", () => { | ||
| 74 | beforeEach(() => handle.mockReset()); | ||
| 75 | |||
| 76 | it.effect( | ||
| 77 | "uses the bundled UI after a full-app update instead of a snapshot from the previous version", | ||
| 78 | () => | ||
| 79 | withUi((source) => | ||
| 80 | Effect.gen(function* () { | ||
| 81 | const ui = yield* DesktopLocalUi.make; | ||
| 82 | yield* writeBuild(source, "local"); | ||
| 83 | yield* ui.apply(source, "local"); | ||
| 84 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 85 | const updated = yield* DesktopLocalUi.make.pipe( | ||
| 86 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, { | ||
| 87 | ...environment, | ||
| 88 | appVersion: "0.0.2000000000", | ||
| 89 | }), | ||
| 90 | ); | ||
| 91 | assert.deepEqual(yield* updated.assetDirectories, [environment.clientAssetsDir]); | ||
| 92 | const restarted = yield* DesktopLocalUi.make; | ||
| 93 | assert.deepEqual(yield* restarted.assetDirectories, yield* ui.assetDirectories); | ||
| 94 | }), | ||
| 95 | ), | ||
| 96 | ); | ||
| 97 | |||
| 98 | it.effect( | ||
| 99 | "switches the stable renderer origin and retains assets requested by the old page", | ||
| 100 | () => | ||
| 101 | withUi((source) => | ||
| 102 | Effect.gen(function* () { | ||
| 103 | const fs = yield* FileSystem.FileSystem; | ||
| 104 | const ui = yield* DesktopLocalUi.make; | ||
| 105 | let request: ((request: Request) => Promise<Response>) | undefined; | ||
| 106 | handle.mockImplementation((_scheme, handler) => { | ||
| 107 | request = handler; | ||
| 108 | }); | ||
| 109 | const protocol = yield* ElectronProtocol.make; | ||
| 110 | yield* protocol.registerDesktopProtocol({ | ||
| 111 | scheme: "t3code", | ||
| 112 | clerkFrontendApiHostname: undefined, | ||
| 113 | assetDirectories: ui.assetDirectories, | ||
| 114 | }); | ||
| 115 | const read = (pathname: string) => | ||
| 116 | Effect.promise(async () => { | ||
| 117 | const response = await request!(new Request(`t3code://app${pathname}`)); | ||
| 118 | return { status: response.status, text: await response.text() }; | ||
| 119 | }); | ||
| 120 | |||
| 121 | assert.equal((yield* read("/settings")).text, "bundled"); | ||
| 122 | yield* writeBuild(source, "one"); | ||
| 123 | yield* ui.apply(source, "local"); | ||
| 124 | const [first] = yield* ui.assetDirectories; | ||
| 125 | yield* fs.remove(source, { recursive: true }); | ||
| 126 | assert.include((yield* read("/thread/active")).text, "one"); | ||
| 127 | assert.equal((yield* read("/app-one.js")).text, 'export default "one";'); | ||
| 128 | |||
| 129 | yield* writeBuild(source, "two"); | ||
| 130 | yield* ui.apply(source, "local"); | ||
| 131 | assert.include((yield* read("/thread/active")).text, "two"); | ||
| 132 | assert.equal((yield* read("/lazy-one.js")).text, 'export default "lazy-one";'); | ||
| 133 | |||
| 134 | yield* writeBuild(source, "three"); | ||
| 135 | yield* ui.apply(source, "local"); | ||
| 136 | assert.isFalse(yield* fs.exists(first)); | ||
| 137 | const resumed = yield* DesktopLocalUi.make; | ||
| 138 | assert.deepEqual((yield* resumed.assetDirectories)[0], (yield* ui.assetDirectories)[0]); | ||
| 139 | |||
| 140 | yield* ui.reset; | ||
| 141 | assert.equal((yield* read("/thread/active")).text, "bundled"); | ||
| 142 | const restored = yield* DesktopLocalUi.make; | ||
| 143 | assert.equal((yield* restored.assetDirectories)[0], (yield* ui.assetDirectories)[0]); | ||
| 144 | }), | ||
| 145 | ), | ||
| 146 | ); | ||
| 147 | |||
| 148 | it.effect("rejects a partial build without switching away from the current UI", () => | ||
| 149 | withUi((source) => | ||
| 150 | Effect.gen(function* () { | ||
| 151 | const fs = yield* FileSystem.FileSystem; | ||
| 152 | const ui = yield* DesktopLocalUi.make; | ||
| 153 | yield* writeBuild(source, "one"); | ||
| 154 | yield* ui.apply(source, "local"); | ||
| 155 | const current = yield* ui.assetDirectories; | ||
| 156 | yield* writeBuild(source, "two"); | ||
| 157 | yield* fs.remove(`${source}/lazy-two.js`); | ||
| 158 | |||
| 159 | const error = yield* ui.apply(source, "local").pipe(Effect.flip); | ||
| 160 | assert.include(error.message, "incomplete"); | ||
| 161 | assert.deepEqual(yield* ui.assetDirectories, current); | ||
| 162 | const resumed = yield* DesktopLocalUi.make; | ||
| 163 | assert.equal((yield* resumed.assetDirectories)[0], current[0]); | ||
| 164 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 165 | const entries = yield* fs.readDirectory( | ||
| 166 | `${environment.stateDir}/local-ui/${environment.appVersion}`, | ||
| 167 | ); | ||
| 168 | assert.equal(entries.filter((name) => name.startsWith("build-")).length, 1); | ||
| 169 | }), | ||
| 170 | ), | ||
| 171 | ); | ||
| 172 | |||
| 173 | it.effect("leaves the current UI active when saving the selection fails", () => | ||
| 174 | withUi((source) => | ||
| 175 | Effect.gen(function* () { | ||
| 176 | const fs = yield* FileSystem.FileSystem; | ||
| 177 | const fail = yield* Ref.make(false); | ||
| 178 | const ui = yield* DesktopLocalUi.make.pipe( | ||
| 179 | Effect.provideService(FileSystem.FileSystem, { | ||
| 180 | ...fs, | ||
| 181 | rename: (from, to) => | ||
| 182 | Effect.gen(function* () { | ||
| 183 | if (yield* Ref.get(fail)) | ||
| 184 | return yield* PlatformError.systemError({ | ||
| 185 | _tag: "PermissionDenied", | ||
| 186 | module: "FileSystem", | ||
| 187 | method: "rename", | ||
| 188 | pathOrDescriptor: to, | ||
| 189 | }); | ||
| 190 | yield* fs.rename(from, to); | ||
| 191 | }), | ||
| 192 | }), | ||
| 193 | ); | ||
| 194 | yield* writeBuild(source, "one"); | ||
| 195 | yield* ui.apply(source, "local"); | ||
| 196 | const current = yield* ui.assetDirectories; | ||
| 197 | yield* writeBuild(source, "two"); | ||
| 198 | yield* Ref.set(fail, true); | ||
| 199 | yield* ui.apply(source, "local").pipe(Effect.flip); | ||
| 200 | assert.deepEqual(yield* ui.assetDirectories, current); | ||
| 201 | const resumed = yield* DesktopLocalUi.make; | ||
| 202 | assert.equal((yield* resumed.assetDirectories)[0], current[0]); | ||
| 203 | }), | ||
| 204 | ), | ||
| 205 | ); | ||
| 206 | |||
| 207 | it.effect("rejects an empty application script", () => | ||
| 208 | withUi((source) => | ||
| 209 | Effect.gen(function* () { | ||
| 210 | const fs = yield* FileSystem.FileSystem; | ||
| 211 | const ui = yield* DesktopLocalUi.make; | ||
| 212 | const current = yield* ui.assetDirectories; | ||
| 213 | yield* writeBuild(source, "one"); | ||
| 214 | yield* fs.writeFileString(`${source}/app-one.js`, ""); | ||
| 215 | assert.include((yield* ui.apply(source, "local").pipe(Effect.flip)).message, "incomplete"); | ||
| 216 | assert.deepEqual(yield* ui.assetDirectories, current); | ||
| 217 | }), | ||
| 218 | ), | ||
| 219 | ); | ||
| 220 | |||
| 221 | it.effect("rejects files changed while copying a build", () => | ||
| 222 | withUi((source) => | ||
| 223 | Effect.gen(function* () { | ||
| 224 | const fs = yield* FileSystem.FileSystem; | ||
| 225 | const ui = yield* DesktopLocalUi.make.pipe( | ||
| 226 | Effect.provideService(FileSystem.FileSystem, { | ||
| 227 | ...fs, | ||
| 228 | copy: (from, to) => | ||
| 229 | Effect.gen(function* () { | ||
| 230 | yield* fs.writeFileString(`${source}/app-one.js`, 'export default "partial";'); | ||
| 231 | yield* fs.copy(from, to); | ||
| 232 | }), | ||
| 233 | }), | ||
| 234 | ); | ||
| 235 | const current = yield* ui.assetDirectories; | ||
| 236 | yield* writeBuild(source, "one"); | ||
| 237 | assert.include((yield* ui.apply(source, "local").pipe(Effect.flip)).message, "incomplete"); | ||
| 238 | assert.deepEqual(yield* ui.assetDirectories, current); | ||
| 239 | }), | ||
| 240 | ), | ||
| 241 | ); | ||
| 242 | |||
| 243 | it.effect("starts with bundled assets when the saved selection cannot be read", () => | ||
| 244 | withUi((source) => | ||
| 245 | Effect.gen(function* () { | ||
| 246 | const fs = yield* FileSystem.FileSystem; | ||
| 247 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 248 | const ui = yield* DesktopLocalUi.make; | ||
| 249 | yield* writeBuild(source, "one"); | ||
| 250 | yield* ui.apply(source, "local"); | ||
| 251 | const resumed = yield* DesktopLocalUi.make.pipe( | ||
| 252 | Effect.provideService(FileSystem.FileSystem, { | ||
| 253 | ...fs, | ||
| 254 | readFileString: (file, options) => | ||
| 255 | file.endsWith("/current") | ||
| 256 | ? Effect.fail( | ||
| 257 | PlatformError.systemError({ | ||
| 258 | _tag: "PermissionDenied", | ||
| 259 | module: "FileSystem", | ||
| 260 | method: "readFileString", | ||
| 261 | pathOrDescriptor: file, | ||
| 262 | }), | ||
| 263 | ) | ||
| 264 | : fs.readFileString(file, options), | ||
| 265 | }), | ||
| 266 | ); | ||
| 267 | assert.deepEqual(yield* resumed.assetDirectories, [environment.clientAssetsDir]); | ||
| 268 | yield* resumed.reset; | ||
| 269 | }), | ||
| 270 | ), | ||
| 271 | ); | ||
| 272 | |||
| 273 | it.effect("keeps the current UI available while staging and after an interrupted update", () => | ||
| 274 | withUi((source) => | ||
| 275 | Effect.gen(function* () { | ||
| 276 | const fs = yield* FileSystem.FileSystem; | ||
| 277 | const started = yield* Deferred.make<void>(); | ||
| 278 | const finish = yield* Deferred.make<void>(); | ||
| 279 | const ui = yield* DesktopLocalUi.make.pipe( | ||
| 280 | Effect.provideService(FileSystem.FileSystem, { | ||
| 281 | ...fs, | ||
| 282 | copy: (from, to) => | ||
| 283 | Effect.gen(function* () { | ||
| 284 | yield* Deferred.succeed(started, undefined); | ||
| 285 | yield* Deferred.await(finish); | ||
| 286 | yield* fs.copy(from, to); | ||
| 287 | }), | ||
| 288 | }), | ||
| 289 | ); | ||
| 290 | yield* writeBuild(source, "one"); | ||
| 291 | const current = yield* ui.assetDirectories; | ||
| 292 | const update = yield* ui.apply(source, "local").pipe(Effect.forkChild); | ||
| 293 | yield* Deferred.await(started); | ||
| 294 | assert.deepEqual(yield* ui.assetDirectories, current); | ||
| 295 | yield* Fiber.interrupt(update); | ||
| 296 | assert.deepEqual(yield* ui.assetDirectories, current); | ||
| 297 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 298 | const entries = yield* fs.readDirectory( | ||
| 299 | `${environment.stateDir}/local-ui/${environment.appVersion}`, | ||
| 300 | ); | ||
| 301 | assert.isEmpty(entries); | ||
| 302 | }), | ||
| 303 | ), | ||
| 304 | ); | ||
| 305 | }); | ||
apps/desktop/src/updates/DesktopLocalUi.ts created+236| ... | @@ -0,0 +1,236 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Ref from "effect/Ref"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import * as Semaphore from "effect/Semaphore"; | ||
| 8 | import * as NodeBuffer from "node:buffer"; | ||
| 9 | |||
| 10 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ||
| 11 | |||
| 12 | const UiBuildManifest = Schema.fromJsonString( | ||
| 13 | Schema.Record( | ||
| 14 | Schema.String, | ||
| 15 | Schema.Struct({ | ||
| 16 | file: Schema.String, | ||
| 17 | css: Schema.optionalKey(Schema.Array(Schema.String)), | ||
| 18 | assets: Schema.optionalKey(Schema.Array(Schema.String)), | ||
| 19 | }), | ||
| 20 | ), | ||
| 21 | ); | ||
| 22 | |||
| 23 | export class DesktopLocalUiError extends Schema.TaggedError<DesktopLocalUiError>()( | ||
| 24 | "DesktopLocalUiError", | ||
| 25 | { message: Schema.String, cause: Schema.Defect() }, | ||
| 26 | ) {} | ||
| 27 | |||
| 28 | const isLocalUiError = Schema.is(DesktopLocalUiError); | ||
| 29 | const decodeUiBuildManifest = Schema.decodeEffect(UiBuildManifest); | ||
| 30 | const UiPointer = Schema.fromJsonString( | ||
| 31 | Schema.Struct({ name: Schema.String, version: Schema.String }), | ||
| 32 | ); | ||
| 33 | const decodeUiPointer = Schema.decodeEffect(UiPointer); | ||
| 34 | const encodeUiPointer = Schema.encodeEffect(UiPointer); | ||
| 35 | const incompleteBuildMessage = "The UI build is incomplete. Finish the build and try again."; | ||
| 36 | |||
| 37 | export class DesktopLocalUi extends Context.Service< | ||
| 38 | DesktopLocalUi, | ||
| 39 | { | ||
| 40 | readonly assetDirectories: Effect.Effect<readonly [string, ...string[]]>; | ||
| 41 | readonly currentVersion: Effect.Effect<string>; | ||
| 42 | readonly apply: (source: string, version: string) => Effect.Effect<void, DesktopLocalUiError>; | ||
| 43 | readonly reset: Effect.Effect<void, DesktopLocalUiError>; | ||
| 44 | } | ||
| 45 | >()("@t3tools/desktop/updates/DesktopLocalUi") {} | ||
| 46 | |||
| 47 | /** @public Service construction is part of the canonical Effect module API. */ | ||
| 48 | export const make = Effect.gen(function* () { | ||
| 49 | const fs = yield* FileSystem.FileSystem; | ||
| 50 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 51 | const path = environment.path; | ||
| 52 | const directory = path.join(environment.stateDir, "local-ui", environment.appVersion); | ||
| 53 | const pointer = path.join(directory, "current"); | ||
| 54 | const bundled = environment.clientAssetsDir; | ||
| 55 | const currentVersion = yield* Ref.make(environment.appVersion); | ||
| 56 | const initial = yield* Effect.gen(function* () { | ||
| 57 | if (yield* fs.exists(pointer)) { | ||
| 58 | const { name, version } = yield* decodeUiPointer(yield* fs.readFileString(pointer)); | ||
| 59 | if (name.startsWith("build-") && path.basename(name) === name) { | ||
| 60 | const snapshot = path.join(directory, name); | ||
| 61 | if (yield* fs.exists(path.join(snapshot, "index.html"))) { | ||
| 62 | yield* Ref.set(currentVersion, version); | ||
| 63 | return snapshot; | ||
| 64 | } | ||
| 65 | } | ||
| 66 | } | ||
| 67 | return bundled; | ||
| 68 | }).pipe(Effect.catch((error) => Effect.logError(error).pipe(Effect.as(bundled)))); | ||
| 69 | |||
| 70 | const active = yield* Ref.make<readonly [string, ...string[]]>( | ||
| 71 | initial === bundled ? [bundled] : [initial, bundled], | ||
| 72 | ); | ||
| 73 | const mutex = yield* Semaphore.make(1); | ||
| 74 | |||
| 75 | const apply = (source: string, version: string) => | ||
| 76 | Effect.gen(function* () { | ||
| 77 | if (environment.isDevelopment) { | ||
| 78 | return yield* new DesktopLocalUiError({ | ||
| 79 | message: "The development UI updates automatically.", | ||
| 80 | cause: "Development renderer", | ||
| 81 | }); | ||
| 82 | } | ||
| 83 | if (!(yield* fs.exists(path.join(source, "index.html")))) { | ||
| 84 | return yield* new DesktopLocalUiError({ | ||
| 85 | message: "The downloaded update has no completed UI build.", | ||
| 86 | cause: source, | ||
| 87 | }); | ||
| 88 | } | ||
| 89 | |||
| 90 | yield* fs.makeDirectory(directory, { recursive: true }); | ||
| 91 | const snapshot = yield* fs.makeTempDirectory({ directory, prefix: "build-" }); | ||
| 92 | |||
| 93 | yield* Effect.gen(function* () { | ||
| 94 | yield* Effect.gen(function* () { | ||
| 95 | const html = yield* fs.readFileString(path.join(source, "index.html")); | ||
| 96 | const manifestJson = yield* fs.readFileString( | ||
| 97 | path.join(source, ".vite", "manifest.json"), | ||
| 98 | ); | ||
| 99 | const manifest = yield* decodeUiBuildManifest(manifestJson); | ||
| 100 | const references = [...html.matchAll(/<(?:script|link)\b[^>]*(?:src|href)="([^"]+)"/g)]; | ||
| 101 | if (!references.some(([tag]) => tag.startsWith("<script"))) { | ||
| 102 | return yield* new DesktopLocalUiError({ | ||
| 103 | message: incompleteBuildMessage, | ||
| 104 | cause: "Missing application script", | ||
| 105 | }); | ||
| 106 | } | ||
| 107 | |||
| 108 | const files = Object.values(manifest).flatMap((entry) => [ | ||
| 109 | entry.file, | ||
| 110 | ...(entry.css ?? []), | ||
| 111 | ...(entry.assets ?? []), | ||
| 112 | ]); | ||
| 113 | const stagedFiles: Array<{ file: string; contents: NodeBuffer.Buffer }> = [ | ||
| 114 | { file: "index.html", contents: NodeBuffer.Buffer.from(html) }, | ||
| 115 | { | ||
| 116 | file: ".vite/manifest.json", | ||
| 117 | contents: NodeBuffer.Buffer.from(manifestJson), | ||
| 118 | }, | ||
| 119 | ]; | ||
| 120 | for (const reference of new Set([...files, ...references.map((match) => match[1]!)])) { | ||
| 121 | const url = yield* Effect.try({ | ||
| 122 | try: () => new URL(reference, "http://local-ui/"), | ||
| 123 | catch: (cause) => new DesktopLocalUiError({ message: incompleteBuildMessage, cause }), | ||
| 124 | }); | ||
| 125 | if (url.origin !== "http://local-ui") continue; | ||
| 126 | const file = yield* Effect.try({ | ||
| 127 | try: () => path.resolve(source, `.${decodeURIComponent(url.pathname)}`), | ||
| 128 | catch: (cause) => new DesktopLocalUiError({ message: incompleteBuildMessage, cause }), | ||
| 129 | }); | ||
| 130 | if (!file.startsWith(source + path.sep)) { | ||
| 131 | return yield* new DesktopLocalUiError({ | ||
| 132 | message: incompleteBuildMessage, | ||
| 133 | cause: reference, | ||
| 134 | }); | ||
| 135 | } | ||
| 136 | const contents = NodeBuffer.Buffer.from(yield* fs.readFile(file)); | ||
| 137 | if (contents.length === 0) { | ||
| 138 | return yield* new DesktopLocalUiError({ | ||
| 139 | message: incompleteBuildMessage, | ||
| 140 | cause: reference, | ||
| 141 | }); | ||
| 142 | } | ||
| 143 | stagedFiles.push({ file: path.relative(source, file), contents }); | ||
| 144 | } | ||
| 145 | |||
| 146 | yield* fs.copy(source, snapshot); | ||
| 147 | for (const { file, contents } of stagedFiles) { | ||
| 148 | for (const root of [source, snapshot]) { | ||
| 149 | const copied = yield* fs.readFile(path.join(root, file)); | ||
| 150 | if (!contents.equals(copied)) { | ||
| 151 | return yield* new DesktopLocalUiError({ | ||
| 152 | message: incompleteBuildMessage, | ||
| 153 | cause: file, | ||
| 154 | }); | ||
| 155 | } | ||
| 156 | } | ||
| 157 | } | ||
| 158 | }).pipe( | ||
| 159 | Effect.mapError((cause) => | ||
| 160 | isLocalUiError(cause) | ||
| 161 | ? cause | ||
| 162 | : new DesktopLocalUiError({ message: incompleteBuildMessage, cause }), | ||
| 163 | ), | ||
| 164 | ); | ||
| 165 | |||
| 166 | yield* Effect.uninterruptible( | ||
| 167 | Effect.gen(function* () { | ||
| 168 | yield* fs.writeFileString( | ||
| 169 | `${pointer}.tmp`, | ||
| 170 | yield* encodeUiPointer({ name: path.basename(snapshot), version }), | ||
| 171 | ); | ||
| 172 | yield* fs.rename(`${pointer}.tmp`, pointer); | ||
| 173 | const previous = yield* Ref.get(active); | ||
| 174 | yield* Ref.set(active, [snapshot, previous[0]]); | ||
| 175 | yield* Ref.set(currentVersion, version); | ||
| 176 | }), | ||
| 177 | ); | ||
| 178 | }).pipe( | ||
| 179 | Effect.onError(() => | ||
| 180 | Effect.gen(function* () { | ||
| 181 | if (!(yield* Ref.get(active)).includes(snapshot)) { | ||
| 182 | yield* fs.remove(snapshot, { recursive: true, force: true }); | ||
| 183 | } | ||
| 184 | }).pipe(Effect.ignore), | ||
| 185 | ), | ||
| 186 | ); | ||
| 187 | |||
| 188 | const keep = yield* Ref.get(active); | ||
| 189 | yield* fs.readDirectory(directory).pipe( | ||
| 190 | Effect.flatMap((entries) => | ||
| 191 | Effect.forEach(entries, (name) => { | ||
| 192 | const candidate = path.join(directory, name); | ||
| 193 | return name.startsWith("build-") && !keep.includes(candidate) | ||
| 194 | ? fs.remove(candidate, { recursive: true, force: true }) | ||
| 195 | : Effect.void; | ||
| 196 | }), | ||
| 197 | ), | ||
| 198 | Effect.ignore, | ||
| 199 | ); | ||
| 200 | }).pipe( | ||
| 201 | Effect.mapError((cause) => | ||
| 202 | isLocalUiError(cause) | ||
| 203 | ? cause | ||
| 204 | : new DesktopLocalUiError({ | ||
| 205 | message: "Could not apply the UI build. Try again.", | ||
| 206 | cause, | ||
| 207 | }), | ||
| 208 | ), | ||
| 209 | Effect.tapError((error) => Effect.logError(error)), | ||
| 210 | mutex.withPermit, | ||
| 211 | ); | ||
| 212 | |||
| 213 | const reset = Effect.uninterruptible( | ||
| 214 | Effect.gen(function* () { | ||
| 215 | yield* fs.remove(pointer, { force: true }); | ||
| 216 | yield* Ref.set(currentVersion, environment.appVersion); | ||
| 217 | const previous = yield* Ref.get(active); | ||
| 218 | yield* Ref.set(active, previous[0] === bundled ? [bundled] : [bundled, previous[0]]); | ||
| 219 | }), | ||
| 220 | ).pipe( | ||
| 221 | Effect.mapError( | ||
| 222 | (cause) => new DesktopLocalUiError({ message: "Could not restore the bundled UI.", cause }), | ||
| 223 | ), | ||
| 224 | Effect.tapError((error) => Effect.logError(error)), | ||
| 225 | mutex.withPermit, | ||
| 226 | ); | ||
| 227 | |||
| 228 | return DesktopLocalUi.of({ | ||
| 229 | assetDirectories: Ref.get(active), | ||
| 230 | currentVersion: Ref.get(currentVersion), | ||
| 231 | apply, | ||
| 232 | reset, | ||
| 233 | }); | ||
| 234 | }); | ||
| 235 | |||
| 236 | export const layer = Layer.effect(DesktopLocalUi, make); | ||
apps/desktop/src/updates/DesktopRemoteUpdates.test.ts+17| ... | @@ -84,6 +84,23 @@ function terminalReports(reports: DesktopUpdateStatusReport[]): DesktopUpdateSta | ... | @@ -84,6 +84,23 @@ function terminalReports(reports: DesktopUpdateStatusReport[]): DesktopUpdateSta |
| 84 | } | 84 | } |
| 85 | 85 | ||
| 86 | describe("DesktopRemoteUpdates", () => { | 86 | describe("DesktopRemoteUpdates", () => { |
| 87 | it.effect( | ||
| 88 | "leaves a UI-only update pending for the local renderer without restarting its backend", | ||
| 89 | () => { | ||
| 90 | const harness = makeHarness({ uiOnly: true }); | ||
| 91 | return runRemoteUpdatesTest(harness, ({ reports, requests }) => | ||
| 92 | Effect.gen(function* () { | ||
| 93 | yield* Queue.offer(requests, request("ui-only")); | ||
| 94 | yield* settle; | ||
| 95 | assert.equal(terminalReports(reports).at(-1)?.outcome, "up-to-date"); | ||
| 96 | assert.equal((yield* (yield* DesktopUpdates.DesktopUpdates).getState).installKind, "ui"); | ||
| 97 | assert.deepEqual(harness.installSteps, []); | ||
| 98 | assert.deepEqual(harness.uiInstalls, []); | ||
| 99 | }), | ||
| 100 | ); | ||
| 101 | }, | ||
| 102 | ); | ||
| 103 | |||
| 87 | it.effect("drives check, download, and install with no local confirmation", () => { | 104 | it.effect("drives check, download, and install with no local confirmation", () => { |
| 88 | const harness = makeHarness(); | 105 | const harness = makeHarness(); |
| 89 | 106 |
apps/desktop/src/updates/DesktopRemoteUpdates.ts+4| ... | @@ -228,6 +228,10 @@ export const listen: Effect.Effect< | ... | @@ -228,6 +228,10 @@ export const listen: Effect.Effect< |
| 228 | ); | 228 | ); |
| 229 | return false; | 229 | return false; |
| 230 | case "install": { | 230 | case "install": { |
| 231 | if (state.installKind === "ui") { | ||
| 232 | yield* publishReport(state, { outcome: "up-to-date" }, request.requestId); | ||
| 233 | return true; | ||
| 234 | } | ||
| 231 | // The download event fires before its action releases the | 235 | // The download event fires before its action releases the |
| 232 | // updater reservation. Wait until the prepared install can | 236 | // updater reservation. Wait until the prepared install can |
| 233 | // be committed by the client in a separate RPC. | 237 | // be committed by the client in a separate RPC. |
apps/desktop/src/updates/DesktopUpdates.test.ts+157-1| ... | @@ -18,9 +18,165 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | ... | @@ -18,9 +18,165 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 19 | import * as DesktopState from "../app/DesktopState.ts"; | 19 | import * as DesktopState from "../app/DesktopState.ts"; |
| 20 | import * as DesktopUpdates from "./DesktopUpdates.ts"; | 20 | import * as DesktopUpdates from "./DesktopUpdates.ts"; |
| 21 | import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; | 21 | import { flushCallbacks, makeHarness, forkReleaseFixture } from "./updatesTestHarness.ts"; |
| 22 | 22 | ||
| 23 | describe("DesktopUpdates", () => { | 23 | describe("DesktopUpdates", () => { |
| 24 | it.effect( | ||
| 25 | "discovers new private releases and uses them for either stored channel preference", | ||
| 26 | () => { | ||
| 27 | let latest = "0.0.2000000000"; | ||
| 28 | const harness = makeHarness({ | ||
| 29 | env: { T3CODE_DESKTOP_MOCK_UPDATES: "false" }, | ||
| 30 | appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n", | ||
| 31 | releaseManifest: () => forkReleaseFixture(latest), | ||
| 32 | }); | ||
| 33 | return Effect.scoped( | ||
| 34 | Effect.gen(function* () { | ||
| 35 | const updates = yield* DesktopUpdates.DesktopUpdates; | ||
| 36 | yield* updates.configure; | ||
| 37 | yield* updates.check("test"); | ||
| 38 | assert.equal(harness.checkCount(), 0); | ||
| 39 | assert.equal(harness.downloadCount(), 0); | ||
| 40 | assert.equal((yield* updates.getState).status, "downloaded"); | ||
| 41 | latest = "0.0.2000000001"; | ||
| 42 | yield* updates.setChannel("nightly"); | ||
| 43 | yield* updates.check("test"); | ||
| 44 | const state = yield* updates.getState; | ||
| 45 | assert.equal(state.channel, "nightly"); | ||
| 46 | assert.equal(state.status, "downloaded"); | ||
| 47 | assert.equal(state.availableVersion, latest); | ||
| 48 | }), | ||
| 49 | ).pipe(Effect.provide(harness.layer)); | ||
| 50 | }, | ||
| 51 | ); | ||
| 52 | |||
| 53 | it.effect("applies a compatible downloaded UI without stopping a backend or quitting", () => { | ||
| 54 | const harness = makeHarness({ uiOnly: true }); | ||
| 55 | return Effect.scoped( | ||
| 56 | Effect.gen(function* () { | ||
| 57 | const updates = yield* DesktopUpdates.DesktopUpdates; | ||
| 58 | yield* updates.configure; | ||
| 59 | yield* updates.check("test"); | ||
| 60 | const downloaded = yield* updates.download; | ||
| 61 | assert.equal(downloaded.state.installKind, "ui"); | ||
| 62 | assert.equal(downloaded.state.status, "downloaded"); | ||
| 63 | const result = yield* updates.install; | ||
| 64 | assert.equal(result.completed, true); | ||
| 65 | assert.equal(result.state.currentVersion, "1.2.3"); | ||
| 66 | assert.equal(result.state.uiVersion, "1.2.4"); | ||
| 67 | assert.equal(result.state.status, "up-to-date"); | ||
| 68 | assert.deepEqual(harness.uiInstalls, ["1.2.4"]); | ||
| 69 | assert.equal(harness.quitAndInstalls(), 0); | ||
| 70 | assert.isEmpty(harness.installSteps); | ||
| 71 | assert.isEmpty([...harness.updateRestartMarkers]); | ||
| 72 | harness.emit("update-available", { version: "1.2.4" }); | ||
| 73 | yield* flushCallbacks; | ||
| 74 | assert.equal((yield* updates.getState).status, "up-to-date"); | ||
| 75 | }), | ||
| 76 | ).pipe(Effect.provide(harness.layer)); | ||
| 77 | }); | ||
| 78 | |||
| 79 | it.effect("downloads code automatically but activates it only after a confirmed install", () => { | ||
| 80 | let stopped = 0; | ||
| 81 | const harness = makeHarness({ | ||
| 82 | appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n", | ||
| 83 | releaseManifest: () => forkReleaseFixture("0.0.2000000000"), | ||
| 84 | stopBackend: Effect.sync(() => { | ||
| 85 | stopped += 1; | ||
| 86 | }), | ||
| 87 | }); | ||
| 88 | return Effect.scoped( | ||
| 89 | Effect.gen(function* () { | ||
| 90 | const updates = yield* DesktopUpdates.DesktopUpdates; | ||
| 91 | yield* updates.configure; | ||
| 92 | const checked = yield* updates.check("test"); | ||
| 93 | assert.equal(checked.state.status, "downloaded"); | ||
| 94 | assert.equal(checked.state.installKind, "app"); | ||
| 95 | assert.deepEqual(harness.installSteps, []); | ||
| 96 | assert.equal(harness.downloadCount(), 0); | ||
| 97 | const installed = yield* updates.install; | ||
| 98 | assert.equal(installed.accepted, true); | ||
| 99 | assert.deepEqual(harness.installSteps, ["activateCode", "relaunch", "quit"]); | ||
| 100 | assert.equal(stopped, 1); | ||
| 101 | assert.equal(harness.quitAndInstalls(), 0); | ||
| 102 | }), | ||
| 103 | ).pipe(Effect.provide(harness.layer)); | ||
| 104 | }); | ||
| 105 | |||
| 106 | it.effect("retains a confirmed full restart for incompatible downloaded releases", () => { | ||
| 107 | const harness = makeHarness({ uiOnly: false }); | ||
| 108 | return Effect.scoped( | ||
| 109 | Effect.gen(function* () { | ||
| 110 | const updates = yield* DesktopUpdates.DesktopUpdates; | ||
| 111 | yield* updates.configure; | ||
| 112 | harness.emit("update-available", { version: "1.2.4" }); | ||
| 113 | yield* flushCallbacks; | ||
| 114 | assert.equal((yield* updates.download).state.installKind, "app"); | ||
| 115 | const result = yield* updates.install; | ||
| 116 | assert.equal(result.accepted, true); | ||
| 117 | assert.equal(harness.quitAndInstalls(), 1); | ||
| 118 | assert.isEmpty(harness.uiInstalls); | ||
| 119 | }), | ||
| 120 | ).pipe(Effect.provide(harness.layer)); | ||
| 121 | }); | ||
| 122 | |||
| 123 | it.effect("does not offer an already-applied UI release after relaunch", () => { | ||
| 124 | const harness = makeHarness({ uiVersion: "1.2.4" }); | ||
| 125 | return Effect.scoped( | ||
| 126 | Effect.gen(function* () { | ||
| 127 | const updates = yield* DesktopUpdates.DesktopUpdates; | ||
| 128 | yield* updates.configure; | ||
| 129 | harness.emit("update-available", { version: "1.2.4" }); | ||
| 130 | yield* flushCallbacks; | ||
| 131 | assert.equal((yield* updates.getState).status, "up-to-date"); | ||
| 132 | assert.equal((yield* updates.getState).currentVersion, "1.2.3"); | ||
| 133 | }), | ||
| 134 | ).pipe(Effect.provide(harness.layer)); | ||
| 135 | }); | ||
| 136 | |||
| 137 | it.effect("does not offer an older private release", () => { | ||
| 138 | const harness = makeHarness({ | ||
| 139 | env: { T3CODE_DESKTOP_MOCK_UPDATES: "false" }, | ||
| 140 | appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n", | ||
| 141 | releaseManifest: () => forkReleaseFixture("0.0.2000000000"), | ||
| 142 | uiVersion: "0.0.2000000001", | ||
| 143 | }); | ||
| 144 | return Effect.scoped( | ||
| 145 | Effect.gen(function* () { | ||
| 146 | const updates = yield* DesktopUpdates.DesktopUpdates; | ||
| 147 | yield* updates.configure; | ||
| 148 | yield* updates.check("test"); | ||
| 149 | assert.equal(harness.checkCount(), 0); | ||
| 150 | assert.equal((yield* updates.getState).status, "up-to-date"); | ||
| 151 | }), | ||
| 152 | ).pipe(Effect.provide(harness.layer)); | ||
| 153 | }); | ||
| 154 | |||
| 155 | it.effect("keeps a platform up to date when only another platform has a newer release", () => { | ||
| 156 | const release = forkReleaseFixture("0.0.2000000001"); | ||
| 157 | const target = release.desktop["darwin-x64"]!; | ||
| 158 | const harness = makeHarness({ | ||
| 159 | env: { T3CODE_DESKTOP_MOCK_UPDATES: "false" }, | ||
| 160 | appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n", | ||
| 161 | releaseManifest: () => ({ | ||
| 162 | ...release, | ||
| 163 | desktop: { "darwin-x64": { ...target, version: "0.0.2000000000" } }, | ||
| 164 | }), | ||
| 165 | uiVersion: "0.0.2000000000", | ||
| 166 | }); | ||
| 167 | return Effect.scoped( | ||
| 168 | Effect.gen(function* () { | ||
| 169 | const updates = yield* DesktopUpdates.DesktopUpdates; | ||
| 170 | yield* updates.configure; | ||
| 171 | yield* updates.check("test"); | ||
| 172 | const state = yield* updates.getState; | ||
| 173 | assert.equal(state.status, "up-to-date"); | ||
| 174 | assert.equal(state.availableVersion, null); | ||
| 175 | assert.equal(harness.downloadCount(), 0); | ||
| 176 | }), | ||
| 177 | ).pipe(Effect.provide(harness.layer)); | ||
| 178 | }); | ||
| 179 | |||
| 24 | it("preserves complete causes for update poller and event failures", () => { | 180 | it("preserves complete causes for update poller and event failures", () => { |
| 25 | const cause = Cause.combine( | 181 | const cause = Cause.combine( |
| 26 | Cause.fail(new Error("updater failed")), | 182 | Cause.fail(new Error("updater failed")), |
apps/desktop/src/updates/DesktopUpdates.ts+167-12| ... | @@ -7,7 +7,9 @@ import { | ... | @@ -7,7 +7,9 @@ import { |
| 7 | type DesktopUpdateCheckResult, | 7 | type DesktopUpdateCheckResult, |
| 8 | type DesktopUpdateState, | 8 | type DesktopUpdateState, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { compareSemverVersions } from "@t3tools/shared/semver"; | ||
| 10 | import { errorTag } from "@t3tools/shared/observability"; | 11 | import { errorTag } from "@t3tools/shared/observability"; |
| 12 | import { ForkReleaseManifest } from "@t3tools/shared/forkUpdateDirectory"; | ||
| 11 | import * as Cause from "effect/Cause"; | 13 | import * as Cause from "effect/Cause"; |
| 12 | import * as Context from "effect/Context"; | 14 | import * as Context from "effect/Context"; |
| 13 | import * as DateTime from "effect/DateTime"; | 15 | import * as DateTime from "effect/DateTime"; |
| ... | @@ -30,6 +32,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ... | @@ -30,6 +32,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 30 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 32 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 31 | import * as DesktopState from "../app/DesktopState.ts"; | 33 | import * as DesktopState from "../app/DesktopState.ts"; |
| 32 | import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; | 34 | import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; |
| 35 | import * as DesktopForkUpdates from "./DesktopForkUpdates.ts"; | ||
| 36 | import * as ElectronApp from "../electron/ElectronApp.ts"; | ||
| 37 | import * as DesktopLocalUi from "./DesktopLocalUi.ts"; | ||
| 33 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | 38 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 34 | import * as IpcChannels from "../ipc/channels.ts"; | 39 | import * as IpcChannels from "../ipc/channels.ts"; |
| 35 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | 40 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| ... | @@ -350,20 +355,36 @@ export const make = Effect.gen(function* () { | ... | @@ -350,20 +355,36 @@ export const make = Effect.gen(function* () { |
| 350 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 355 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 351 | const fileSystem = yield* FileSystem.FileSystem; | 356 | const fileSystem = yield* FileSystem.FileSystem; |
| 352 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; | 357 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 358 | const fork = yield* DesktopForkUpdates.DesktopForkUpdates; | ||
| 359 | const electronApp = yield* ElectronApp.ElectronApp; | ||
| 360 | const forkRelease = yield* Ref.make<{ | ||
| 361 | base: URL; | ||
| 362 | release: typeof ForkReleaseManifest.Type; | ||
| 363 | kind: "ui" | "code" | "shell"; | ||
| 364 | } | null>(null); | ||
| 353 | 365 | ||
| 354 | const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none()); | 366 | const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none()); |
| 367 | const forkUpdateDirectory = Ref.get(appUpdateYmlConfigRef).pipe( | ||
| 368 | Effect.map((config) => { | ||
| 369 | if (Option.isNone(config) || config.value.provider !== "generic") return; | ||
| 370 | const url = URL.parse(config.value.url ?? ""); | ||
| 371 | return url?.searchParams.has("ls") ? url : undefined; | ||
| 372 | }), | ||
| 373 | ); | ||
| 374 | const localUi = yield* DesktopLocalUi.DesktopLocalUi; | ||
| 355 | const activeUpdateActionRef = yield* Ref.make<Option.Option<UpdateAction>>(Option.none()); | 375 | const activeUpdateActionRef = yield* Ref.make<Option.Option<UpdateAction>>(Option.none()); |
| 356 | const finishedUpdateActions = yield* PubSub.unbounded<UpdateAction>(); | 376 | const finishedUpdateActions = yield* PubSub.unbounded<UpdateAction>(); |
| 357 | const updaterConfiguredRef = yield* Ref.make(false); | 377 | const updaterConfiguredRef = yield* Ref.make(false); |
| 358 | const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); | 378 | const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); |
| 359 | const notAppliedVersionRef = yield* Ref.make(Option.none<string>()); | 379 | const notAppliedVersionRef = yield* Ref.make(Option.none<string>()); |
| 360 | const updateStateRef = yield* Ref.make<DesktopUpdateState>( | 380 | const updateStateRef = yield* Ref.make<DesktopUpdateState>({ |
| 361 | createInitialDesktopUpdateState( | 381 | ...createInitialDesktopUpdateState( |
| 362 | environment.appVersion, | 382 | environment.appVersion, |
| 363 | environment.runtimeInfo, | 383 | environment.runtimeInfo, |
| 364 | environment.defaultDesktopSettings.updateChannel, | 384 | environment.defaultDesktopSettings.updateChannel, |
| 365 | ), | 385 | ), |
| 366 | ); | 386 | uiVersion: yield* localUi.currentVersion, |
| 387 | }); | ||
| 367 | 388 | ||
| 368 | const stateChanges = yield* PubSub.sliding<DesktopUpdateState>(16); | 389 | const stateChanges = yield* PubSub.sliding<DesktopUpdateState>(16); |
| 369 | // Makes ref writes + publishes atomic against subscribe, so a snapshot | 390 | // Makes ref writes + publishes atomic against subscribe, so a snapshot |
| ... | @@ -377,7 +398,11 @@ export const make = Effect.gen(function* () { | ... | @@ -377,7 +398,11 @@ export const make = Effect.gen(function* () { |
| 377 | const setState = (state: DesktopUpdateState): Effect.Effect<void> => | 398 | const setState = (state: DesktopUpdateState): Effect.Effect<void> => |
| 378 | stateMutex | 399 | stateMutex |
| 379 | .withPermits(1)( | 400 | .withPermits(1)( |
| 380 | Ref.set(updateStateRef, state).pipe(Effect.andThen(PubSub.publish(stateChanges, state))), | 401 | Effect.gen(function* () { |
| 402 | const next = { ...state, uiVersion: yield* localUi.currentVersion }; | ||
| 403 | yield* Ref.set(updateStateRef, next); | ||
| 404 | yield* PubSub.publish(stateChanges, next); | ||
| 405 | }), | ||
| 381 | ) | 406 | ) |
| 382 | .pipe(Effect.andThen(emitState)); | 407 | .pipe(Effect.andThen(emitState)); |
| 383 | 408 | ||
| ... | @@ -498,9 +523,50 @@ export const make = Effect.gen(function* () { | ... | @@ -498,9 +523,50 @@ export const make = Effect.gen(function* () { |
| 498 | yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); | 523 | yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); |
| 499 | yield* logUpdaterInfo("checking for updates", { reason }); | 524 | yield* logUpdaterInfo("checking for updates", { reason }); |
| 500 | 525 | ||
| 501 | return yield* electronUpdater.checkForUpdates.pipe( | 526 | return yield* Effect.gen(function* () { |
| 502 | Effect.as(true), | 527 | const directory = yield* forkUpdateDirectory; |
| 528 | if (directory) { | ||
| 529 | directory.searchParams.delete("ls"); | ||
| 530 | const release = yield* fork.check(directory); | ||
| 531 | const kind = yield* fork.kind(release); | ||
| 532 | const target = release.desktop[`${environment.platform}-${environment.processArch}`]!; | ||
| 533 | const effectiveVersion = state.uiVersion ?? state.currentVersion; | ||
| 534 | if ( | ||
| 535 | /^0\.0\.[0-9]+$/.test(effectiveVersion) && | ||
| 536 | compareSemverVersions(target.version, effectiveVersion) <= 0 | ||
| 537 | ) { | ||
| 538 | yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); | ||
| 539 | return true; | ||
| 540 | } | ||
| 541 | yield* Ref.set(forkRelease, { base: directory, release, kind }); | ||
| 542 | if (kind !== "shell") { | ||
| 543 | yield* setState({ | ||
| 544 | ...reduceDesktopUpdateStateOnUpdateAvailable(state, target.version, checkedAt), | ||
| 545 | installKind: kind === "ui" ? "ui" : "app", | ||
| 546 | }); | ||
| 547 | return true; | ||
| 548 | } | ||
| 549 | yield* electronUpdater.setFeedURL({ provider: "generic", url: directory.href }); | ||
| 550 | yield* electronUpdater.setChannel(target.app.version); | ||
| 551 | yield* electronUpdater.setAllowDowngrade(false); | ||
| 552 | } | ||
| 553 | yield* electronUpdater.checkForUpdates; | ||
| 554 | return true; | ||
| 555 | }).pipe( | ||
| 556 | Effect.mapError((error) => | ||
| 557 | error._tag === "ElectronUpdaterCheckForUpdatesError" || | ||
| 558 | error._tag === "DesktopForkUpdateError" | ||
| 559 | ? error | ||
| 560 | : new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({ | ||
| 561 | channel: state.channel, | ||
| 562 | cause: error, | ||
| 563 | }), | ||
| 564 | ), | ||
| 503 | Effect.catchTags({ | 565 | Effect.catchTags({ |
| 566 | DesktopForkUpdateError: (error) => | ||
| 567 | updateState((current) => | ||
| 568 | reduceDesktopUpdateStateOnCheckFailure(current, error.message, checkedAt), | ||
| 569 | ).pipe(Effect.as(true)), | ||
| 504 | ElectronUpdaterCheckForUpdatesError: Effect.fn( | 570 | ElectronUpdaterCheckForUpdatesError: Effect.fn( |
| 505 | "desktop.updates.handleCheckForUpdatesFailure", | 571 | "desktop.updates.handleCheckForUpdatesFailure", |
| 506 | )(function* (error) { | 572 | )(function* (error) { |
| ... | @@ -543,10 +609,27 @@ export const make = Effect.gen(function* () { | ... | @@ -543,10 +609,27 @@ export const make = Effect.gen(function* () { |
| 543 | isArm64HostRunningIntelBuild(environment.runtimeInfo), | 609 | isArm64HostRunningIntelBuild(environment.runtimeInfo), |
| 544 | ); | 610 | ); |
| 545 | yield* logUpdaterInfo("downloading update"); | 611 | yield* logUpdaterInfo("downloading update"); |
| 546 | yield* electronUpdater.downloadUpdate; | 612 | const candidate = yield* Ref.get(forkRelease); |
| 613 | const version = state.availableVersion; | ||
| 614 | let uiOnly = false; | ||
| 615 | if (candidate !== null && candidate.kind !== "shell") { | ||
| 616 | uiOnly = (yield* fork.prepare(candidate.base, candidate.release)) === "ui"; | ||
| 617 | } else { | ||
| 618 | yield* electronUpdater.downloadUpdate; | ||
| 619 | } | ||
| 620 | yield* updateState((current) => ({ | ||
| 621 | ...(version === null | ||
| 622 | ? current | ||
| 623 | : reduceDesktopUpdateStateOnDownloadComplete(current, version)), | ||
| 624 | installKind: uiOnly ? "ui" : "app", | ||
| 625 | })); | ||
| 547 | return { accepted: true, completed: true }; | 626 | return { accepted: true, completed: true }; |
| 548 | }).pipe( | 627 | }).pipe( |
| 549 | Effect.catchTags({ | 628 | Effect.catchTags({ |
| 629 | DesktopForkUpdateError: (error) => | ||
| 630 | updateState((current) => | ||
| 631 | reduceDesktopUpdateStateOnDownloadFailure(current, error.message), | ||
| 632 | ).pipe(Effect.as({ accepted: true, completed: false })), | ||
| 550 | ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")( | 633 | ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")( |
| 551 | function* (error) { | 634 | function* (error) { |
| 552 | yield* updateState((current) => | 635 | yield* updateState((current) => |
| ... | @@ -744,6 +827,37 @@ export const make = Effect.gen(function* () { | ... | @@ -744,6 +827,37 @@ export const make = Effect.gen(function* () { |
| 744 | return { accepted: false, completed: false, failed: false }; | 827 | return { accepted: false, completed: false, failed: false }; |
| 745 | } | 828 | } |
| 746 | 829 | ||
| 830 | const installing = yield* Ref.get(updateStateRef); | ||
| 831 | if ( | ||
| 832 | expectedVersion === undefined && | ||
| 833 | installing.installKind === "ui" && | ||
| 834 | installing.downloadedVersion !== null | ||
| 835 | ) { | ||
| 836 | return yield* fork.installUi(installing.downloadedVersion).pipe( | ||
| 837 | Effect.flatMap((applied) => | ||
| 838 | applied | ||
| 839 | ? updateState((current) => ({ | ||
| 840 | ...current, | ||
| 841 | status: "up-to-date", | ||
| 842 | availableVersion: null, | ||
| 843 | downloadedVersion: null, | ||
| 844 | installKind: "app", | ||
| 845 | downloadPercent: null, | ||
| 846 | message: null, | ||
| 847 | errorContext: null, | ||
| 848 | canRetry: false, | ||
| 849 | })).pipe(Effect.as({ accepted: true, completed: true, failed: false })) | ||
| 850 | : Effect.succeed({ accepted: false, completed: false, failed: false }), | ||
| 851 | ), | ||
| 852 | Effect.catch((error) => | ||
| 853 | updateState((current) => | ||
| 854 | reduceDesktopUpdateStateOnInstallFailure(current, error.message), | ||
| 855 | ).pipe(Effect.as({ accepted: true, completed: false, failed: true })), | ||
| 856 | ), | ||
| 857 | Effect.ensuring(finishUpdateAction("install")), | ||
| 858 | ); | ||
| 859 | } | ||
| 860 | |||
| 747 | yield* Ref.set(desktopState.quitting, true); | 861 | yield* Ref.set(desktopState.quitting, true); |
| 748 | 862 | ||
| 749 | return yield* Effect.gen(function* () { | 863 | return yield* Effect.gen(function* () { |
| ... | @@ -763,13 +877,26 @@ export const make = Effect.gen(function* () { | ... | @@ -763,13 +877,26 @@ export const make = Effect.gen(function* () { |
| 763 | (instance) => instance.stop({ timeout: Duration.seconds(5) }), | 877 | (instance) => instance.stop({ timeout: Duration.seconds(5) }), |
| 764 | { concurrency: "unbounded" }, | 878 | { concurrency: "unbounded" }, |
| 765 | ); | 879 | ); |
| 766 | yield* electronUpdater.quitAndInstall({ | 880 | const candidate = yield* Ref.get(forkRelease); |
| 767 | isSilent: true, | 881 | if (candidate?.kind === "code" && downloadedVersion !== null) { |
| 768 | isForceRunAfter: true, | 882 | if (!(yield* fork.activateCode(downloadedVersion))) { |
| 769 | }); | 883 | return yield* new DesktopForkUpdates.DesktopForkUpdateError({ |
| 884 | message: "The update is no longer prepared. Download it again.", | ||
| 885 | cause: downloadedVersion, | ||
| 886 | }); | ||
| 887 | } | ||
| 888 | yield* electronApp.relaunch({}); | ||
| 889 | yield* electronApp.quit; | ||
| 890 | } else { | ||
| 891 | yield* electronUpdater.quitAndInstall({ isSilent: true, isForceRunAfter: true }); | ||
| 892 | } | ||
| 770 | return { accepted: true, completed: false, failed: false }; | 893 | return { accepted: true, completed: false, failed: false }; |
| 771 | }).pipe( | 894 | }).pipe( |
| 772 | Effect.catchTags({ | 895 | Effect.catchTags({ |
| 896 | DesktopForkUpdateError: (error) => | ||
| 897 | recoverFailedInstall(error.message).pipe( | ||
| 898 | Effect.as({ accepted: true, completed: false, failed: true }), | ||
| 899 | ), | ||
| 773 | ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( | 900 | ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( |
| 774 | function* (error) { | 901 | function* (error) { |
| 775 | yield* recoverFailedInstall(error.message); | 902 | yield* recoverFailedInstall(error.message); |
| ... | @@ -827,6 +954,11 @@ export const make = Effect.gen(function* () { | ... | @@ -827,6 +954,11 @@ export const make = Effect.gen(function* () { |
| 827 | const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () { | 954 | const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () { |
| 828 | yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( | 955 | yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( |
| 829 | Effect.andThen(checkForUpdates("startup")), | 956 | Effect.andThen(checkForUpdates("startup")), |
| 957 | Effect.andThen( | ||
| 958 | Effect.gen(function* () { | ||
| 959 | if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate; | ||
| 960 | }), | ||
| 961 | ), | ||
| 830 | Effect.catchCause((cause) => { | 962 | Effect.catchCause((cause) => { |
| 831 | if (Cause.hasInterruptsOnly(cause)) { | 963 | if (Cause.hasInterruptsOnly(cause)) { |
| 832 | return Effect.void; | 964 | return Effect.void; |
| ... | @@ -842,6 +974,11 @@ export const make = Effect.gen(function* () { | ... | @@ -842,6 +974,11 @@ export const make = Effect.gen(function* () { |
| 842 | ); | 974 | ); |
| 843 | yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe( | 975 | yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe( |
| 844 | Effect.andThen(checkForUpdates("poll")), | 976 | Effect.andThen(checkForUpdates("poll")), |
| 977 | Effect.andThen( | ||
| 978 | Effect.gen(function* () { | ||
| 979 | if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate; | ||
| 980 | }), | ||
| 981 | ), | ||
| 845 | Effect.forever, | 982 | Effect.forever, |
| 846 | Effect.catchCause((cause) => { | 983 | Effect.catchCause((cause) => { |
| 847 | if (Cause.hasInterruptsOnly(cause)) { | 984 | if (Cause.hasInterruptsOnly(cause)) { |
| ... | @@ -865,7 +1002,10 @@ export const make = Effect.gen(function* () { | ... | @@ -865,7 +1002,10 @@ export const make = Effect.gen(function* () { |
| 865 | Effect.flatMap( | 1002 | Effect.flatMap( |
| 866 | Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) { | 1003 | Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) { |
| 867 | const state = yield* Ref.get(updateStateRef); | 1004 | const state = yield* Ref.get(updateStateRef); |
| 868 | if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) { | 1005 | if ( |
| 1006 | !(yield* forkUpdateDirectory) && | ||
| 1007 | resolveDefaultDesktopUpdateChannel(info.version) !== state.channel | ||
| 1008 | ) { | ||
| 869 | yield* logUpdaterInfo("ignoring update that does not match selected channel", { | 1009 | yield* logUpdaterInfo("ignoring update that does not match selected channel", { |
| 870 | version: info.version, | 1010 | version: info.version, |
| 871 | channel: state.channel, | 1011 | channel: state.channel, |
| ... | @@ -877,6 +1017,14 @@ export const make = Effect.gen(function* () { | ... | @@ -877,6 +1017,14 @@ export const make = Effect.gen(function* () { |
| 877 | } | 1017 | } |
| 878 | 1018 | ||
| 879 | const checkedAt = yield* currentIsoTimestamp; | 1019 | const checkedAt = yield* currentIsoTimestamp; |
| 1020 | if ( | ||
| 1021 | state.uiVersion && | ||
| 1022 | state.uiVersion !== state.currentVersion && | ||
| 1023 | compareSemverVersions(info.version, state.uiVersion) <= 0 | ||
| 1024 | ) { | ||
| 1025 | yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); | ||
| 1026 | return; | ||
| 1027 | } | ||
| 880 | const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( | 1028 | const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( |
| 881 | info.releaseNotes, | 1029 | info.releaseNotes, |
| 882 | info.version, | 1030 | info.version, |
| ... | @@ -1002,6 +1150,12 @@ export const make = Effect.gen(function* () { | ... | @@ -1002,6 +1150,12 @@ export const make = Effect.gen(function* () { |
| 1002 | Effect.flatMap( | 1150 | Effect.flatMap( |
| 1003 | Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) { | 1151 | Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) { |
| 1004 | const state = yield* Ref.get(updateStateRef); | 1152 | const state = yield* Ref.get(updateStateRef); |
| 1153 | if ( | ||
| 1154 | state.uiVersion && | ||
| 1155 | state.uiVersion !== state.currentVersion && | ||
| 1156 | compareSemverVersions(info.version, state.uiVersion) <= 0 | ||
| 1157 | ) | ||
| 1158 | return; | ||
| 1005 | yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); | 1159 | yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); |
| 1006 | yield* logUpdaterInfo("update downloaded", { version: info.version }); | 1160 | yield* logUpdaterInfo("update downloaded", { version: info.version }); |
| 1007 | }), | 1161 | }), |
| ... | @@ -1157,6 +1311,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1157,6 +1311,7 @@ export const make = Effect.gen(function* () { |
| 1157 | }; | 1311 | }; |
| 1158 | } | 1312 | } |
| 1159 | const checked = yield* checkForUpdates(reason); | 1313 | const checked = yield* checkForUpdates(reason); |
| 1314 | if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate; | ||
| 1160 | return { | 1315 | return { |
| 1161 | checked, | 1316 | checked, |
| 1162 | state: yield* Ref.get(updateStateRef), | 1317 | state: yield* Ref.get(updateStateRef), |
apps/desktop/src/updates/forkRuntime.test.ts created+86| ... | @@ -0,0 +1,86 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - The fixture verifies synchronous bootstrap selection on disk. | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodeOS from "node:os"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | import { afterEach, beforeEach, expect, it } from "vite-plus/test"; | ||
| 6 | import { readActiveForkRuntime } from "./forkRuntime.ts"; | ||
| 7 | |||
| 8 | let home: string; | ||
| 9 | let bundled: string; | ||
| 10 | let releases: string; | ||
| 11 | const shell = "a".repeat(64); | ||
| 12 | const pointer = { | ||
| 13 | directory: "release-abc123", | ||
| 14 | version: "0.0.2000000000", | ||
| 15 | shell, | ||
| 16 | runtime: "b".repeat(64), | ||
| 17 | }; | ||
| 18 | |||
| 19 | beforeEach(() => { | ||
| 20 | home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "knife-bootstrap-")); | ||
| 21 | bundled = NodePath.join(home, "bundle"); | ||
| 22 | releases = NodePath.join(home, "fork-releases"); | ||
| 23 | NodeFS.mkdirSync(bundled); | ||
| 24 | NodeFS.mkdirSync(releases); | ||
| 25 | NodeFS.writeFileSync( | ||
| 26 | NodePath.join(bundled, "knife-runtime.json"), | ||
| 27 | JSON.stringify({ shell, version: "0.0.1999999999" }), | ||
| 28 | ); | ||
| 29 | for (const file of [ | ||
| 30 | "apps/desktop/dist-electron/main.cjs", | ||
| 31 | "apps/desktop/dist-electron/compileCache.cjs", | ||
| 32 | "apps/desktop/dist-electron/preload.cjs", | ||
| 33 | "apps/server/dist/bin.mjs", | ||
| 34 | "apps/server/dist/client/index.html", | ||
| 35 | ]) { | ||
| 36 | const target = NodePath.join(releases, pointer.directory, file); | ||
| 37 | NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true }); | ||
| 38 | NodeFS.writeFileSync(target, "fixture"); | ||
| 39 | } | ||
| 40 | NodeFS.writeFileSync(NodePath.join(releases, "current.json"), JSON.stringify(pointer)); | ||
| 41 | }); | ||
| 42 | afterEach(() => NodeFS.rmSync(home, { recursive: true, force: true })); | ||
| 43 | |||
| 44 | it("selects the fully staged runtime matching the signed shell", () => { | ||
| 45 | expect(readActiveForkRuntime(home, bundled)).toEqual({ | ||
| 46 | ...pointer, | ||
| 47 | directory: NodePath.join(releases, pointer.directory), | ||
| 48 | }); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it.each([ | ||
| 52 | { ...pointer, shell: "c".repeat(64) }, | ||
| 53 | { ...pointer, directory: "../outside" }, | ||
| 54 | { ...pointer, runtime: "malformed" }, | ||
| 55 | ])("ignores an incompatible or malformed active pointer", (invalid) => { | ||
| 56 | NodeFS.writeFileSync(NodePath.join(releases, "current.json"), JSON.stringify(invalid)); | ||
| 57 | expect(readActiveForkRuntime(home, bundled)).toBeUndefined(); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it.each([ | ||
| 61 | "apps/server/dist/client/index.html", | ||
| 62 | "apps/desktop/dist-electron/compileCache.cjs", | ||
| 63 | "apps/desktop/dist-electron/preload.cjs", | ||
| 64 | ])("keeps the bundled app when the staged runtime is missing %s", (file) => { | ||
| 65 | NodeFS.unlinkSync(NodePath.join(releases, pointer.directory, file)); | ||
| 66 | expect(readActiveForkRuntime(home, bundled)).toBeUndefined(); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("prefers a newer bundled release even when an older pointer uses the same shell", () => { | ||
| 70 | NodeFS.writeFileSync( | ||
| 71 | NodePath.join(bundled, "knife-runtime.json"), | ||
| 72 | JSON.stringify({ shell, version: "0.0.2000000001" }), | ||
| 73 | ); | ||
| 74 | expect(readActiveForkRuntime(home, bundled)).toBeUndefined(); | ||
| 75 | }); | ||
| 76 | |||
| 77 | it("requires the separate Windows server tree before selecting its runtime", () => { | ||
| 78 | expect(readActiveForkRuntime(home, bundled, "win32")).toBeUndefined(); | ||
| 79 | const directory = NodePath.join(releases, pointer.directory); | ||
| 80 | NodeFS.mkdirSync(NodePath.join(directory, "server/apps"), { recursive: true }); | ||
| 81 | NodeFS.renameSync( | ||
| 82 | NodePath.join(directory, "apps/server"), | ||
| 83 | NodePath.join(directory, "server/apps/server"), | ||
| 84 | ); | ||
| 85 | expect(readActiveForkRuntime(home, bundled, "win32")?.version).toBe(pointer.version); | ||
| 86 | }); | ||
apps/desktop/src/updates/forkRuntime.ts created+75| ... | @@ -0,0 +1,75 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Bootstrap metadata is read synchronously before the main bundle loads. | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodePath from "node:path"; | ||
| 4 | |||
| 5 | export interface ActiveForkRuntime { | ||
| 6 | readonly directory: string; | ||
| 7 | readonly version: string; | ||
| 8 | readonly shell: string; | ||
| 9 | readonly runtime: string; | ||
| 10 | } | ||
| 11 | |||
| 12 | export function readActiveForkRuntime( | ||
| 13 | stateDir: string, | ||
| 14 | bundledRoot: string, | ||
| 15 | // oxlint-disable-next-line t3code/no-global-process-runtime -- The synchronous bootstrap runs before Effect services exist. | ||
| 16 | platform: NodeJS.Platform = process.platform, | ||
| 17 | ): ActiveForkRuntime | undefined { | ||
| 18 | try { | ||
| 19 | const baseline: unknown = JSON.parse( | ||
| 20 | NodeFS.readFileSync(NodePath.join(bundledRoot, "knife-runtime.json"), "utf8"), | ||
| 21 | ); | ||
| 22 | const active: unknown = JSON.parse( | ||
| 23 | NodeFS.readFileSync(NodePath.join(stateDir, "fork-releases", "current.json"), "utf8"), | ||
| 24 | ); | ||
| 25 | if ( | ||
| 26 | typeof baseline !== "object" || | ||
| 27 | baseline === null || | ||
| 28 | !("shell" in baseline) || | ||
| 29 | typeof active !== "object" || | ||
| 30 | active === null || | ||
| 31 | !("directory" in active) || | ||
| 32 | typeof active.directory !== "string" || | ||
| 33 | !("version" in active) || | ||
| 34 | typeof active.version !== "string" || | ||
| 35 | !("shell" in active) || | ||
| 36 | active.shell !== baseline.shell || | ||
| 37 | typeof active.shell !== "string" || | ||
| 38 | !("runtime" in active) || | ||
| 39 | typeof active.runtime !== "string" || | ||
| 40 | !/^[a-f0-9]{64}$/.test(active.runtime) || | ||
| 41 | !/^release-[A-Za-z0-9-]+$/.test(active.directory) | ||
| 42 | ) | ||
| 43 | return; | ||
| 44 | |||
| 45 | const bundledVersion = | ||
| 46 | "version" in baseline && typeof baseline.version === "string" ? baseline.version : undefined; | ||
| 47 | if ( | ||
| 48 | bundledVersion !== undefined && | ||
| 49 | /^0\.0\.[0-9]+$/.test(bundledVersion) && | ||
| 50 | /^0\.0\.[0-9]+$/.test(active.version) && | ||
| 51 | BigInt(active.version.slice(4)) < BigInt(bundledVersion.slice(4)) | ||
| 52 | ) | ||
| 53 | return; | ||
| 54 | |||
| 55 | const directory = NodePath.join(stateDir, "fork-releases", active.directory); | ||
| 56 | const serverRoot = platform === "win32" ? "server" : "."; | ||
| 57 | for (const file of [ | ||
| 58 | "apps/desktop/dist-electron/main.cjs", | ||
| 59 | "apps/desktop/dist-electron/compileCache.cjs", | ||
| 60 | "apps/desktop/dist-electron/preload.cjs", | ||
| 61 | NodePath.join(serverRoot, "apps/server/dist/bin.mjs"), | ||
| 62 | NodePath.join(serverRoot, "apps/server/dist/client/index.html"), | ||
| 63 | ]) { | ||
| 64 | if (!NodeFS.statSync(NodePath.join(directory, file)).isFile()) return; | ||
| 65 | } | ||
| 66 | return { directory, version: active.version, shell: active.shell, runtime: active.runtime }; | ||
| 67 | } catch { | ||
| 68 | return; | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | export function activeForkRuntime(): ActiveForkRuntime | undefined { | ||
| 73 | const raw = process.env.T3CODE_DESKTOP_PAYLOAD; | ||
| 74 | return raw === undefined ? undefined : JSON.parse(raw); | ||
| 75 | } | ||
apps/desktop/src/updates/updateMachine.ts+1| ... | @@ -99,6 +99,7 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( | ... | @@ -99,6 +99,7 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( |
| 99 | ...state, | 99 | ...state, |
| 100 | status: isDownloadedVersion ? "downloaded" : "available", | 100 | status: isDownloadedVersion ? "downloaded" : "available", |
| 101 | availableVersion: version, | 101 | availableVersion: version, |
| 102 | installKind: isDownloadedVersion ? (state.installKind ?? "app") : "app", | ||
| 102 | downloadedVersion: isDownloadedVersion ? version : null, | 103 | downloadedVersion: isDownloadedVersion ? version : null, |
| 103 | releaseNotes: preserveReleaseNotes ? state.releaseNotes : releaseNotes, | 104 | releaseNotes: preserveReleaseNotes ? state.releaseNotes : releaseNotes, |
| 104 | omittedReleaseCount: preserveReleaseNotes ? state.omittedReleaseCount : omittedReleaseCount, | 105 | omittedReleaseCount: preserveReleaseNotes ? state.omittedReleaseCount : omittedReleaseCount, |
apps/desktop/src/updates/updatesTestHarness.ts+105-2| ... | @@ -5,15 +5,20 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -5,15 +5,20 @@ import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as PlatformError from "effect/PlatformError"; | 5 | import * as PlatformError from "effect/PlatformError"; |
| 6 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 8 | 9 | ||
| 9 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; | 10 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; |
| 10 | import * as DesktopConfig from "../app/DesktopConfig.ts"; | 11 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 11 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 12 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 13 | import * as ElectronApp from "../electron/ElectronApp.ts"; | ||
| 14 | import * as DesktopForkUpdates from "./DesktopForkUpdates.ts"; | ||
| 15 | import { ForkReleaseManifest } from "@t3tools/shared/forkUpdateDirectory"; | ||
| 12 | import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; | 16 | import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; |
| 13 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | 17 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 14 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | 18 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 15 | import * as DesktopState from "../app/DesktopState.ts"; | 19 | import * as DesktopState from "../app/DesktopState.ts"; |
| 16 | import * as DesktopUpdates from "./DesktopUpdates.ts"; | 20 | import * as DesktopUpdates from "./DesktopUpdates.ts"; |
| 21 | import * as DesktopLocalUi from "./DesktopLocalUi.ts"; | ||
| 17 | 22 | ||
| 18 | /** Shared DesktopUpdates test harness: a fully stubbed updater layer whose | 23 | /** Shared DesktopUpdates test harness: a fully stubbed updater layer whose |
| 19 | electron-updater events are driven by hand via `emit`. Used by | 24 | electron-updater events are driven by hand via `emit`. Used by |
| ... | @@ -30,6 +35,8 @@ export interface UpdatesHarnessOptions { | ... | @@ -30,6 +35,8 @@ export interface UpdatesHarnessOptions { |
| 30 | readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; | 35 | readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; |
| 31 | readonly setDisableDifferentialDownload?: Effect.Effect<void>; | 36 | readonly setDisableDifferentialDownload?: Effect.Effect<void>; |
| 32 | readonly downloadUpdate?: Effect.Effect<void>; | 37 | readonly downloadUpdate?: Effect.Effect<void>; |
| 38 | readonly uiOnly?: boolean; | ||
| 39 | readonly uiVersion?: string; | ||
| 33 | readonly quitAndInstall?: Effect.Effect<void, ElectronUpdater.ElectronUpdaterQuitAndInstallError>; | 40 | readonly quitAndInstall?: Effect.Effect<void, ElectronUpdater.ElectronUpdaterQuitAndInstallError>; |
| 34 | readonly stopBackend?: Effect.Effect<void>; | 41 | readonly stopBackend?: Effect.Effect<void>; |
| 35 | readonly startBackend?: Effect.Effect<void>; | 42 | readonly startBackend?: Effect.Effect<void>; |
| ... | @@ -39,6 +46,9 @@ export interface UpdatesHarnessOptions { | ... | @@ -39,6 +46,9 @@ export interface UpdatesHarnessOptions { |
| 39 | readonly packageType?: string | undefined; | 46 | readonly packageType?: string | undefined; |
| 40 | /** Contents of the install record a previous run left behind. */ | 47 | /** Contents of the install record a previous run left behind. */ |
| 41 | readonly installRecord?: string | undefined; | 48 | readonly installRecord?: string | undefined; |
| 49 | readonly appUpdateYml?: string; | ||
| 50 | readonly releaseManifest?: () => typeof ForkReleaseManifest.Type; | ||
| 51 | readonly forkKind?: "ui" | "code" | "shell"; | ||
| 42 | } | 52 | } |
| 43 | 53 | ||
| 44 | export function makeHarness(options: UpdatesHarnessOptions = {}) { | 54 | export function makeHarness(options: UpdatesHarnessOptions = {}) { |
| ... | @@ -48,6 +58,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { | ... | @@ -48,6 +58,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 48 | let allowDowngrade = false; | 58 | let allowDowngrade = false; |
| 49 | let fullChangelog = false; | 59 | let fullChangelog = false; |
| 50 | const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; | 60 | const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; |
| 61 | const channels: string[] = []; | ||
| 51 | const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>(); | 62 | const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>(); |
| 52 | const sentStates: DesktopUpdateState[] = []; | 63 | const sentStates: DesktopUpdateState[] = []; |
| 53 | const installSteps: string[] = []; | 64 | const installSteps: string[] = []; |
| ... | @@ -76,7 +87,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { | ... | @@ -76,7 +87,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 76 | }), | 87 | }), |
| 77 | setAutoDownload: () => Effect.void, | 88 | setAutoDownload: () => Effect.void, |
| 78 | setAutoInstallOnAppQuit: () => Effect.void, | 89 | setAutoInstallOnAppQuit: () => Effect.void, |
| 79 | setChannel: () => Effect.void, | 90 | setChannel: (channel) => |
| 91 | Effect.sync(() => { | ||
| 92 | channels.push(channel); | ||
| 93 | }), | ||
| 80 | setAllowPrerelease: () => Effect.void, | 94 | setAllowPrerelease: () => Effect.void, |
| 81 | allowDowngrade: Effect.sync(() => allowDowngrade), | 95 | allowDowngrade: Effect.sync(() => allowDowngrade), |
| 82 | setAllowDowngrade: (value) => | 96 | setAllowDowngrade: (value) => |
| ... | @@ -93,7 +107,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { | ... | @@ -93,7 +107,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 93 | }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), | 107 | }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), |
| 94 | downloadUpdate: Effect.sync(() => { | 108 | downloadUpdate: Effect.sync(() => { |
| 95 | downloadCount += 1; | 109 | downloadCount += 1; |
| 96 | }).pipe(Effect.andThen(options.downloadUpdate ?? Effect.void)), | 110 | }).pipe( |
| 111 | Effect.andThen(options.downloadUpdate ?? Effect.void), | ||
| 112 | Effect.as(["/download/update.zip"]), | ||
| 113 | ), | ||
| 97 | quitAndInstall: () => | 114 | quitAndInstall: () => |
| 98 | Effect.sync(() => { | 115 | Effect.sync(() => { |
| 99 | quitAndInstallCount += 1; | 116 | quitAndInstallCount += 1; |
| ... | @@ -224,6 +241,14 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { | ... | @@ -224,6 +241,14 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 224 | ? (installRecords.get(path) ?? previousInstallRecord) | 241 | ? (installRecords.get(path) ?? previousInstallRecord) |
| 225 | : undefined; | 242 | : undefined; |
| 226 | if (installRecord !== undefined) return Effect.succeed(installRecord); | 243 | if (installRecord !== undefined) return Effect.succeed(installRecord); |
| 244 | if ( | ||
| 245 | path === "/missing/resources/app-update.yml" && | ||
| 246 | (options.appUpdateYml !== undefined || options.uiOnly) | ||
| 247 | ) | ||
| 248 | return Effect.succeed( | ||
| 249 | options.appUpdateYml ?? | ||
| 250 | "provider: generic\nurl: https://updates.example.test/fork/?ls=\n", | ||
| 251 | ); | ||
| 227 | return path === "/missing/resources/package-type" && options.packageType !== undefined | 252 | return path === "/missing/resources/package-type" && options.packageType !== undefined |
| 228 | ? Effect.succeed(options.packageType) | 253 | ? Effect.succeed(options.packageType) |
| 229 | : Effect.fail( | 254 | : Effect.fail( |
| ... | @@ -249,7 +274,62 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { | ... | @@ -249,7 +274,62 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 249 | }), | 274 | }), |
| 250 | }); | 275 | }); |
| 251 | 276 | ||
| 277 | const uiInstalls: string[] = []; | ||
| 278 | let uiVersion = options.uiVersion ?? "1.2.3"; | ||
| 252 | const layer = DesktopUpdates.layer.pipe( | 279 | const layer = DesktopUpdates.layer.pipe( |
| 280 | Layer.provide( | ||
| 281 | Layer.succeed(DesktopLocalUi.DesktopLocalUi, { | ||
| 282 | currentVersion: Effect.sync(() => uiVersion), | ||
| 283 | assetDirectories: Effect.succeed(["/client"] as const), | ||
| 284 | apply: () => Effect.void, | ||
| 285 | reset: Effect.void, | ||
| 286 | }), | ||
| 287 | ), | ||
| 288 | Layer.provide( | ||
| 289 | Layer.succeed(DesktopForkUpdates.DesktopForkUpdates, { | ||
| 290 | check: () => Effect.sync(() => options.releaseManifest?.() ?? forkReleaseFixture("1.2.4")), | ||
| 291 | kind: () => Effect.succeed(options.forkKind ?? (options.uiOnly ? "ui" : "code")), | ||
| 292 | prepare: () => Effect.succeed(options.forkKind ?? (options.uiOnly ? "ui" : "code")), | ||
| 293 | installUi: (version) => | ||
| 294 | Effect.sync(() => { | ||
| 295 | uiInstalls.push(version); | ||
| 296 | uiVersion = version; | ||
| 297 | return true; | ||
| 298 | }), | ||
| 299 | activateCode: () => | ||
| 300 | Effect.sync(() => { | ||
| 301 | installSteps.push("activateCode"); | ||
| 302 | return true; | ||
| 303 | }), | ||
| 304 | }), | ||
| 305 | ), | ||
| 306 | Layer.provide( | ||
| 307 | Layer.succeed(ElectronApp.ElectronApp, { | ||
| 308 | ...ElectronApp.make, | ||
| 309 | relaunch: () => | ||
| 310 | Effect.sync(() => { | ||
| 311 | installSteps.push("relaunch"); | ||
| 312 | }), | ||
| 313 | quit: Effect.sync(() => { | ||
| 314 | installSteps.push("quit"); | ||
| 315 | }), | ||
| 316 | }), | ||
| 317 | ), | ||
| 318 | Layer.provide( | ||
| 319 | Layer.succeed( | ||
| 320 | HttpClient.HttpClient, | ||
| 321 | HttpClient.make((request) => | ||
| 322 | Effect.succeed( | ||
| 323 | HttpClientResponse.fromWeb( | ||
| 324 | request, | ||
| 325 | new Response( | ||
| 326 | JSON.stringify(options.releaseManifest?.() ?? forkReleaseFixture("1.2.4")), | ||
| 327 | ), | ||
| 328 | ), | ||
| 329 | ), | ||
| 330 | ), | ||
| 331 | ), | ||
| 332 | ), | ||
| 253 | Layer.provide(layerFileSystem), | 333 | Layer.provide(layerFileSystem), |
| 254 | Layer.provideMerge(layerUpdater), | 334 | Layer.provideMerge(layerUpdater), |
| 255 | Layer.provideMerge(layerWindow), | 335 | Layer.provideMerge(layerWindow), |
| ... | @@ -273,12 +353,14 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { | ... | @@ -273,12 +353,14 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 273 | checkCount: () => checkCount, | 353 | checkCount: () => checkCount, |
| 274 | quitAndInstalls: () => quitAndInstallCount, | 354 | quitAndInstalls: () => quitAndInstallCount, |
| 275 | installSteps, | 355 | installSteps, |
| 356 | uiInstalls, | ||
| 276 | updateRestartMarkers, | 357 | updateRestartMarkers, |
| 277 | installRecords, | 358 | installRecords, |
| 278 | /** The record the test started with, until the app removes it. */ | 359 | /** The record the test started with, until the app removes it. */ |
| 279 | previousInstallRecord: () => previousInstallRecord, | 360 | previousInstallRecord: () => previousInstallRecord, |
| 280 | downloadCount: () => downloadCount, | 361 | downloadCount: () => downloadCount, |
| 281 | feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls, | 362 | feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls, |
| 363 | channels, | ||
| 282 | fullChangelog: () => fullChangelog, | 364 | fullChangelog: () => fullChangelog, |
| 283 | listenerCount: () => | 365 | listenerCount: () => |
| 284 | Array.from(listeners.values()).reduce( | 366 | Array.from(listeners.values()).reduce( |
| ... | @@ -293,3 +375,24 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { | ... | @@ -293,3 +375,24 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 293 | }, | 375 | }, |
| 294 | }; | 376 | }; |
| 295 | } | 377 | } |
| 378 | |||
| 379 | export function forkReleaseFixture(version: string): typeof ForkReleaseManifest.Type { | ||
| 380 | const artifact = { file: "fixture.tar.gz", size: 1, sha256: "a".repeat(64) }; | ||
| 381 | return { | ||
| 382 | version, | ||
| 383 | ui: artifact, | ||
| 384 | assets: artifact, | ||
| 385 | server: { ...artifact, version, runtime: "b".repeat(64) }, | ||
| 386 | desktop: { | ||
| 387 | "darwin-x64": { | ||
| 388 | version, | ||
| 389 | ui: artifact, | ||
| 390 | assets: artifact, | ||
| 391 | shell: "c".repeat(64), | ||
| 392 | runtime: "d".repeat(64), | ||
| 393 | code: artifact, | ||
| 394 | app: { ...artifact, version }, | ||
| 395 | }, | ||
| 396 | }, | ||
| 397 | }; | ||
| 398 | } |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+39| ... | @@ -13,6 +13,7 @@ import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts"; | ... | @@ -13,6 +13,7 @@ import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts"; |
| 13 | import * as DesktopConfig from "../app/DesktopConfig.ts"; | 13 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 14 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 14 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 15 | import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; | 15 | import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; |
| 16 | import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts"; | ||
| 16 | import * as DesktopWindow from "./DesktopWindow.ts"; | 17 | import * as DesktopWindow from "./DesktopWindow.ts"; |
| 17 | 18 | ||
| 18 | const environmentInput = { | 19 | const environmentInput = { |
| ... | @@ -106,6 +107,12 @@ const configureMenu = ( | ... | @@ -106,6 +107,12 @@ const configureMenu = ( |
| 106 | selectedAction: Deferred.Deferred<string>, | 107 | selectedAction: Deferred.Deferred<string>, |
| 107 | applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>, | 108 | applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>, |
| 108 | environment: Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> = {}, | 109 | environment: Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> = {}, |
| 110 | localUi: DesktopLocalUi.DesktopLocalUi["Service"] = { | ||
| 111 | assetDirectories: Effect.die("unexpected asset read"), | ||
| 112 | currentVersion: Effect.succeed("1.0.0"), | ||
| 113 | apply: () => Effect.die("unexpected local UI update"), | ||
| 114 | reset: Effect.die("unexpected local UI reset"), | ||
| 115 | }, | ||
| 109 | ) => | 116 | ) => |
| 110 | Effect.gen(function* () { | 117 | Effect.gen(function* () { |
| 111 | const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; | 118 | const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; |
| ... | @@ -116,6 +123,7 @@ const configureMenu = ( | ... | @@ -116,6 +123,7 @@ const configureMenu = ( |
| 116 | Layer.provideMerge(layerElectronMenu(applicationMenuTemplate)), | 123 | Layer.provideMerge(layerElectronMenu(applicationMenuTemplate)), |
| 117 | Layer.provideMerge(layerDesktopWindow(selectedAction)), | 124 | Layer.provideMerge(layerDesktopWindow(selectedAction)), |
| 118 | Layer.provideMerge(layerDesktopUpdates), | 125 | Layer.provideMerge(layerDesktopUpdates), |
| 126 | Layer.provideMerge(Layer.succeed(DesktopLocalUi.DesktopLocalUi, localUi)), | ||
| 119 | Layer.provideMerge(layerElectronDialog), | 127 | Layer.provideMerge(layerElectronDialog), |
| 120 | Layer.provideMerge(layerElectronApp), | 128 | Layer.provideMerge(layerElectronApp), |
| 121 | Layer.provideMerge( | 129 | Layer.provideMerge( |
| ... | @@ -128,6 +136,37 @@ const configureMenu = ( | ... | @@ -128,6 +136,37 @@ const configureMenu = ( |
| 128 | ); | 136 | ); |
| 129 | 137 | ||
| 130 | describe("DesktopApplicationMenu", () => { | 138 | describe("DesktopApplicationMenu", () => { |
| 139 | it.effect.each([["Use Bundled UI", "reset"]] as const)( | ||
| 140 | "runs %s without needing a working renderer", | ||
| 141 | ([label, operation]) => | ||
| 142 | Effect.gen(function* () { | ||
| 143 | const selectedAction = yield* Deferred.make<string>(); | ||
| 144 | const templateReady = | ||
| 145 | yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>(); | ||
| 146 | const steps: string[] = []; | ||
| 147 | yield* configureMenu( | ||
| 148 | selectedAction, | ||
| 149 | templateReady, | ||
| 150 | {}, | ||
| 151 | { | ||
| 152 | assetDirectories: Effect.die("unexpected asset read"), | ||
| 153 | currentVersion: Effect.succeed("1.0.0"), | ||
| 154 | apply: () => Effect.die("unexpected local UI update"), | ||
| 155 | reset: Effect.sync(() => { | ||
| 156 | steps.push("reset"); | ||
| 157 | }), | ||
| 158 | }, | ||
| 159 | ); | ||
| 160 | const template = yield* Deferred.await(templateReady); | ||
| 161 | const view = template.find((item) => item.label === "View"); | ||
| 162 | if (!Array.isArray(view?.submenu)) throw new Error("Missing View menu"); | ||
| 163 | const item = view.submenu.find((entry) => entry.label === label); | ||
| 164 | if (!item?.click) throw new Error(`Missing ${label} menu action`); | ||
| 165 | item.click({} as Electron.MenuItem, undefined, {} as KeyboardEvent); | ||
| 166 | assert.equal(yield* Deferred.await(selectedAction), "main-forceReload"); | ||
| 167 | assert.deepEqual(steps, [operation]); | ||
| 168 | }), | ||
| 169 | ); | ||
| 131 | it.effect("keeps display branding in the macOS application menu", () => | 170 | it.effect("keeps display branding in the macOS application menu", () => |
| 132 | Effect.gen(function* () { | 171 | Effect.gen(function* () { |
| 133 | const selectedAction = yield* Deferred.make<string>(); | 172 | const selectedAction = yield* Deferred.make<string>(); |
apps/desktop/src/window/DesktopApplicationMenu.ts+31-1| ... | @@ -11,6 +11,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts"; | ... | @@ -11,6 +11,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 11 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; | 11 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; |
| 12 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 12 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 13 | import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; | 13 | import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; |
| 14 | import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts"; | ||
| 14 | import * as DesktopWindow from "./DesktopWindow.ts"; | 15 | import * as DesktopWindow from "./DesktopWindow.ts"; |
| 15 | 16 | ||
| 16 | export class DesktopApplicationMenuActionError extends Schema.TaggedError<DesktopApplicationMenuActionError>()( | 17 | export class DesktopApplicationMenuActionError extends Schema.TaggedError<DesktopApplicationMenuActionError>()( |
| ... | @@ -34,6 +35,7 @@ export class DesktopApplicationMenu extends Context.Service< | ... | @@ -34,6 +35,7 @@ export class DesktopApplicationMenu extends Context.Service< |
| 34 | 35 | ||
| 35 | type DesktopApplicationMenuRuntimeServices = | 36 | type DesktopApplicationMenuRuntimeServices = |
| 36 | | DesktopUpdates.DesktopUpdates | 37 | | DesktopUpdates.DesktopUpdates |
| 38 | | DesktopLocalUi.DesktopLocalUi | ||
| 37 | | DesktopWindow.DesktopWindow | 39 | | DesktopWindow.DesktopWindow |
| 38 | | ElectronDialog.ElectronDialog; | 40 | | ElectronDialog.ElectronDialog; |
| 39 | 41 | ||
| ... | @@ -74,7 +76,7 @@ const checkForUpdatesFromMenu = Effect.gen(function* () { | ... | @@ -74,7 +76,7 @@ const checkForUpdatesFromMenu = Effect.gen(function* () { |
| 74 | yield* electronDialog.showMessageBox({ | 76 | yield* electronDialog.showMessageBox({ |
| 75 | type: "info", | 77 | type: "info", |
| 76 | title: "You're up to date!", | 78 | title: "You're up to date!", |
| 77 | message: `The Knife ${updateState.currentVersion} is currently the newest version available.`, | 79 | message: `The Knife ${updateState.uiVersion ?? updateState.currentVersion} is currently the newest version available.`, |
| 78 | buttons: ["OK"], | 80 | buttons: ["OK"], |
| 79 | }); | 81 | }); |
| 80 | } else if (updateState.status === "error") { | 82 | } else if (updateState.status === "error") { |
| ... | @@ -160,6 +162,29 @@ export const make = Effect.gen(function* () { | ... | @@ -160,6 +162,29 @@ export const make = Effect.gen(function* () { |
| 160 | const mainContentsClick = (command: DesktopWindow.MainWindowContentsCommand) => () => { | 162 | const mainContentsClick = (command: DesktopWindow.MainWindowContentsCommand) => () => { |
| 161 | runMenuEffect(command, runMainContentsCommand(command)); | 163 | runMenuEffect(command, runMainContentsCommand(command)); |
| 162 | }; | 164 | }; |
| 165 | const localUiClick = () => () => { | ||
| 166 | runMenuEffect( | ||
| 167 | "reset-ui", | ||
| 168 | Effect.gen(function* () { | ||
| 169 | const localUi = yield* DesktopLocalUi.DesktopLocalUi; | ||
| 170 | yield* localUi.reset; | ||
| 171 | yield* runMainContentsCommand("forceReload"); | ||
| 172 | }).pipe( | ||
| 173 | Effect.catchTags({ | ||
| 174 | DesktopLocalUiError: (error) => | ||
| 175 | Effect.gen(function* () { | ||
| 176 | const dialog = yield* ElectronDialog.ElectronDialog; | ||
| 177 | yield* dialog.showMessageBox({ | ||
| 178 | type: "error", | ||
| 179 | title: "Could not update UI", | ||
| 180 | message: error.message, | ||
| 181 | buttons: ["OK"], | ||
| 182 | }); | ||
| 183 | }), | ||
| 184 | }), | ||
| 185 | ), | ||
| 186 | ); | ||
| 187 | }; | ||
| 163 | const template: Electron.MenuItemConstructorOptions[] = []; | 188 | const template: Electron.MenuItemConstructorOptions[] = []; |
| 164 | 189 | ||
| 165 | if (environment.platform === "darwin") { | 190 | if (environment.platform === "darwin") { |
| ... | @@ -244,6 +269,11 @@ export const make = Effect.gen(function* () { | ... | @@ -244,6 +269,11 @@ export const make = Effect.gen(function* () { |
| 244 | the main window (see DesktopWindow.zoomMain). | 269 | the main window (see DesktopWindow.zoomMain). |
| 245 | */ | 270 | */ |
| 246 | { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") }, | 271 | { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") }, |
| 272 | { | ||
| 273 | label: "Use Bundled UI", | ||
| 274 | enabled: !environment.isDevelopment, | ||
| 275 | click: localUiClick(), | ||
| 276 | }, | ||
| 247 | { | 277 | { |
| 248 | label: "Force Reload", | 278 | label: "Force Reload", |
| 249 | accelerator: "Shift+CmdOrCtrl+R", | 279 | accelerator: "Shift+CmdOrCtrl+R", |
apps/desktop/vite.config.ts+3-1| ... | @@ -95,7 +95,9 @@ export default defineConfig({ | ... | @@ -95,7 +95,9 @@ export default defineConfig({ |
| 95 | entry: ["src/boot.ts", "src/compileCache.ts"], | 95 | entry: ["src/boot.ts", "src/compileCache.ts"], |
| 96 | clean: false, | 96 | clean: false, |
| 97 | deps: { | 97 | deps: { |
| 98 | neverBundle: (id) => id === "./main.cjs" || id === "./compileCache.cjs", | 98 | alwaysBundle: (id) => !id.startsWith("node:") && id !== "electron", |
| 99 | neverBundle: (id) => | ||
| 100 | id === "electron" || id === "./main.cjs" || id === "./compileCache.cjs", | ||
| 99 | }, | 101 | }, |
| 100 | }, | 102 | }, |
| 101 | { | 103 | { |
apps/server/src/binCli.ts+2-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { SERVER_VERSION } from "./version.ts"; | ||
| 1 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | 2 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| ... | @@ -6,7 +7,6 @@ import { Argument, Command } from "effect/cli"; | ... | @@ -6,7 +7,6 @@ import { Argument, Command } from "effect/cli"; |
| 6 | import * as CliError from "effect/cli/CliError"; | 7 | import * as CliError from "effect/cli/CliError"; |
| 7 | 8 | ||
| 8 | import * as NetService from "@t3tools/shared/Net"; | 9 | import * as NetService from "@t3tools/shared/Net"; |
| 9 | import packageJson from "../package.json" with { type: "json" }; | ||
| 10 | import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts"; | 10 | import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts"; |
| 11 | import { authCommand } from "./cli/auth.ts"; | 11 | import { authCommand } from "./cli/auth.ts"; |
| 12 | import { appCommand } from "./cli/app.ts"; | 12 | import { appCommand } from "./cli/app.ts"; |
| ... | @@ -92,7 +92,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => | ... | @@ -92,7 +92,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => |
| 92 | export const cli = makeCli(); | 92 | export const cli = makeCli(); |
| 93 | 93 | ||
| 94 | export function runCli() { | 94 | export function runCli() { |
| 95 | Command.run(cli, { version: packageJson.version }).pipe( | 95 | Command.run(cli, { version: SERVER_VERSION }).pipe( |
| 96 | Effect.scoped, | 96 | Effect.scoped, |
| 97 | Effect.provide(layerCliRuntime), | 97 | Effect.provide(layerCliRuntime), |
| 98 | NodeRuntime.runMain, | 98 | NodeRuntime.runMain, |
apps/server/src/cli/invocation.ts+2-3| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { SERVER_VERSION } from "../version.ts"; | ||
| 1 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 2 | import * as FileSystem from "effect/FileSystem"; | 3 | import * as FileSystem from "effect/FileSystem"; |
| 3 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| ... | @@ -8,8 +9,6 @@ import type { ServerInstallation } from "@t3tools/contracts"; | ... | @@ -8,8 +9,6 @@ import type { ServerInstallation } from "@t3tools/contracts"; |
| 8 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 9 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 9 | import { isCommandAvailable } from "@t3tools/shared/shell"; | 10 | import { isCommandAvailable } from "@t3tools/shared/shell"; |
| 10 | 11 | ||
| 11 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 12 | |||
| 13 | export type CliRunner = "npx" | "pnpm dlx" | "bunx"; | 12 | export type CliRunner = "npx" | "pnpm dlx" | "bunx"; |
| 14 | 13 | ||
| 15 | /** | 14 | /** |
| ... | @@ -139,7 +138,7 @@ export const resolveCliCommand = (subcommand: string) => | ... | @@ -139,7 +138,7 @@ export const resolveCliCommand = (subcommand: string) => |
| 139 | formatCliCommand({ | 138 | formatCliCommand({ |
| 140 | subcommand, | 139 | subcommand, |
| 141 | entryPath: processArguments[1] ?? "", | 140 | entryPath: processArguments[1] ?? "", |
| 142 | version: packageJson.version, | 141 | version: SERVER_VERSION, |
| 143 | }), | 142 | }), |
| 144 | ); | 143 | ); |
| 145 | 144 |
apps/server/src/cli/service.ts+10-12| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { SERVER_VERSION } from "../version.ts"; | ||
| 1 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 2 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 2 | import * as Console from "effect/Console"; | 3 | import * as Console from "effect/Console"; |
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| ... | @@ -6,7 +7,6 @@ import * as Terminal from "effect/Terminal"; | ... | @@ -6,7 +7,6 @@ import * as Terminal from "effect/Terminal"; |
| 6 | import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; | 7 | import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; |
| 7 | import { FetchHttpClient } from "effect/http"; | 8 | import { FetchHttpClient } from "effect/http"; |
| 8 | 9 | ||
| 9 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 10 | import * as BootService from "../cloud/bootService.ts"; | 10 | import * as BootService from "../cloud/bootService.ts"; |
| 11 | import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; | 11 | import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; |
| 12 | import type * as ServerConfig from "../config.ts"; | 12 | import type * as ServerConfig from "../config.ts"; |
| ... | @@ -17,7 +17,7 @@ export const layer = (config: ServerConfig.ServerConfig["Service"]) => | ... | @@ -17,7 +17,7 @@ export const layer = (config: ServerConfig.ServerConfig["Service"]) => |
| 17 | BootService.layer({ | 17 | BootService.layer({ |
| 18 | baseDir: config.baseDir, | 18 | baseDir: config.baseDir, |
| 19 | logsDir: config.logsDir, | 19 | logsDir: config.logsDir, |
| 20 | cliVersion: packageJson.version, | 20 | cliVersion: SERVER_VERSION, |
| 21 | }).pipe( | 21 | }).pipe( |
| 22 | Layer.provide(ProcessRunner.layer), | 22 | Layer.provide(ProcessRunner.layer), |
| 23 | // Archive-distributed versions download the release archive here. | 23 | // Archive-distributed versions download the release archive here. |
| ... | @@ -48,11 +48,11 @@ export const reconcileService = Effect.fn("cli.service.reconcile")(function* (op | ... | @@ -48,11 +48,11 @@ export const reconcileService = Effect.fn("cli.service.reconcile")(function* (op |
| 48 | if ( | 48 | if ( |
| 49 | status.installedVersion !== undefined && | 49 | status.installedVersion !== undefined && |
| 50 | options?.allowDowngrade !== true && | 50 | options?.allowDowngrade !== true && |
| 51 | compareExactServiceVersions(packageJson.version, status.installedVersion) < 0 | 51 | compareExactServiceVersions(SERVER_VERSION, status.installedVersion) < 0 |
| 52 | ) { | 52 | ) { |
| 53 | return yield* new BootService.BootServiceDowngradeRefusedError({ | 53 | return yield* new BootService.BootServiceDowngradeRefusedError({ |
| 54 | installedVersion: status.installedVersion, | 54 | installedVersion: status.installedVersion, |
| 55 | targetVersion: packageJson.version, | 55 | targetVersion: SERVER_VERSION, |
| 56 | }); | 56 | }); |
| 57 | } | 57 | } |
| 58 | const plan = yield* service.install(options); | 58 | const plan = yield* service.install(options); |
| ... | @@ -126,13 +126,11 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip | ... | @@ -126,13 +126,11 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip |
| 126 | Effect.gen(function* () { | 126 | Effect.gen(function* () { |
| 127 | const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); | 127 | const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); |
| 128 | if (!result.changed) { | 128 | if (!result.changed) { |
| 129 | yield* Console.log( | 129 | yield* Console.log(`T3 Code service is already installed with t3@${SERVER_VERSION}.`); |
| 130 | `T3 Code service is already installed with t3@${packageJson.version}.`, | ||
| 131 | ); | ||
| 132 | return; | 130 | return; |
| 133 | } | 131 | } |
| 134 | yield* Console.log( | 132 | yield* Console.log( |
| 135 | `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, | 133 | `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${SERVER_VERSION}.\nLogs: ${result.plan.logPath}`, |
| 136 | ); | 134 | ); |
| 137 | }), | 135 | }), |
| 138 | ), | 136 | ), |
| ... | @@ -153,11 +151,11 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( | ... | @@ -153,11 +151,11 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( |
| 153 | ); | 151 | ); |
| 154 | const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); | 152 | const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); |
| 155 | if (!result.changed) { | 153 | if (!result.changed) { |
| 156 | yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); | 154 | yield* Console.log(`T3 Code service is already using t3@${SERVER_VERSION}.`); |
| 157 | return; | 155 | return; |
| 158 | } | 156 | } |
| 159 | yield* Console.log( | 157 | yield* Console.log( |
| 160 | `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, | 158 | `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${SERVER_VERSION}.\nLogs: ${result.plan.logPath}`, |
| 161 | ); | 159 | ); |
| 162 | }), | 160 | }), |
| 163 | ), | 161 | ), |
| ... | @@ -208,7 +206,7 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( | ... | @@ -208,7 +206,7 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( |
| 208 | flags, | 206 | flags, |
| 209 | Effect.gen(function* () { | 207 | Effect.gen(function* () { |
| 210 | const service = yield* BootService.BootService; | 208 | const service = yield* BootService.BootService; |
| 211 | yield* Console.log(formatServiceStatus(yield* service.status, packageJson.version)); | 209 | yield* Console.log(formatServiceStatus(yield* service.status, SERVER_VERSION)); |
| 212 | }), | 210 | }), |
| 213 | ), | 211 | ), |
| 214 | ), | 212 | ), |
| ... | @@ -231,7 +229,7 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { | ... | @@ -231,7 +229,7 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { |
| 231 | if ( | 229 | if ( |
| 232 | installed && | 230 | installed && |
| 233 | status.installedVersion !== undefined && | 231 | status.installedVersion !== undefined && |
| 234 | compareExactServiceVersions(status.installedVersion, packageJson.version) > 0 | 232 | compareExactServiceVersions(status.installedVersion, SERVER_VERSION) > 0 |
| 235 | ) { | 233 | ) { |
| 236 | yield* Console.log( | 234 | yield* Console.log( |
| 237 | `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`, | 235 | `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`, |
apps/server/src/cli/triage.ts+2-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { SERVER_VERSION } from "../version.ts"; | ||
| 1 | /** | 2 | /** |
| 2 | * `t3 triage` - hand a misbehaving install to the user's own coding agent. | 3 | * `t3 triage` - hand a misbehaving install to the user's own coding agent. |
| 3 | * | 4 | * |
| ... | @@ -26,7 +27,6 @@ import * as Path from "effect/Path"; | ... | @@ -26,7 +27,6 @@ import * as Path from "effect/Path"; |
| 26 | import * as Schema from "effect/Schema"; | 27 | import * as Schema from "effect/Schema"; |
| 27 | import { Command, Flag } from "effect/cli"; | 28 | import { Command, Flag } from "effect/cli"; |
| 28 | 29 | ||
| 29 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 30 | import * as BootService from "../cloud/bootService.ts"; | 30 | import * as BootService from "../cloud/bootService.ts"; |
| 31 | import * as ServerConfig from "../config.ts"; | 31 | import * as ServerConfig from "../config.ts"; |
| 32 | import { resolveBaseDir } from "../os-jank.ts"; | 32 | import { resolveBaseDir } from "../os-jank.ts"; |
| ... | @@ -195,7 +195,7 @@ export const triageCommand = Command.make("triage", { | ... | @@ -195,7 +195,7 @@ export const triageCommand = Command.make("triage", { |
| 195 | contextFilePath, | 195 | contextFilePath, |
| 196 | buildTriageContext({ | 196 | buildTriageContext({ |
| 197 | generatedAt: DateTime.formatIso(now), | 197 | generatedAt: DateTime.formatIso(now), |
| 198 | cliVersion: packageJson.version, | 198 | cliVersion: SERVER_VERSION, |
| 199 | os: `${yield* HostProcess.Platform} ${yield* HostProcess.Architecture} (${NodeOS.release()})`, | 199 | os: `${yield* HostProcess.Platform} ${yield* HostProcess.Architecture} (${NodeOS.release()})`, |
| 200 | nodeVersion: process.version, | 200 | nodeVersion: process.version, |
| 201 | launchedAs: yield* resolveCliCommand("triage"), | 201 | launchedAs: yield* resolveCliCommand("triage"), |
apps/server/src/cli/update.ts+2-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { SERVER_VERSION } from "../version.ts"; | ||
| 1 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 2 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 2 | import { | 3 | import { |
| 3 | CLI_RELEASE_BASE_URL_ENV, | 4 | CLI_RELEASE_BASE_URL_ENV, |
| ... | @@ -18,7 +19,6 @@ import * as Schema from "effect/Schema"; | ... | @@ -18,7 +19,6 @@ import * as Schema from "effect/Schema"; |
| 18 | import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/cli"; | 19 | import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/cli"; |
| 19 | import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | 20 | import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| 20 | 21 | ||
| 21 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 22 | import * as BootService from "../cloud/bootService.ts"; | 22 | import * as BootService from "../cloud/bootService.ts"; |
| 23 | import { | 23 | import { |
| 24 | ensurePinnedRuntimeInstalled, | 24 | ensurePinnedRuntimeInstalled, |
| ... | @@ -339,7 +339,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { | ... | @@ -339,7 +339,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 339 | const httpClient = yield* HttpClient.HttpClient; | 339 | const httpClient = yield* HttpClient.HttpClient; |
| 340 | const service = yield* BootService.BootService; | 340 | const service = yield* BootService.BootService; |
| 341 | 341 | ||
| 342 | const currentVersion = packageJson.version; | 342 | const currentVersion = SERVER_VERSION; |
| 343 | const channel = input.channel ?? cliReleaseChannelOf(currentVersion); | 343 | const channel = input.channel ?? cliReleaseChannelOf(currentVersion); |
| 344 | if (input.requestedVersion !== undefined && !isExactServiceVersion(input.requestedVersion)) { | 344 | if (input.requestedVersion !== undefined && !isExactServiceVersion(input.requestedVersion)) { |
| 345 | return yield* new CliUpdateError({ | 345 | return yield* new CliUpdateError({ |
apps/server/src/cloud/serviceLauncherClient.ts+2-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { SERVER_VERSION } from "../version.ts"; | ||
| 1 | import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; | 2 | import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; |
| 2 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 3 | import * as Context from "effect/Context"; | 4 | import * as Context from "effect/Context"; |
| ... | @@ -5,7 +6,6 @@ import * as Effect from "effect/Effect"; | ... | @@ -5,7 +6,6 @@ import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 6 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 7 | 8 | ||
| 8 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 9 | import { | 9 | import { |
| 10 | decodeServiceLauncherContext, | 10 | decodeServiceLauncherContext, |
| 11 | decodeServiceLauncherParentMessage, | 11 | decodeServiceLauncherParentMessage, |
| ... | @@ -115,7 +115,7 @@ const resolveStartup = Effect.fn("cloud.service_launcher_client.resolve_startup" | ... | @@ -115,7 +115,7 @@ const resolveStartup = Effect.fn("cloud.service_launcher_client.resolve_startup" |
| 115 | function* (options?: { readonly currentVersion?: string }) { | 115 | function* (options?: { readonly currentVersion?: string }) { |
| 116 | const host = yield* ServiceLauncherHostProcess; | 116 | const host = yield* ServiceLauncherHostProcess; |
| 117 | const environment = yield* HostProcess.Environment; | 117 | const environment = yield* HostProcess.Environment; |
| 118 | const currentVersion = options?.currentVersion ?? packageJson.version; | 118 | const currentVersion = options?.currentVersion ?? SERVER_VERSION; |
| 119 | const rawContext = environment[SERVICE_LAUNCHER_CONTEXT_ENV]; | 119 | const rawContext = environment[SERVICE_LAUNCHER_CONTEXT_ENV]; |
| 120 | const context = rawContext === undefined ? undefined : decodeServiceLauncherContext(rawContext); | 120 | const context = rawContext === undefined ? undefined : decodeServiceLauncherContext(rawContext); |
| 121 | 121 |
apps/server/src/cloud/servicePreflight.ts+2-2| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import packageJson from "../../package.json" with { type: "json" }; | 1 | import { SERVER_VERSION } from "../version.ts"; |
| 2 | import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; | 2 | import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; |
| 3 | 3 | ||
| 4 | export type ServicePreflightResult = | 4 | export type ServicePreflightResult = |
| ... | @@ -19,7 +19,7 @@ export function runServicePreflight(input: { | ... | @@ -19,7 +19,7 @@ export function runServicePreflight(input: { |
| 19 | readonly launcherProtocol: number; | 19 | readonly launcherProtocol: number; |
| 20 | readonly version?: string; | 20 | readonly version?: string; |
| 21 | }): ServicePreflightResult { | 21 | }): ServicePreflightResult { |
| 22 | const version = input.version ?? packageJson.version; | 22 | const version = input.version ?? SERVER_VERSION; |
| 23 | if (input.launcherProtocol !== SERVICE_LAUNCHER_PROTOCOL) { | 23 | if (input.launcherProtocol !== SERVICE_LAUNCHER_PROTOCOL) { |
| 24 | return { | 24 | return { |
| 25 | status: "blocked", | 25 | status: "blocked", |
apps/server/src/environment/ServerEnvironment.ts+2-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { SERVER_VERSION } from "../version.ts"; | ||
| 1 | import { | 2 | import { |
| 2 | EnvironmentId, | 3 | EnvironmentId, |
| 3 | ORCHESTRATION_PROTOCOL_VERSION, | 4 | ORCHESTRATION_PROTOCOL_VERSION, |
| ... | @@ -13,7 +14,6 @@ import * as Layer from "effect/Layer"; | ... | @@ -13,7 +14,6 @@ import * as Layer from "effect/Layer"; |
| 13 | import * as Path from "effect/Path"; | 14 | import * as Path from "effect/Path"; |
| 14 | import * as Schema from "effect/Schema"; | 15 | import * as Schema from "effect/Schema"; |
| 15 | 16 | ||
| 16 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 17 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 17 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 18 | import { resolveServerInstallation } from "../cli/invocation.ts"; | 18 | import { resolveServerInstallation } from "../cli/invocation.ts"; |
| 19 | import { readAgentActivityPublishingActive } from "../cloud/config.ts"; | 19 | import { readAgentActivityPublishingActive } from "../cloud/config.ts"; |
| ... | @@ -213,7 +213,7 @@ export const make = Effect.gen(function* () { | ... | @@ -213,7 +213,7 @@ export const make = Effect.gen(function* () { |
| 213 | arch: platformArch(hostArchitecture), | 213 | arch: platformArch(hostArchitecture), |
| 214 | ...(machine === null ? {} : { machine }), | 214 | ...(machine === null ? {} : { machine }), |
| 215 | }, | 215 | }, |
| 216 | serverVersion: packageJson.version, | 216 | serverVersion: SERVER_VERSION, |
| 217 | orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, | 217 | orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, |
| 218 | capabilities: { | 218 | capabilities: { |
| 219 | repositoryIdentity: true, | 219 | repositoryIdentity: true, |
apps/server/src/mcp/McpHttpServer.ts+2-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { SERVER_VERSION } from "../version.ts"; | ||
| 1 | import * as Cause from "effect/Cause"; | 2 | import * as Cause from "effect/Cause"; |
| 2 | import * as Clock from "effect/Clock"; | 3 | import * as Clock from "effect/Clock"; |
| 3 | import * as Context from "effect/Context"; | 4 | import * as Context from "effect/Context"; |
| ... | @@ -15,7 +16,6 @@ import { AiError, McpProtocol, McpSchema, McpServer, Tool, type Toolkit } from " | ... | @@ -15,7 +16,6 @@ import { AiError, McpProtocol, McpSchema, McpServer, Tool, type Toolkit } from " |
| 15 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; | 16 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; |
| 16 | import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts"; | 17 | import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts"; |
| 17 | 18 | ||
| 18 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 19 | import * as ServerConfig from "../config.ts"; | 19 | import * as ServerConfig from "../config.ts"; |
| 20 | import * as DeviceService from "../device/DeviceService.ts"; | 20 | import * as DeviceService from "../device/DeviceService.ts"; |
| 21 | import * as HtmlRender from "../htmlRender/HtmlRender.ts"; | 21 | import * as HtmlRender from "../htmlRender/HtmlRender.ts"; |
| ... | @@ -860,7 +860,7 @@ export const layerDeviceToolkit = Layer.mergeAll( | ... | @@ -860,7 +860,7 @@ export const layerDeviceToolkit = Layer.mergeAll( |
| 860 | 860 | ||
| 861 | export const layerMcpTransport = McpServer.layerHttp({ | 861 | export const layerMcpTransport = McpServer.layerHttp({ |
| 862 | name: "T3 Code", | 862 | name: "T3 Code", |
| 863 | version: packageJson.version, | 863 | version: SERVER_VERSION, |
| 864 | path: "/mcp", | 864 | path: "/mcp", |
| 865 | protocols: [McpProtocol.v2025_06_18], | 865 | protocols: [McpProtocol.v2025_06_18], |
| 866 | allowSessionTermination: true, | 866 | allowSessionTermination: true, |
apps/server/src/provider/CodexProvider.ts+2-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { SERVER_VERSION } from "../version.ts"; | ||
| 1 | import * as DateTime from "effect/DateTime"; | 2 | import * as DateTime from "effect/DateTime"; |
| 2 | import * as Duration from "effect/Duration"; | 3 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| ... | @@ -51,7 +52,6 @@ import { | ... | @@ -51,7 +52,6 @@ import { |
| 51 | type CodexResetCreditsSummary, | 52 | type CodexResetCreditsSummary, |
| 52 | } from "./codexUsageLimits.ts"; | 53 | } from "./codexUsageLimits.ts"; |
| 53 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 54 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 54 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 55 | const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); | 55 | const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); |
| 56 | const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; | 56 | const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; |
| 57 | 57 | ||
| ... | @@ -410,7 +410,7 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { | ... | @@ -410,7 +410,7 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { |
| 410 | clientInfo: { | 410 | clientInfo: { |
| 411 | name: CODEX_CLIENT_NAME, | 411 | name: CODEX_CLIENT_NAME, |
| 412 | title: CODEX_CLIENT_NAME, | 412 | title: CODEX_CLIENT_NAME, |
| 413 | version: packageJson.version, | 413 | version: SERVER_VERSION, |
| 414 | }, | 414 | }, |
| 415 | capabilities: { | 415 | capabilities: { |
| 416 | experimentalApi: true, | 416 | experimentalApi: true, |
apps/server/src/provider/providerCompatibility.ts+2-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { SERVER_VERSION } from "../version.ts"; | ||
| 1 | import { | 2 | import { |
| 2 | TrimmedNonEmptyString, | 3 | TrimmedNonEmptyString, |
| 3 | ServerProviderCompatibilityStatus, | 4 | ServerProviderCompatibilityStatus, |
| ... | @@ -7,7 +8,6 @@ import { | ... | @@ -7,7 +8,6 @@ import { |
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 8 | import { satisfiesSemverRange } from "@t3tools/shared/semver"; | 9 | import { satisfiesSemverRange } from "@t3tools/shared/semver"; |
| 9 | import * as Schema from "effect/Schema"; | 10 | import * as Schema from "effect/Schema"; |
| 10 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 11 | 11 | ||
| 12 | // Deliberately uses the shared CLI gate syntax: comparator groups joined by ||. | 12 | // Deliberately uses the shared CLI gate syntax: comparator groups joined by ||. |
| 13 | // Prereleases and unrecognized release tags remain unknown. | 13 | // Prereleases and unrecognized release tags remain unknown. |
| ... | @@ -60,7 +60,7 @@ export function resolveProviderCompatibility( | ... | @@ -60,7 +60,7 @@ export function resolveProviderCompatibility( |
| 60 | policies: ReadonlyArray<ProviderCompatibilityPolicy> | undefined, | 60 | policies: ReadonlyArray<ProviderCompatibilityPolicy> | undefined, |
| 61 | driver: ProviderDriverKind, | 61 | driver: ProviderDriverKind, |
| 62 | version: string | null, | 62 | version: string | null, |
| 63 | t3CodeVersion = packageJson.version, | 63 | t3CodeVersion = SERVER_VERSION, |
| 64 | ): ServerProviderCompatibilityAdvisory | undefined { | 64 | ): ServerProviderCompatibilityAdvisory | undefined { |
| 65 | const policy = policies?.find( | 65 | const policy = policies?.find( |
| 66 | (entry) => entry.driver === driver && satisfiesSemverRange(t3CodeVersion, entry.t3CodeRange), | 66 | (entry) => entry.driver === driver && satisfiesSemverRange(t3CodeVersion, entry.t3CodeRange), |
apps/server/src/serverRuntimeState.ts+2-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { SERVER_VERSION } from "./version.ts"; | ||
| 1 | import * as DateTime from "effect/DateTime"; | 2 | import * as DateTime from "effect/DateTime"; |
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -5,7 +6,6 @@ import * as Option from "effect/Option"; | ... | @@ -5,7 +6,6 @@ import * as Option from "effect/Option"; |
| 5 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 6 | 7 | ||
| 7 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | 8 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; |
| 8 | import packageJson from "../package.json" with { type: "json" }; | ||
| 9 | import type * as ServerConfig from "./config.ts"; | 9 | import type * as ServerConfig from "./config.ts"; |
| 10 | import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; | 10 | import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; |
| 11 | 11 | ||
| ... | @@ -76,7 +76,7 @@ export const makePersistedServerRuntimeState = (input: { | ... | @@ -76,7 +76,7 @@ export const makePersistedServerRuntimeState = (input: { |
| 76 | ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), | 76 | ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), |
| 77 | startedAt: DateTime.formatIso(now), | 77 | startedAt: DateTime.formatIso(now), |
| 78 | ...(input.serviceManaged ? { serviceManaged: true } : {}), | 78 | ...(input.serviceManaged ? { serviceManaged: true } : {}), |
| 79 | serverVersion: packageJson.version, | 79 | serverVersion: SERVER_VERSION, |
| 80 | })); | 80 | })); |
| 81 | 81 | ||
| 82 | export const persistServerRuntimeState = (input: { | 82 | export const persistServerRuntimeState = (input: { |
apps/server/src/telemetry/AnalyticsService.ts+3-5| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { SERVER_VERSION } from "../version.ts"; | ||
| 1 | /** | 2 | /** |
| 2 | * Anonymous PostHog telemetry service. | 3 | * Anonymous PostHog telemetry service. |
| 3 | * | 4 | * |
| ... | @@ -27,7 +28,6 @@ import * as HttpClient from "effect/http/HttpClient"; | ... | @@ -27,7 +28,6 @@ import * as HttpClient from "effect/http/HttpClient"; |
| 27 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; | 28 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 28 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; | 29 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 29 | 30 | ||
| 30 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 31 | import * as ServerConfig from "../config.ts"; | 31 | import * as ServerConfig from "../config.ts"; |
| 32 | import { getTelemetryIdentifier } from "./Identify.ts"; | 32 | import { getTelemetryIdentifier } from "./Identify.ts"; |
| 33 | 33 | ||
| ... | @@ -188,12 +188,12 @@ export const make = Effect.gen(function* () { | ... | @@ -188,12 +188,12 @@ export const make = Effect.gen(function* () { |
| 188 | platform: hostPlatform, | 188 | platform: hostPlatform, |
| 189 | wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), | 189 | wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), |
| 190 | arch: hostArchitecture, | 190 | arch: hostArchitecture, |
| 191 | t3CodeVersion: packageJson.version, | 191 | t3CodeVersion: SERVER_VERSION, |
| 192 | clientType, | 192 | clientType, |
| 193 | serverOs: serverOsFromNodePlatform(hostPlatform), | 193 | serverOs: serverOsFromNodePlatform(hostPlatform), |
| 194 | serverArch: hostArchitecture, | 194 | serverArch: hostArchitecture, |
| 195 | serverWslDistro: Option.getOrUndefined(telemetryConfig.wslDistroName), | 195 | serverWslDistro: Option.getOrUndefined(telemetryConfig.wslDistroName), |
| 196 | serverAppVersion: packageJson.version, | 196 | serverAppVersion: SERVER_VERSION, |
| 197 | serverMode: serverConfig.mode, | 197 | serverMode: serverConfig.mode, |
| 198 | }, | 198 | }, |
| 199 | timestamp: event.capturedAt, | 199 | timestamp: event.capturedAt, |
| ... | @@ -286,5 +286,3 @@ export const make = Effect.gen(function* () { | ... | @@ -286,5 +286,3 @@ export const make = Effect.gen(function* () { |
| 286 | }); | 286 | }); |
| 287 | 287 | ||
| 288 | export const layer = Layer.effect(AnalyticsService, make); | 288 | export const layer = Layer.effect(AnalyticsService, make); |
| 289 | |||
| 290 | const layerTest = AnalyticsService.layerTest; |
apps/server/src/version.test.ts created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Exercises the actual CLI in isolated child processes. | ||
| 2 | import * as NodeChildProcess from "node:child_process"; | ||
| 3 | import * as NodeFSP from "node:fs/promises"; | ||
| 4 | import * as NodeOS from "node:os"; | ||
| 5 | import * as NodePath from "node:path"; | ||
| 6 | import * as NodeURL from "node:url"; | ||
| 7 | import * as NodeUtil from "node:util"; | ||
| 8 | import { afterAll, beforeAll, expect, it } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import packageJson from "../package.json" with { type: "json" }; | ||
| 11 | |||
| 12 | const run = NodeUtil.promisify(NodeChildProcess.execFile); | ||
| 13 | let home: string; | ||
| 14 | |||
| 15 | beforeAll(async () => { | ||
| 16 | home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "knife-version-")); | ||
| 17 | }); | ||
| 18 | |||
| 19 | afterAll(async () => { | ||
| 20 | await NodeFSP.rm(home, { recursive: true, force: true }); | ||
| 21 | }); | ||
| 22 | |||
| 23 | it.each([ | ||
| 24 | [undefined, packageJson.version], | ||
| 25 | ["0.0.1234567890", "0.0.1234567890"], | ||
| 26 | ["9.0.1", packageJson.version], | ||
| 27 | ])("reports the running server version for override %s", async (override, expected) => { | ||
| 28 | const env: NodeJS.ProcessEnv = { ...process.env, T3CODE_HOME: home }; | ||
| 29 | delete env.T3CODE_VERSION_OVERRIDE; | ||
| 30 | if (override !== undefined) env.T3CODE_VERSION_OVERRIDE = override; | ||
| 31 | const { stdout } = await run( | ||
| 32 | process.execPath, | ||
| 33 | [NodeURL.fileURLToPath(new URL("./bin.ts", import.meta.url)), "--version"], | ||
| 34 | { env }, | ||
| 35 | ); | ||
| 36 | expect(stdout.trim()).toBe(`t3 v${expected}`); | ||
| 37 | }); | ||
apps/server/src/version.ts created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | import packageJson from "../package.json" with { type: "json" }; | ||
| 2 | |||
| 3 | const override = process.env.T3CODE_VERSION_OVERRIDE; | ||
| 4 | |||
| 5 | export const SERVER_VERSION = | ||
| 6 | override && /^0\.0\.\d+$/.test(override) ? override : packageJson.version; | ||
apps/web/src/components/AutomaticUiRefresh.tsx created+39| ... | @@ -0,0 +1,39 @@ | ||
| 1 | import { useEffect, useLayoutEffect, useRef } from "react"; | ||
| 2 | import { useDesktopUpdateState } from "~/state/desktopUpdate"; | ||
| 3 | import { prepareUiRefresh, startAutomaticUiRefresh } from "~/uiRefresh"; | ||
| 4 | import { APP_VERSION } from "~/branding"; | ||
| 5 | import { installLocalUpdate } from "./desktopUpdate.actions"; | ||
| 6 | |||
| 7 | export function AutomaticUiRefresh() { | ||
| 8 | const state = useDesktopUpdateState(); | ||
| 9 | const latest = useRef(state); | ||
| 10 | const controller = useRef<ReturnType<typeof startAutomaticUiRefresh> | null>(null); | ||
| 11 | |||
| 12 | useEffect(() => { | ||
| 13 | const bridge = window.desktopBridge; | ||
| 14 | if (!bridge) return; | ||
| 15 | const refresh = startAutomaticUiRefresh({ | ||
| 16 | pending: () => | ||
| 17 | (latest.current?.status === "downloaded" && latest.current.installKind === "ui") || | ||
| 18 | Boolean(latest.current?.uiVersion && latest.current.uiVersion !== APP_VERSION), | ||
| 19 | apply: async () => { | ||
| 20 | if (latest.current?.uiVersion && latest.current.uiVersion !== APP_VERSION) { | ||
| 21 | if (prepareUiRefresh()) window.location.reload(); | ||
| 22 | return; | ||
| 23 | } | ||
| 24 | await installLocalUpdate(bridge, undefined, { automatic: true }); | ||
| 25 | }, | ||
| 26 | }); | ||
| 27 | controller.current = refresh; | ||
| 28 | return () => { | ||
| 29 | controller.current = null; | ||
| 30 | refresh.dispose(); | ||
| 31 | }; | ||
| 32 | }, []); | ||
| 33 | |||
| 34 | useLayoutEffect(() => { | ||
| 35 | latest.current = state; | ||
| 36 | controller.current?.check(); | ||
| 37 | }, [state]); | ||
| 38 | return null; | ||
| 39 | } | ||
apps/web/src/components/ComposerPromptEditor.test.tsx+56-4| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | // @vitest-environment jsdom | 1 | // @vitest-environment jsdom |
| 2 | 2 | ||
| 3 | import { act, createRef, useLayoutEffect, useState } from "react"; | 3 | import { act, createRef, StrictMode, useEffect, useLayoutEffect, useState } from "react"; |
| 4 | import { createRoot, type Root } from "react-dom/client"; | 4 | import { createRoot, type Root } from "react-dom/client"; |
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 6 | 6 | ||
| ... | @@ -17,6 +17,8 @@ import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; | ... | @@ -17,6 +17,8 @@ import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 17 | import { ComposerContextActionsContext } from "./composerContextPresentation"; | 17 | import { ComposerContextActionsContext } from "./composerContextPresentation"; |
| 18 | 18 | ||
| 19 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; | 19 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; |
| 20 | import { EditorView } from "@codemirror/view"; | ||
| 21 | import { PREPARE_UI_REFRESH_EVENT } from "~/uiRefresh"; | ||
| 20 | 22 | ||
| 21 | let root: Root; | 23 | let root: Root; |
| 22 | let container: HTMLDivElement; | 24 | let container: HTMLDivElement; |
| ... | @@ -27,16 +29,28 @@ const openMention = vi.fn(); | ... | @@ -27,16 +29,28 @@ const openMention = vi.fn(); |
| 27 | const commandKey = vi.fn((_key: string, _event: KeyboardEvent) => false); | 29 | const commandKey = vi.fn((_key: string, _event: KeyboardEvent) => false); |
| 28 | const changes: Array<{ value: string; suppressed: boolean }> = []; | 30 | const changes: Array<{ value: string; suppressed: boolean }> = []; |
| 29 | 31 | ||
| 30 | function ComposerProbe() { | 32 | function ComposerProbe({ |
| 33 | focusOnMount = false, | ||
| 34 | initiallyEmpty = false, | ||
| 35 | }: { | ||
| 36 | focusOnMount?: boolean; | ||
| 37 | initiallyEmpty?: boolean; | ||
| 38 | }) { | ||
| 31 | const [richText, setRichText] = useState(false); | 39 | const [richText, setRichText] = useState(false); |
| 32 | const [draft, setDraft] = useState({ | 40 | const [draft, setDraft] = useState({ |
| 33 | value: "before :dizzy after", | 41 | value: initiallyEmpty ? "" : "before :dizzy after", |
| 34 | cursor: "before :dizzy".length, | 42 | cursor: initiallyEmpty ? 0 : "before :dizzy".length, |
| 35 | }); | 43 | }); |
| 36 | useLayoutEffect(() => { | 44 | useLayoutEffect(() => { |
| 37 | replace = (value, cursor) => setDraft({ value, cursor }); | 45 | replace = (value, cursor) => setDraft({ value, cursor }); |
| 38 | enableRichText = setRichText; | 46 | enableRichText = setRichText; |
| 39 | }); | 47 | }); |
| 48 | useEffect(() => { | ||
| 49 | if (!focusOnMount) return; | ||
| 50 | // oxlint-disable-next-line react/set-state-in-effect -- Reproduce the parent's mount-time cursor reset. | ||
| 51 | setDraft((current) => ({ ...current, cursor: current.value.length })); | ||
| 52 | handle.current?.focusAtEnd(); | ||
| 53 | }, [focusOnMount]); | ||
| 40 | return ( | 54 | return ( |
| 41 | <ComposerContextActionsContext | 55 | <ComposerContextActionsContext |
| 42 | value={{ | 56 | value={{ |
| ... | @@ -49,6 +63,7 @@ function ComposerProbe() { | ... | @@ -49,6 +63,7 @@ function ComposerProbe() { |
| 49 | }} | 63 | }} |
| 50 | > | 64 | > |
| 51 | <ComposerPromptEditor | 65 | <ComposerPromptEditor |
| 66 | persistenceKey="refresh-test" | ||
| 52 | {...draft} | 67 | {...draft} |
| 53 | richTextEnabled={richText} | 68 | richTextEnabled={richText} |
| 54 | editorRef={handle} | 69 | editorRef={handle} |
| ... | @@ -90,9 +105,46 @@ beforeEach(async () => { | ... | @@ -90,9 +105,46 @@ beforeEach(async () => { |
| 90 | afterEach(async () => { | 105 | afterEach(async () => { |
| 91 | await act(() => root.unmount()); | 106 | await act(() => root.unmount()); |
| 92 | container.remove(); | 107 | container.remove(); |
| 108 | sessionStorage.clear(); | ||
| 109 | vi.restoreAllMocks(); | ||
| 93 | vi.unstubAllGlobals(); | 110 | vi.unstubAllGlobals(); |
| 94 | }); | 111 | }); |
| 95 | 112 | ||
| 113 | it("restores the real editor selection and viewport after a refresh remount", async () => { | ||
| 114 | const textbox = container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!; | ||
| 115 | const view = EditorView.findFromDOM(textbox)!; | ||
| 116 | await act(() => view.dispatch({ selection: { anchor: 2, head: 6 } })); | ||
| 117 | view.scrollDOM.scrollTop = 35; | ||
| 118 | expect(window.dispatchEvent(new Event(PREPARE_UI_REFRESH_EVENT, { cancelable: true }))).toBe( | ||
| 119 | true, | ||
| 120 | ); | ||
| 121 | vi.spyOn(performance, "timeOrigin", "get").mockReturnValue(performance.timeOrigin + 1); | ||
| 122 | await act(() => root.unmount()); | ||
| 123 | root = createRoot(container); | ||
| 124 | await act(() => | ||
| 125 | root.render( | ||
| 126 | <StrictMode> | ||
| 127 | <ComposerProbe focusOnMount initiallyEmpty /> | ||
| 128 | </StrictMode>, | ||
| 129 | ), | ||
| 130 | ); | ||
| 131 | await act(() => replace("before :dizzy after", "before :dizzy after".length)); | ||
| 132 | expect(handle.current!.readSelectionRange()).toEqual({ start: 2, end: 6 }); | ||
| 133 | expect(document.getSelection()?.toString()).toBe("fore"); | ||
| 134 | const restored = EditorView.findFromDOM( | ||
| 135 | container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!, | ||
| 136 | )!; | ||
| 137 | expect(restored.scrollDOM.scrollTop).toBe(35); | ||
| 138 | await act(() => { | ||
| 139 | restored.contentDOM.dispatchEvent( | ||
| 140 | new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }), | ||
| 141 | ); | ||
| 142 | handle.current!.focusAtEnd(); | ||
| 143 | }); | ||
| 144 | const end = handle.current!.readSnapshot().value.length; | ||
| 145 | expect(handle.current!.readSelectionRange()).toEqual({ start: end, end }); | ||
| 146 | }); | ||
| 147 | |||
| 96 | describe("emoji completion in the real composer editor", () => { | 148 | describe("emoji completion in the real composer editor", () => { |
| 97 | it("keeps the caret and undo history when a controlled completion replaces the shortcode", async () => { | 149 | it("keeps the caret and undo history when a controlled completion replaces the shortcode", async () => { |
| 98 | await act(() => handle.current!.focus()); | 150 | await act(() => handle.current!.focus()); |
apps/web/src/components/ComposerPromptEditor.tsx+109-6| ... | @@ -10,14 +10,16 @@ import { | ... | @@ -10,14 +10,16 @@ import { |
| 10 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; | 10 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; |
| 11 | import { isolateHistory } from "@codemirror/commands"; | 11 | import { isolateHistory } from "@codemirror/commands"; |
| 12 | import { Transaction } from "@codemirror/state"; | 12 | import { Transaction } from "@codemirror/state"; |
| 13 | import * as Schema from "effect/Schema"; | ||
| 13 | import { createPortal } from "react-dom"; | 14 | import { createPortal } from "react-dom"; |
| 14 | import { use, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; | 15 | import { use, useCallback, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; |
| 15 | 16 | ||
| 16 | import { expandCollapsedComposerCursor } from "~/composer-logic"; | 17 | import { expandCollapsedComposerCursor } from "~/composer-logic"; |
| 17 | import { collectInlineContextIds } from "~/lib/composerContextReferences"; | 18 | import { collectInlineContextIds } from "~/lib/composerContextReferences"; |
| 18 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; | 19 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; |
| 19 | import { basenameOfPath } from "~/pierre-icons"; | 20 | import { basenameOfPath } from "~/pierre-icons"; |
| 20 | import { cn } from "~/lib/utils"; | 21 | import { cn } from "~/lib/utils"; |
| 22 | import { PREPARE_UI_REFRESH_EVENT } from "~/uiRefresh"; | ||
| 21 | import { | 23 | import { |
| 22 | ComposerContextActionsContext, | 24 | ComposerContextActionsContext, |
| 23 | ComposerContextRecordsContext, | 25 | ComposerContextRecordsContext, |
| ... | @@ -66,6 +68,7 @@ export interface ComposerPromptEditorHandle { | ... | @@ -66,6 +68,7 @@ export interface ComposerPromptEditorHandle { |
| 66 | } | 68 | } |
| 67 | 69 | ||
| 68 | export interface ComposerPromptEditorProps { | 70 | export interface ComposerPromptEditorProps { |
| 71 | persistenceKey?: string | undefined; | ||
| 69 | value: string; | 72 | value: string; |
| 70 | cursor: number; | 73 | cursor: number; |
| 71 | /** | 74 | /** |
| ... | @@ -129,20 +132,45 @@ type CitationComment = { | ... | @@ -129,20 +132,45 @@ type CitationComment = { |
| 129 | insertedSpaces?: CitationInsertedSpaces | undefined; | 132 | insertedSpaces?: CitationInsertedSpaces | undefined; |
| 130 | }; | 133 | }; |
| 131 | 134 | ||
| 135 | const RefreshSelection = Schema.fromJsonString( | ||
| 136 | Schema.Struct({ | ||
| 137 | pageOrigin: Schema.Finite, | ||
| 138 | value: Schema.String, | ||
| 139 | anchor: Schema.Int, | ||
| 140 | head: Schema.Int, | ||
| 141 | scrollTop: Schema.Finite, | ||
| 142 | scrollLeft: Schema.Finite, | ||
| 143 | }), | ||
| 144 | ); | ||
| 145 | const decodeRefreshSelection = Schema.decodeSync(RefreshSelection); | ||
| 146 | const REFRESH_SELECTION_STORAGE_PREFIX = "t3code:ui-refresh-composer:"; | ||
| 147 | |||
| 132 | export function ComposerPromptEditor(props: ComposerPromptEditorProps) { | 148 | export function ComposerPromptEditor(props: ComposerPromptEditorProps) { |
| 133 | const host = useRef<HTMLDivElement>(null); | 149 | const host = useRef<HTMLDivElement>(null); |
| 134 | const currentProps = useRef(props); | 150 | const currentProps = useRef(props); |
| 135 | const editor = useRef<ReturnType<typeof createComposerMarkdownEditor> | null>(null); | 151 | const editor = useRef<ReturnType<typeof createComposerMarkdownEditor> | null>(null); |
| 152 | const restoringRefreshSelection = useRef(false); | ||
| 136 | const pendingCitation = useRef<ComposerCitationCommentRequest | null>(null); | 153 | const pendingCitation = useRef<ComposerCitationCommentRequest | null>(null); |
| 137 | const citationDraft = useRef<{ start: number; comment: string } | null>(null); | 154 | const citationDraft = useRef<{ start: number; comment: string } | null>(null); |
| 138 | const [comment, setComment] = useState<CitationComment | null>(null); | 155 | const [comment, setComment] = useState<CitationComment | null>(null); |
| 139 | const [chips, setChips] = useState<ComposerChip[]>([]); | 156 | const [chips, setChips] = useState<ComposerChip[]>([]); |
| 157 | const discardRefreshSelection = useCallback(() => { | ||
| 158 | restoringRefreshSelection.current = false; | ||
| 159 | const key = currentProps.current.persistenceKey; | ||
| 160 | if (!key) return; | ||
| 161 | try { | ||
| 162 | window.sessionStorage.removeItem(`${REFRESH_SELECTION_STORAGE_PREFIX}${key}`); | ||
| 163 | } catch { | ||
| 164 | // Storage availability must not prevent editing. | ||
| 165 | } | ||
| 166 | }, []); | ||
| 140 | 167 | ||
| 141 | useLayoutEffect(() => { | 168 | useLayoutEffect(() => { |
| 142 | currentProps.current = props; | 169 | currentProps.current = props; |
| 143 | }); | 170 | }); |
| 144 | useLayoutEffect(() => { | 171 | useLayoutEffect(() => { |
| 145 | let alive = true; | 172 | let alive = true; |
| 173 | restoringRefreshSelection.current = false; | ||
| 146 | const instance = createComposerMarkdownEditor({ | 174 | const instance = createComposerMarkdownEditor({ |
| 147 | props: () => currentProps.current, | 175 | props: () => currentProps.current, |
| 148 | mountChip: (chip) => { | 176 | mountChip: (chip) => { |
| ... | @@ -154,12 +182,18 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { | ... | @@ -154,12 +182,18 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { |
| 154 | }); | 182 | }); |
| 155 | editor.current = instance; | 183 | editor.current = instance; |
| 156 | host.current!.append(instance.dom); | 184 | host.current!.append(instance.dom); |
| 185 | instance.dom.addEventListener("pointerdown", discardRefreshSelection, true); | ||
| 186 | instance.dom.addEventListener("keydown", discardRefreshSelection, true); | ||
| 187 | instance.dom.addEventListener("beforeinput", discardRefreshSelection, true); | ||
| 157 | return () => { | 188 | return () => { |
| 158 | alive = false; | 189 | alive = false; |
| 159 | editor.current = null; | 190 | editor.current = null; |
| 191 | instance.dom.removeEventListener("pointerdown", discardRefreshSelection, true); | ||
| 192 | instance.dom.removeEventListener("keydown", discardRefreshSelection, true); | ||
| 193 | instance.dom.removeEventListener("beforeinput", discardRefreshSelection, true); | ||
| 160 | instance.destroy(); | 194 | instance.destroy(); |
| 161 | }; | 195 | }; |
| 162 | }, []); | 196 | }, [discardRefreshSelection]); |
| 163 | 197 | ||
| 164 | // oxlint-disable react/exhaustive-effect-dependencies -- Editor callbacks read the ref; DOM configuration follows these props. | 198 | // oxlint-disable react/exhaustive-effect-dependencies -- Editor callbacks read the ref; DOM configuration follows these props. |
| 165 | useLayoutEffect(() => { | 199 | useLayoutEffect(() => { |
| ... | @@ -180,6 +214,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { | ... | @@ -180,6 +214,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { |
| 180 | if (!instance) return; | 214 | if (!instance) return; |
| 181 | const snapshot = instance.snapshot(); | 215 | const snapshot = instance.snapshot(); |
| 182 | if (snapshot.value !== props.value) { | 216 | if (snapshot.value !== props.value) { |
| 217 | if (restoringRefreshSelection.current) discardRefreshSelection(); | ||
| 183 | const draft = citationDraft.current; | 218 | const draft = citationDraft.current; |
| 184 | citationDraft.current = null; | 219 | citationDraft.current = null; |
| 185 | const saveDraft = currentProps.current.onCitationDraftSave; | 220 | const saveDraft = currentProps.current.onCitationDraftSave; |
| ... | @@ -229,7 +264,11 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { | ... | @@ -229,7 +264,11 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { |
| 229 | }, | 264 | }, |
| 230 | annotations: [isolateHistory.of("full"), Transaction.userEvent.of("t3.controlled")], | 265 | annotations: [isolateHistory.of("full"), Transaction.userEvent.of("t3.controlled")], |
| 231 | }); | 266 | }); |
| 232 | } else if (snapshot.cursor !== props.cursor && !instance.view.compositionStarted) { | 267 | } else if ( |
| 268 | snapshot.cursor !== props.cursor && | ||
| 269 | !instance.view.compositionStarted && | ||
| 270 | !restoringRefreshSelection.current | ||
| 271 | ) { | ||
| 233 | instance.view.dispatch({ | 272 | instance.view.dispatch({ |
| 234 | selection: { | 273 | selection: { |
| 235 | anchor: expandCollapsedComposerCursor(props.value, props.cursor, props.literalText), | 274 | anchor: expandCollapsedComposerCursor(props.value, props.cursor, props.literalText), |
| ... | @@ -251,7 +290,66 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { | ... | @@ -251,7 +290,66 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { |
| 251 | insertedSpaces: request.insertedSpaces, | 290 | insertedSpaces: request.insertedSpaces, |
| 252 | }); | 291 | }); |
| 253 | } | 292 | } |
| 254 | }, [props.value, props.cursor, props.literalText]); | 293 | }, [props.value, props.cursor, props.literalText, discardRefreshSelection]); |
| 294 | |||
| 295 | useLayoutEffect(() => { | ||
| 296 | const instance = editor.current; | ||
| 297 | if (!instance || !props.persistenceKey) return; | ||
| 298 | const storageKey = `${REFRESH_SELECTION_STORAGE_PREFIX}${props.persistenceKey}`; | ||
| 299 | const value = props.value; | ||
| 300 | try { | ||
| 301 | const saved = window.sessionStorage.getItem(storageKey); | ||
| 302 | if (saved) { | ||
| 303 | const selection = decodeRefreshSelection(saved); | ||
| 304 | if ( | ||
| 305 | selection.pageOrigin !== performance.timeOrigin && | ||
| 306 | selection.value === value && | ||
| 307 | selection.anchor >= 0 && | ||
| 308 | selection.anchor <= value.length && | ||
| 309 | selection.head >= 0 && | ||
| 310 | selection.head <= value.length | ||
| 311 | ) { | ||
| 312 | // Mount-time cursor resets and autofocus must not replace a restored selection. | ||
| 313 | restoringRefreshSelection.current = true; | ||
| 314 | instance.view.dispatch({ selection: { anchor: selection.anchor, head: selection.head } }); | ||
| 315 | instance.view.scrollDOM.scrollTop = selection.scrollTop; | ||
| 316 | instance.view.scrollDOM.scrollLeft = selection.scrollLeft; | ||
| 317 | } | ||
| 318 | } | ||
| 319 | } catch { | ||
| 320 | // An unreadable snapshot leaves the editor at its ordinary initial caret. | ||
| 321 | } | ||
| 322 | }, [props.persistenceKey, props.value]); | ||
| 323 | |||
| 324 | useLayoutEffect(() => { | ||
| 325 | const instance = editor.current; | ||
| 326 | if (!instance || !props.persistenceKey) return; | ||
| 327 | const storageKey = `${REFRESH_SELECTION_STORAGE_PREFIX}${props.persistenceKey}`; | ||
| 328 | const save = (event: Event) => { | ||
| 329 | if (instance.view.compositionStarted || citationDraft.current || comment) { | ||
| 330 | event.preventDefault(); | ||
| 331 | return; | ||
| 332 | } | ||
| 333 | try { | ||
| 334 | const selection = instance.view.state.selection.main; | ||
| 335 | window.sessionStorage.setItem( | ||
| 336 | storageKey, | ||
| 337 | JSON.stringify({ | ||
| 338 | pageOrigin: performance.timeOrigin, | ||
| 339 | value: instance.view.state.doc.toString(), | ||
| 340 | anchor: selection.anchor, | ||
| 341 | head: selection.head, | ||
| 342 | scrollTop: instance.view.scrollDOM.scrollTop, | ||
| 343 | scrollLeft: instance.view.scrollDOM.scrollLeft, | ||
| 344 | }), | ||
| 345 | ); | ||
| 346 | } catch { | ||
| 347 | event.preventDefault(); | ||
| 348 | } | ||
| 349 | }; | ||
| 350 | window.addEventListener(PREPARE_UI_REFRESH_EVENT, save); | ||
| 351 | return () => window.removeEventListener(PREPARE_UI_REFRESH_EVENT, save); | ||
| 352 | }, [props.persistenceKey, comment]); | ||
| 255 | 353 | ||
| 256 | useLayoutEffect(() => { | 354 | useLayoutEffect(() => { |
| 257 | editor.current?.view.requestMeasure(); | 355 | editor.current?.view.requestMeasure(); |
| ... | @@ -264,6 +362,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { | ... | @@ -264,6 +362,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { |
| 264 | focusAt: (cursor) => { | 362 | focusAt: (cursor) => { |
| 265 | const instance = editor.current; | 363 | const instance = editor.current; |
| 266 | if (!instance) return; | 364 | if (!instance) return; |
| 365 | discardRefreshSelection(); | ||
| 267 | const snapshot = instance.snapshot(); | 366 | const snapshot = instance.snapshot(); |
| 268 | instance.view.dispatch({ | 367 | instance.view.dispatch({ |
| 269 | selection: { | 368 | selection: { |
| ... | @@ -280,6 +379,10 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { | ... | @@ -280,6 +379,10 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { |
| 280 | focusAtEnd: () => { | 379 | focusAtEnd: () => { |
| 281 | const instance = editor.current; | 380 | const instance = editor.current; |
| 282 | if (!instance) return; | 381 | if (!instance) return; |
| 382 | if (restoringRefreshSelection.current) { | ||
| 383 | instance.focus(); | ||
| 384 | return; | ||
| 385 | } | ||
| 283 | instance.view.dispatch({ | 386 | instance.view.dispatch({ |
| 284 | selection: { anchor: instance.view.state.doc.length }, | 387 | selection: { anchor: instance.view.state.doc.length }, |
| 285 | scrollIntoView: true, | 388 | scrollIntoView: true, |
| ... | @@ -337,7 +440,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { | ... | @@ -337,7 +440,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { |
| 337 | ); | 440 | ); |
| 338 | }, | 441 | }, |
| 339 | }), | 442 | }), |
| 340 | [props.editorRef, props.value, props.cursor, props.literalText], | 443 | [props.editorRef, props.value, props.cursor, props.literalText, discardRefreshSelection], |
| 341 | ); | 444 | ); |
| 342 | 445 | ||
| 343 | const copy = (event: React.ClipboardEvent<HTMLElement>, cut: boolean) => { | 446 | const copy = (event: React.ClipboardEvent<HTMLElement>, cut: boolean) => { |
| ... | @@ -381,7 +484,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { | ... | @@ -381,7 +484,7 @@ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { |
| 381 | onKeyUp={(event) => props.onPageScrollKeyUp?.(event.key)} | 484 | onKeyUp={(event) => props.onPageScrollKeyUp?.(event.key)} |
| 382 | onBlur={props.onPageScrollRelease} | 485 | onBlur={props.onPageScrollRelease} |
| 383 | > | 486 | > |
| 384 | <div ref={host} /> | 487 | <div ref={host} data-ui-refresh-composer={props.persistenceKey} /> |
| 385 | {props.value.length === 0 && props.contextRecords.size === 0 && props.placeholder ? ( | 488 | {props.value.length === 0 && props.contextRecords.size === 0 && props.placeholder ? ( |
| 386 | <div | 489 | <div |
| 387 | className={cn( | 490 | className={cn( |
apps/web/src/components/chat/ChatComposer.tsx+12| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; | 1 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; |
| 2 | import { useComposerTypingGuard } from "./useComposerTypingGuard"; | 2 | import { useComposerTypingGuard } from "./useComposerTypingGuard"; |
| 3 | import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; | 3 | import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; |
| 4 | import { PREPARE_UI_REFRESH_EVENT } from "../../uiRefresh"; | ||
| 4 | import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig"; | 5 | import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig"; |
| 5 | import { isLocalEnvironmentDisabled } from "../../localEnvironment"; | 6 | import { isLocalEnvironmentDisabled } from "../../localEnvironment"; |
| 6 | import { usePrimaryEnvironmentId } from "../../state/environments"; | 7 | import { usePrimaryEnvironmentId } from "../../state/environments"; |
| ... | @@ -2568,6 +2569,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2568,6 +2569,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2568 | const onBlur = () => { | 2569 | const onBlur = () => { |
| 2569 | pasteAsTextShortcutUntilRef.current = 0; | 2570 | pasteAsTextShortcutUntilRef.current = 0; |
| 2570 | }; | 2571 | }; |
| 2572 | const onPrepareUiRefresh = (event: Event) => { | ||
| 2573 | if ( | ||
| 2574 | pendingImageCompressionsRef.current.size > 0 || | ||
| 2575 | pendingThreadImportsRef.current.size > 0 || | ||
| 2576 | stashInFlightRef.current.size > 0 | ||
| 2577 | ) | ||
| 2578 | event.preventDefault(); | ||
| 2579 | }; | ||
| 2571 | const onDesktopPasteAsText = () => { | 2580 | const onDesktopPasteAsText = () => { |
| 2572 | const activeElement = document.activeElement; | 2581 | const activeElement = document.activeElement; |
| 2573 | const blocksPasteToFocus = | 2582 | const blocksPasteToFocus = |
| ... | @@ -2585,10 +2594,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2585,10 +2594,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2585 | window.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); | 2594 | window.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); |
| 2586 | window.addEventListener("keydown", onKeyDown, true); | 2595 | window.addEventListener("keydown", onKeyDown, true); |
| 2587 | window.addEventListener("blur", onBlur); | 2596 | window.addEventListener("blur", onBlur); |
| 2597 | window.addEventListener(PREPARE_UI_REFRESH_EVENT, onPrepareUiRefresh); | ||
| 2588 | return () => { | 2598 | return () => { |
| 2589 | window.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); | 2599 | window.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); |
| 2590 | window.removeEventListener("keydown", onKeyDown, true); | 2600 | window.removeEventListener("keydown", onKeyDown, true); |
| 2591 | window.removeEventListener("blur", onBlur); | 2601 | window.removeEventListener("blur", onBlur); |
| 2602 | window.removeEventListener(PREPARE_UI_REFRESH_EVENT, onPrepareUiRefresh); | ||
| 2592 | }; | 2603 | }; |
| 2593 | }, []); | 2604 | }, []); |
| 2594 | 2605 | ||
| ... | @@ -7691,6 +7702,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -7691,6 +7702,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 7691 | ) : null} | 7702 | ) : null} |
| 7692 | <ComposerContextActionsContext value={composerContextActions}> | 7703 | <ComposerContextActionsContext value={composerContextActions}> |
| 7693 | <ComposerPromptEditor | 7704 | <ComposerPromptEditor |
| 7705 | persistenceKey={activePendingProgress ? undefined : composerDraftTargetKey} | ||
| 7694 | key={`${composerSuggestionListId}:${activePendingProgress?.activeQuestion?.id ?? "thread"}`} | 7706 | key={`${composerSuggestionListId}:${activePendingProgress?.activeQuestion?.id ?? "thread"}`} |
| 7695 | ariaLabel="Message" | 7707 | ariaLabel="Message" |
| 7696 | suggestionListId={composerSuggestionListId} | 7708 | suggestionListId={composerSuggestionListId} |
apps/web/src/components/chat/MessagesTimeline.test.tsx+70-2| ... | @@ -896,6 +896,76 @@ describe("MessagesTimeline", () => { | ... | @@ -896,6 +896,76 @@ describe("MessagesTimeline", () => { |
| 896 | } | 896 | } |
| 897 | }); | 897 | }); |
| 898 | 898 | ||
| 899 | it("loads missing history before restoring a saved reading anchor", async () => { | ||
| 900 | const { rememberTimelinePosition } = await import("./timelineScrollAnchoring"); | ||
| 901 | const props = { ...buildProps(), routeThreadKey: "environment-local:refresh-old-history" }; | ||
| 902 | rememberTimelinePosition( | ||
| 903 | props.routeThreadKey, | ||
| 904 | { | ||
| 905 | rowId: "entry-old", | ||
| 906 | offsetWithinRow: 20, | ||
| 907 | scrollOffset: 700, | ||
| 908 | atEnd: false, | ||
| 909 | }, | ||
| 910 | false, | ||
| 911 | ); | ||
| 912 | const viewport = { | ||
| 913 | scrollTop: 0, | ||
| 914 | ownerDocument: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, | ||
| 915 | addEventListener: vi.fn(), | ||
| 916 | removeEventListener: vi.fn(), | ||
| 917 | getBoundingClientRect: () => ({ top: 0 }), | ||
| 918 | }; | ||
| 919 | const scrollToIndex = vi.fn(async () => {}); | ||
| 920 | const scrollToOffset = vi.fn(async () => {}); | ||
| 921 | props.listRef.current = { | ||
| 922 | getScrollableNode: () => viewport, | ||
| 923 | getState: () => undefined, | ||
| 924 | scrollToIndex, | ||
| 925 | scrollToOffset, | ||
| 926 | } as unknown as LegendListRef; | ||
| 927 | const onLoadEarlier = vi.fn(); | ||
| 928 | const historyControls = { hasMoreHistory: true, loading: false, error: null, onLoadEarlier }; | ||
| 929 | const latest = buildUserTimelineEntry("Latest prompt"); | ||
| 930 | let renderer: ReactTestRenderer | undefined; | ||
| 931 | try { | ||
| 932 | await act(() => { | ||
| 933 | renderer = create( | ||
| 934 | <MessagesTimeline | ||
| 935 | {...props} | ||
| 936 | timelineEntries={[latest]} | ||
| 937 | historyControls={historyControls} | ||
| 938 | />, | ||
| 939 | ); | ||
| 940 | }); | ||
| 941 | expect(onLoadEarlier).toHaveBeenCalledWith("entry-old"); | ||
| 942 | expect(scrollToOffset).not.toHaveBeenCalled(); | ||
| 943 | expect(scrollToIndex).not.toHaveBeenCalled(); | ||
| 944 | const older = { | ||
| 945 | ...buildUserTimelineEntry("Old prompt"), | ||
| 946 | id: "entry-old", | ||
| 947 | message: { ...latest.message, id: MessageId.make("old-message"), text: "Old prompt" }, | ||
| 948 | }; | ||
| 949 | await act(() => | ||
| 950 | renderer!.update( | ||
| 951 | <MessagesTimeline | ||
| 952 | {...props} | ||
| 953 | timelineEntries={[older, latest]} | ||
| 954 | historyControls={{ ...historyControls, hasMoreHistory: false }} | ||
| 955 | />, | ||
| 956 | ), | ||
| 957 | ); | ||
| 958 | expect(scrollToIndex).toHaveBeenCalledWith({ | ||
| 959 | index: 0, | ||
| 960 | animated: false, | ||
| 961 | viewPosition: 0, | ||
| 962 | viewOffset: -20, | ||
| 963 | }); | ||
| 964 | } finally { | ||
| 965 | await act(() => renderer?.unmount()); | ||
| 966 | } | ||
| 967 | }); | ||
| 968 | |||
| 899 | it("keeps a following thread's intent while its timeline is held for the next thread", async () => { | 969 | it("keeps a following thread's intent while its timeline is held for the next thread", async () => { |
| 900 | const { readTimelinePosition } = await import("./timelineScrollAnchoring"); | 970 | const { readTimelinePosition } = await import("./timelineScrollAnchoring"); |
| 901 | const timeline = stubFollowTimeline("environment-local:held-departure"); | 971 | const timeline = stubFollowTimeline("environment-local:held-departure"); |
| ... | @@ -2647,8 +2717,6 @@ describe("MessagesTimeline", () => { | ... | @@ -2647,8 +2717,6 @@ describe("MessagesTimeline", () => { |
| 2647 | />, | 2717 | />, |
| 2648 | ); | 2718 | ); |
| 2649 | 2719 | ||
| 2650 | // The T3 wordmark replaces the generic tool icon for T3 MCP calls. | ||
| 2651 | expect(markup).toContain('viewBox="15.5309 37 94.3941 56.96"'); | ||
| 2652 | expect(markup).toContain("Read a T3 thread"); | 2720 | expect(markup).toContain("Read a T3 thread"); |
| 2653 | expect(markup).not.toContain("mcp__t3-code__t3_thread_read"); | 2721 | expect(markup).not.toContain("mcp__t3-code__t3_thread_read"); |
| 2654 | }); | 2722 | }); |
apps/web/src/components/chat/MessagesTimeline.tsx+22-11| ... | @@ -1003,6 +1003,26 @@ const ConversationTimeline = memo(function ConversationTimeline({ | ... | @@ -1003,6 +1003,26 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 1003 | const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1; | 1003 | const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1; |
| 1004 | if (position?.atEnd === false) onManualNavigation(); | 1004 | if (position?.atEnd === false) onManualNavigation(); |
| 1005 | if (cancelPositionRestoreRef) cancelPositionRestoreRef.current = cancelRestoration; | 1005 | if (cancelPositionRestoreRef) cancelPositionRestoreRef.current = cancelRestoration; |
| 1006 | const cleanup = () => { | ||
| 1007 | cancelled = true; | ||
| 1008 | if (cancelPositionRestoreRef?.current === cancelRestoration) { | ||
| 1009 | cancelPositionRestoreRef.current = null; | ||
| 1010 | } | ||
| 1011 | if (settleFrame !== null) cancelAnimationFrame(settleFrame); | ||
| 1012 | viewport?.removeEventListener("wheel", cancelForNavigation); | ||
| 1013 | viewport?.removeEventListener("touchmove", cancelForNavigation); | ||
| 1014 | viewport?.removeEventListener("pointerdown", cancelForNavigation); | ||
| 1015 | viewport?.ownerDocument.removeEventListener("keydown", onScrollKey); | ||
| 1016 | }; | ||
| 1017 | if ( | ||
| 1018 | position?.atEnd === false && | ||
| 1019 | index < 0 && | ||
| 1020 | historyControls?.hasMoreHistory && | ||
| 1021 | !historyControls.error | ||
| 1022 | ) { | ||
| 1023 | if (!historyControls.loading) historyControls.onLoadEarlier(position.rowId); | ||
| 1024 | return cleanup; | ||
| 1025 | } | ||
| 1006 | const scrolling = | 1026 | const scrolling = |
| 1007 | position?.atEnd === false | 1027 | position?.atEnd === false |
| 1008 | ? index >= 0 | 1028 | ? index >= 0 |
| ... | @@ -1055,23 +1075,14 @@ const ConversationTimeline = memo(function ConversationTimeline({ | ... | @@ -1055,23 +1075,14 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 1055 | }; | 1075 | }; |
| 1056 | settleFrame = requestAnimationFrame(reconcile); | 1076 | settleFrame = requestAnimationFrame(reconcile); |
| 1057 | }); | 1077 | }); |
| 1058 | return () => { | 1078 | return cleanup; |
| 1059 | cancelled = true; | ||
| 1060 | if (cancelPositionRestoreRef?.current === cancelRestoration) { | ||
| 1061 | cancelPositionRestoreRef.current = null; | ||
| 1062 | } | ||
| 1063 | if (settleFrame !== null) cancelAnimationFrame(settleFrame); | ||
| 1064 | viewport?.removeEventListener("wheel", cancelForNavigation); | ||
| 1065 | viewport?.removeEventListener("touchmove", cancelForNavigation); | ||
| 1066 | viewport?.removeEventListener("pointerdown", cancelForNavigation); | ||
| 1067 | viewport?.ownerDocument.removeEventListener("keydown", onScrollKey); | ||
| 1068 | }; | ||
| 1069 | }, [ | 1079 | }, [ |
| 1070 | citationRequest, | 1080 | citationRequest, |
| 1071 | cancelPositionRestoreRef, | 1081 | cancelPositionRestoreRef, |
| 1072 | listIdentityKey, | 1082 | listIdentityKey, |
| 1073 | listRef, | 1083 | listRef, |
| 1074 | onManualNavigation, | 1084 | onManualNavigation, |
| 1085 | historyControls, | ||
| 1075 | rememberedPosition, | 1086 | rememberedPosition, |
| 1076 | restoringThreadPosition, | 1087 | restoringThreadPosition, |
| 1077 | rows, | 1088 | rows, |
apps/web/src/components/chat/timelineRefresh.test.ts created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | // @vitest-environment jsdom | ||
| 2 | |||
| 3 | import { RunAttemptId, RunId } from "@t3tools/contracts"; | ||
| 4 | import { afterEach, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | afterEach(() => { | ||
| 7 | sessionStorage.clear(); | ||
| 8 | vi.resetModules(); | ||
| 9 | }); | ||
| 10 | |||
| 11 | it("restores the reading anchor, expanded work and nested scroll after a module reload", async () => { | ||
| 12 | const timeline = await import("./timelineScrollAnchoring"); | ||
| 13 | const reading = { | ||
| 14 | rowId: "message-50", | ||
| 15 | offsetWithinRow: 23, | ||
| 16 | scrollOffset: 800, | ||
| 17 | atEnd: false, | ||
| 18 | disclosures: { | ||
| 19 | runs: new Set([RunId.make("run-a")]), | ||
| 20 | workGroups: new Set(["work-a"]), | ||
| 21 | attempts: new Set([RunAttemptId.make("attempt-a")]), | ||
| 22 | workGroupState: { | ||
| 23 | scrollPositions: new Map([["work-a", { entryId: "tool-3", offset: 20 }]]), | ||
| 24 | expandedEntries: new Set(["tool-3"]), | ||
| 25 | }, | ||
| 26 | }, | ||
| 27 | }; | ||
| 28 | timeline.rememberTimelinePosition("environment:thread", reading, false); | ||
| 29 | timeline.persistTimelinePositionsForReload(); | ||
| 30 | vi.resetModules(); | ||
| 31 | const refreshed = await import("./timelineScrollAnchoring"); | ||
| 32 | expect(refreshed.readTimelinePosition("environment:thread")).toEqual(reading); | ||
| 33 | vi.resetModules(); | ||
| 34 | const later = await import("./timelineScrollAnchoring"); | ||
| 35 | expect(later.readTimelinePosition("environment:thread")).toBeUndefined(); | ||
| 36 | }); | ||
apps/web/src/components/chat/timelineScrollAnchoring.ts+87-3| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { MessageId, RunId } from "@t3tools/contracts"; | 1 | import { RunId, RunAttemptId, type MessageId } from "@t3tools/contracts"; |
| 2 | import * as Schema from "effect/Schema"; | ||
| 2 | 3 | ||
| 3 | export interface TimelineRunObservation { | 4 | export interface TimelineRunObservation { |
| 4 | readonly threadKey: string | null; | 5 | readonly threadKey: string | null; |
| ... | @@ -33,8 +34,6 @@ export function observeTimelineRun( | ... | @@ -33,8 +34,6 @@ export function observeTimelineRun( |
| 33 | } | 34 | } |
| 34 | return { observation, anchorMessageId: input.messageId }; | 35 | return { observation, anchorMessageId: input.messageId }; |
| 35 | } | 36 | } |
| 36 | import type { RunAttemptId } from "@t3tools/contracts"; | ||
| 37 | |||
| 38 | // Match the titlebar fade inset so draft promotion preserves the first row's position. | 37 | // Match the titlebar fade inset so draft promotion preserves the first row's position. |
| 39 | export const CHAT_TIMELINE_ANCHOR_OFFSET = 24; | 38 | export const CHAT_TIMELINE_ANCHOR_OFFSET = 24; |
| 40 | 39 | ||
| ... | @@ -161,6 +160,91 @@ export interface RememberedTimelinePosition { | ... | @@ -161,6 +160,91 @@ export interface RememberedTimelinePosition { |
| 161 | // Scoped thread keys keep separate environments independent. Bound the session cache. | 160 | // Scoped thread keys keep separate environments independent. Bound the session cache. |
| 162 | const rememberedTimelinePositions = new Map<string, RememberedTimelinePosition>(); | 161 | const rememberedTimelinePositions = new Map<string, RememberedTimelinePosition>(); |
| 163 | 162 | ||
| 163 | const REFRESH_POSITIONS_KEY = "t3code:ui-refresh-timeline:v1"; | ||
| 164 | const RefreshPositions = Schema.fromJsonString( | ||
| 165 | Schema.Record( | ||
| 166 | Schema.String, | ||
| 167 | Schema.Struct({ | ||
| 168 | rowId: Schema.String, | ||
| 169 | offsetWithinRow: Schema.Finite, | ||
| 170 | scrollOffset: Schema.Finite, | ||
| 171 | atEnd: Schema.Boolean, | ||
| 172 | disclosures: Schema.optionalKey( | ||
| 173 | Schema.Struct({ | ||
| 174 | runs: Schema.Array(RunId), | ||
| 175 | workGroups: Schema.Array(Schema.String), | ||
| 176 | attempts: Schema.Array(RunAttemptId), | ||
| 177 | workGroupState: Schema.Struct({ | ||
| 178 | scrollPositions: Schema.Array( | ||
| 179 | Schema.Tuple([ | ||
| 180 | Schema.String, | ||
| 181 | Schema.Struct({ entryId: Schema.String, offset: Schema.Finite }), | ||
| 182 | ]), | ||
| 183 | ), | ||
| 184 | expandedEntries: Schema.Array(Schema.String), | ||
| 185 | }), | ||
| 186 | }), | ||
| 187 | ), | ||
| 188 | }), | ||
| 189 | ), | ||
| 190 | ); | ||
| 191 | |||
| 192 | /** Save only at refresh time; scrolling keeps its existing in-memory cache. */ | ||
| 193 | export function persistTimelinePositionsForReload(): void { | ||
| 194 | const positions = Object.fromEntries( | ||
| 195 | [...rememberedTimelinePositions].map(([key, position]) => [ | ||
| 196 | key, | ||
| 197 | { | ||
| 198 | ...position, | ||
| 199 | ...(position.disclosures | ||
| 200 | ? { | ||
| 201 | disclosures: { | ||
| 202 | runs: [...position.disclosures.runs], | ||
| 203 | workGroups: [...position.disclosures.workGroups], | ||
| 204 | attempts: [...position.disclosures.attempts], | ||
| 205 | workGroupState: { | ||
| 206 | scrollPositions: [...position.disclosures.workGroupState.scrollPositions], | ||
| 207 | expandedEntries: [...position.disclosures.workGroupState.expandedEntries], | ||
| 208 | }, | ||
| 209 | }, | ||
| 210 | } | ||
| 211 | : {}), | ||
| 212 | }, | ||
| 213 | ]), | ||
| 214 | ); | ||
| 215 | window.sessionStorage.setItem(REFRESH_POSITIONS_KEY, JSON.stringify(positions)); | ||
| 216 | } | ||
| 217 | |||
| 218 | if (typeof window !== "undefined") { | ||
| 219 | try { | ||
| 220 | const saved = window.sessionStorage.getItem(REFRESH_POSITIONS_KEY); | ||
| 221 | window.sessionStorage.removeItem(REFRESH_POSITIONS_KEY); | ||
| 222 | if (saved) { | ||
| 223 | const positions = Schema.decodeSync(RefreshPositions)(saved); | ||
| 224 | for (const [key, { disclosures, ...position }] of Object.entries(positions)) { | ||
| 225 | rememberedTimelinePositions.set(key, { | ||
| 226 | ...position, | ||
| 227 | ...(disclosures | ||
| 228 | ? { | ||
| 229 | disclosures: { | ||
| 230 | runs: new Set(disclosures.runs), | ||
| 231 | workGroups: new Set(disclosures.workGroups), | ||
| 232 | attempts: new Set(disclosures.attempts), | ||
| 233 | workGroupState: { | ||
| 234 | scrollPositions: new Map(disclosures.workGroupState.scrollPositions), | ||
| 235 | expandedEntries: new Set(disclosures.workGroupState.expandedEntries), | ||
| 236 | }, | ||
| 237 | }, | ||
| 238 | } | ||
| 239 | : {}), | ||
| 240 | }); | ||
| 241 | } | ||
| 242 | } | ||
| 243 | } catch { | ||
| 244 | // Unreadable refresh state falls back to the normal initial position. | ||
| 245 | } | ||
| 246 | } | ||
| 247 | |||
| 164 | export function readTimelinePosition(threadKey: string) { | 248 | export function readTimelinePosition(threadKey: string) { |
| 165 | return rememberedTimelinePositions.get(threadKey); | 249 | return rememberedTimelinePositions.get(threadKey); |
| 166 | } | 250 | } |
apps/web/src/components/desktopUpdate.actions.test.ts created+81| ... | @@ -0,0 +1,81 @@ | ||
| 1 | import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; | ||
| 2 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | ||
| 3 | |||
| 4 | const mocks = vi.hoisted(() => ({ prepare: vi.fn(() => true), reload: vi.fn(), toast: vi.fn() })); | ||
| 5 | vi.mock("~/uiRefresh", () => ({ prepareUiRefresh: mocks.prepare })); | ||
| 6 | vi.mock("./ui/toast", () => ({ | ||
| 7 | toastManager: { add: mocks.toast }, | ||
| 8 | stackedThreadToast: (value: unknown) => value, | ||
| 9 | })); | ||
| 10 | vi.mock("./desktopUpdate.toast", () => ({ showDesktopUpdateDownloadedToast: mocks.toast })); | ||
| 11 | |||
| 12 | import { downloadLocalUpdate, installLocalUpdate } from "./desktopUpdate.actions"; | ||
| 13 | |||
| 14 | const state: DesktopUpdateState = { | ||
| 15 | enabled: true, | ||
| 16 | status: "downloaded", | ||
| 17 | channel: "latest", | ||
| 18 | currentVersion: "0.0.1", | ||
| 19 | hostArch: "arm64", | ||
| 20 | appArch: "arm64", | ||
| 21 | runningUnderArm64Translation: false, | ||
| 22 | availableVersion: "0.0.2", | ||
| 23 | downloadedVersion: "0.0.2", | ||
| 24 | installKind: "ui", | ||
| 25 | releaseNotes: [], | ||
| 26 | omittedReleaseCount: 0, | ||
| 27 | downloadPercent: 100, | ||
| 28 | checkedAt: null, | ||
| 29 | message: null, | ||
| 30 | errorContext: null, | ||
| 31 | canRetry: true, | ||
| 32 | }; | ||
| 33 | const result: DesktopUpdateActionResult = { | ||
| 34 | accepted: true, | ||
| 35 | completed: true, | ||
| 36 | state: { ...state, status: "idle", uiVersion: "0.0.2" }, | ||
| 37 | }; | ||
| 38 | |||
| 39 | beforeEach(() => { | ||
| 40 | mocks.prepare.mockReset().mockReturnValue(true); | ||
| 41 | mocks.reload.mockReset(); | ||
| 42 | mocks.toast.mockReset(); | ||
| 43 | vi.stubGlobal("window", { location: { reload: mocks.reload } }); | ||
| 44 | }); | ||
| 45 | afterEach(() => vi.unstubAllGlobals()); | ||
| 46 | |||
| 47 | it("downloads a UI release without activating it", async () => { | ||
| 48 | const bridge = { | ||
| 49 | openExternal: vi.fn(async () => true), | ||
| 50 | downloadUpdate: vi.fn(async () => ({ ...result, state })), | ||
| 51 | }; | ||
| 52 | expect(await downloadLocalUpdate(bridge, { notifyDownloaded: false })).toBe(true); | ||
| 53 | expect(mocks.reload).not.toHaveBeenCalled(); | ||
| 54 | expect(mocks.prepare).not.toHaveBeenCalled(); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("activates an automatic update only when state can roundtrip", async () => { | ||
| 58 | const bridge = { | ||
| 59 | openExternal: vi.fn(async () => true), | ||
| 60 | installUpdate: vi.fn(async () => result), | ||
| 61 | }; | ||
| 62 | mocks.prepare.mockReturnValue(false); | ||
| 63 | expect(await installLocalUpdate(bridge, undefined, { automatic: true })).toBe(false); | ||
| 64 | expect(bridge.installUpdate).not.toHaveBeenCalled(); | ||
| 65 | mocks.prepare.mockReturnValue(true); | ||
| 66 | expect(await installLocalUpdate(bridge, undefined, { automatic: true })).toBe(true); | ||
| 67 | expect(mocks.reload).toHaveBeenCalledOnce(); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it("keeps the current UI if unsafe state appears while activation is in progress", async () => { | ||
| 71 | const bridge = { | ||
| 72 | openExternal: vi.fn(async () => true), | ||
| 73 | installUpdate: vi.fn(async () => { | ||
| 74 | mocks.prepare.mockReturnValue(false); | ||
| 75 | return result; | ||
| 76 | }), | ||
| 77 | }; | ||
| 78 | expect(await installLocalUpdate(bridge, undefined, { automatic: true })).toBe(true); | ||
| 79 | expect(bridge.installUpdate).toHaveBeenCalledOnce(); | ||
| 80 | expect(mocks.reload).not.toHaveBeenCalled(); | ||
| 81 | }); | ||
apps/web/src/components/desktopUpdate.actions.ts+10-7| ... | @@ -9,14 +9,15 @@ import { | ... | @@ -9,14 +9,15 @@ import { |
| 9 | } from "./desktopUpdate.logic"; | 9 | } from "./desktopUpdate.logic"; |
| 10 | import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; | 10 | import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; |
| 11 | import type { ReportLocalUpdateError } from "./sidebar/useUpdateEverything"; | 11 | import type { ReportLocalUpdateError } from "./sidebar/useUpdateEverything"; |
| 12 | import { prepareUiRefresh } from "~/uiRefresh"; | ||
| 12 | 13 | ||
| 13 | export const toastUpdateError: ReportLocalUpdateError = (title, description) => { | 14 | export const toastUpdateError: ReportLocalUpdateError = (title, description) => { |
| 14 | toastManager.add(stackedThreadToast({ type: "error", title, description })); | 15 | toastManager.add(stackedThreadToast({ type: "error", title, description })); |
| 15 | }; | 16 | }; |
| 16 | 17 | ||
| 17 | /** Prepares a full-app update or applies a compatible UI without stopping tasks. */ | 18 | /** Downloads updates; activation waits for confirmation or a safe idle refresh. */ |
| 18 | export async function downloadLocalUpdate( | 19 | export async function downloadLocalUpdate( |
| 19 | bridge: DesktopBridge, | 20 | bridge: Pick<DesktopBridge, "downloadUpdate" | "openExternal">, |
| 20 | { | 21 | { |
| 21 | notifyDownloaded, | 22 | notifyDownloaded, |
| 22 | reportError = toastUpdateError, | 23 | reportError = toastUpdateError, |
| ... | @@ -24,9 +25,6 @@ export async function downloadLocalUpdate( | ... | @@ -24,9 +25,6 @@ export async function downloadLocalUpdate( |
| 24 | ): Promise<boolean> { | 25 | ): Promise<boolean> { |
| 25 | try { | 26 | try { |
| 26 | const result = await bridge.downloadUpdate(); | 27 | const result = await bridge.downloadUpdate(); |
| 27 | if (result.completed && result.state.installKind === "ui") { | ||
| 28 | return await installLocalUpdate(bridge, reportError); | ||
| 29 | } | ||
| 30 | if (result.completed && notifyDownloaded) { | 28 | if (result.completed && notifyDownloaded) { |
| 31 | showDesktopUpdateDownloadedToast(bridge, result.state); | 29 | showDesktopUpdateDownloadedToast(bridge, result.state); |
| 32 | } | 30 | } |
| ... | @@ -46,10 +44,12 @@ export async function downloadLocalUpdate( | ... | @@ -46,10 +44,12 @@ export async function downloadLocalUpdate( |
| 46 | 44 | ||
| 47 | /** Applies a prepared UI in place or starts an app relaunch after the caller confirms. */ | 45 | /** Applies a prepared UI in place or starts an app relaunch after the caller confirms. */ |
| 48 | export async function installLocalUpdate( | 46 | export async function installLocalUpdate( |
| 49 | bridge: DesktopBridge, | 47 | bridge: Pick<DesktopBridge, "installUpdate" | "openExternal">, |
| 50 | reportError: ReportLocalUpdateError = toastUpdateError, | 48 | reportError: ReportLocalUpdateError = toastUpdateError, |
| 49 | options: { readonly automatic?: boolean } = {}, | ||
| 51 | ): Promise<boolean> { | 50 | ): Promise<boolean> { |
| 52 | try { | 51 | try { |
| 52 | if (options.automatic && !prepareUiRefresh()) return false; | ||
| 53 | const result = await bridge.installUpdate(); | 53 | const result = await bridge.installUpdate(); |
| 54 | if (!result.accepted) { | 54 | if (!result.accepted) { |
| 55 | showDesktopUpdateDownloadedToast(bridge, result.state); | 55 | showDesktopUpdateDownloadedToast(bridge, result.state); |
| ... | @@ -59,7 +59,10 @@ export async function installLocalUpdate( | ... | @@ -59,7 +59,10 @@ export async function installLocalUpdate( |
| 59 | ? getDesktopUpdateActionError(result) | 59 | ? getDesktopUpdateActionError(result) |
| 60 | : null; | 60 | : null; |
| 61 | if (!actionError) { | 61 | if (!actionError) { |
| 62 | if (result.completed && result.state.uiVersion) window.location.reload(); | 62 | if (result.completed && result.state.uiVersion) { |
| 63 | const preserved = prepareUiRefresh(); | ||
| 64 | if (!options.automatic || preserved) window.location.reload(); | ||
| 65 | } | ||
| 63 | return true; | 66 | return true; |
| 64 | } | 67 | } |
| 65 | reportError("Could not install update", actionError); | 68 | reportError("Could not install update", actionError); |
apps/web/src/composerDraftStore.ts+32| ... | @@ -130,6 +130,38 @@ const composerPersistStorage: PersistStorage<ComposerPersistState> = { | ... | @@ -130,6 +130,38 @@ const composerPersistStorage: PersistStorage<ComposerPersistState> = { |
| 130 | removeItem: (name) => composerDebouncedStorage.removeItem(name), | 130 | removeItem: (name) => composerDebouncedStorage.removeItem(name), |
| 131 | }; | 131 | }; |
| 132 | 132 | ||
| 133 | /** A reload is safe only after every draft byte is durably represented in storage. */ | ||
| 134 | export function prepareComposerDraftsForReload(): boolean { | ||
| 135 | const state = useComposerDraftStore.getState(); | ||
| 136 | if ( | ||
| 137 | state.rewindingThreadKeys.size > 0 || | ||
| 138 | Object.keys(state.backgroundSubmissionThreadKeys).length > 0 || | ||
| 139 | Object.values(state.draftThreadsByThreadKey).some(isDraftThreadPromoting) || | ||
| 140 | Object.values(state.draftsByThreadKey).some( | ||
| 141 | (draft) => | ||
| 142 | draft.nonPersistedImageIds.length > 0 || | ||
| 143 | draft.images.some((image) => !draft.persistedAttachments.some(({ id }) => id === image.id)) || | ||
| 144 | draft.files.some((file) => !file.uploadedAttachmentId || !file.uploadEnvironmentId), | ||
| 145 | ) | ||
| 146 | ) | ||
| 147 | return false; | ||
| 148 | |||
| 149 | try { | ||
| 150 | composerPersistStorage.setItem(COMPOSER_DRAFT_STORAGE_KEY, { | ||
| 151 | state: { capturedState: state }, | ||
| 152 | version: COMPOSER_DRAFT_STORAGE_VERSION, | ||
| 153 | }); | ||
| 154 | composerDebouncedStorage.flush(); | ||
| 155 | const expected = JSON.stringify({ | ||
| 156 | state: partializeComposerDraftStoreState(state), | ||
| 157 | version: COMPOSER_DRAFT_STORAGE_VERSION, | ||
| 158 | }); | ||
| 159 | return window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY) === expected; | ||
| 160 | } catch { | ||
| 161 | return false; | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 133 | // Flush pending composer draft writes before page unload to prevent data loss. | 165 | // Flush pending composer draft writes before page unload to prevent data loss. |
| 134 | if (typeof window !== "undefined" && typeof window.addEventListener === "function") { | 166 | if (typeof window !== "undefined" && typeof window.addEventListener === "function") { |
| 135 | window.addEventListener("beforeunload", () => { | 167 | window.addEventListener("beforeunload", () => { |
apps/web/src/composerReload.test.ts created+67| ... | @@ -0,0 +1,67 @@ | ||
| 1 | // @vitest-environment jsdom | ||
| 2 | |||
| 3 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | ||
| 4 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | ||
| 6 | import { | ||
| 7 | COMPOSER_DRAFT_STORAGE_KEY, | ||
| 8 | prepareComposerDraftsForReload, | ||
| 9 | useComposerDraftStore, | ||
| 10 | } from "./composerDraftStore"; | ||
| 11 | |||
| 12 | const target = scopeThreadRef(EnvironmentId.make("refresh-host"), ThreadId.make("refresh-thread")); | ||
| 13 | |||
| 14 | beforeEach(() => { | ||
| 15 | vi.useFakeTimers(); | ||
| 16 | useComposerDraftStore.setState({ | ||
| 17 | draftsByThreadKey: {}, | ||
| 18 | draftThreadsByThreadKey: {}, | ||
| 19 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | ||
| 20 | }); | ||
| 21 | }); | ||
| 22 | afterEach(() => { | ||
| 23 | vi.restoreAllMocks(); | ||
| 24 | vi.useRealTimers(); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("flushes current text synchronously and confirms it survives rehydration", async () => { | ||
| 28 | useComposerDraftStore.getState().setPrompt(target, "latest keystroke"); | ||
| 29 | expect(prepareComposerDraftsForReload()).toBe(true); | ||
| 30 | expect(localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY)).toContain("latest keystroke"); | ||
| 31 | useComposerDraftStore.setState({ draftsByThreadKey: {} }); | ||
| 32 | await useComposerDraftStore.persist.rehydrate(); | ||
| 33 | expect(useComposerDraftStore.getState().getComposerDraft(target)?.prompt).toBe( | ||
| 34 | "latest keystroke", | ||
| 35 | ); | ||
| 36 | }); | ||
| 37 | |||
| 38 | it("defers while file bytes exist only locally, then permits their uploaded reference", () => { | ||
| 39 | const file = new File(["private bytes"], "note.txt", { type: "text/plain" }); | ||
| 40 | useComposerDraftStore | ||
| 41 | .getState() | ||
| 42 | .addFiles(target, [ | ||
| 43 | { | ||
| 44 | type: "file", | ||
| 45 | id: "note", | ||
| 46 | name: file.name, | ||
| 47 | mimeType: file.type, | ||
| 48 | sizeBytes: file.size, | ||
| 49 | file, | ||
| 50 | }, | ||
| 51 | ]); | ||
| 52 | expect(prepareComposerDraftsForReload()).toBe(false); | ||
| 53 | useComposerDraftStore | ||
| 54 | .getState() | ||
| 55 | .setFileUpload(target, "note", target.environmentId, "uploaded-note"); | ||
| 56 | expect(prepareComposerDraftsForReload()).toBe(true); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("defers when storage rejects or silently drops the flushed draft", () => { | ||
| 60 | useComposerDraftStore.getState().setPrompt(target, "must survive"); | ||
| 61 | const write = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { | ||
| 62 | throw new DOMException("full", "QuotaExceededError"); | ||
| 63 | }); | ||
| 64 | expect(prepareComposerDraftsForReload()).toBe(false); | ||
| 65 | write.mockImplementation(() => {}); | ||
| 66 | expect(prepareComposerDraftsForReload()).toBe(false); | ||
| 67 | }); | ||
apps/web/src/routes/__root.tsx+2| ... | @@ -32,6 +32,7 @@ import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkC | ... | @@ -32,6 +32,7 @@ import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkC |
| 32 | import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; | 32 | import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; |
| 33 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; | 33 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 34 | import { DesktopUpdateLaunchNotice } from "../components/sidebar/updateEverythingRun"; | 34 | import { DesktopUpdateLaunchNotice } from "../components/sidebar/updateEverythingRun"; |
| 35 | import { AutomaticUiRefresh } from "../components/AutomaticUiRefresh"; | ||
| 35 | import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; | 36 | import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; |
| 36 | import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast"; | 37 | import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast"; |
| 37 | import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; | 38 | import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; |
| ... | @@ -261,6 +262,7 @@ function RootRouteView() { | ... | @@ -261,6 +262,7 @@ function RootRouteView() { |
| 261 | ) : null} | 262 | ) : null} |
| 262 | {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null} | 263 | {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null} |
| 263 | {isElectron ? <DesktopUpdateLaunchNotice /> : null} | 264 | {isElectron ? <DesktopUpdateLaunchNotice /> : null} |
| 265 | {isElectron ? <AutomaticUiRefresh /> : null} | ||
| 264 | {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */} | 266 | {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */} |
| 265 | <NightlyMobileBetaNotice /> | 267 | <NightlyMobileBetaNotice /> |
| 266 | {appShell} | 268 | {appShell} |
apps/web/src/uiRefresh.test.ts created+190| ... | @@ -0,0 +1,190 @@ | ||
| 1 | // @vitest-environment jsdom | ||
| 2 | |||
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | const mocks = vi.hoisted(() => ({ | ||
| 6 | drafts: vi.fn(() => true), | ||
| 7 | timeline: vi.fn(), | ||
| 8 | recordings: vi.fn(() => new Set<string>()), | ||
| 9 | })); | ||
| 10 | vi.mock("./composerDraftStore", () => ({ prepareComposerDraftsForReload: mocks.drafts })); | ||
| 11 | vi.mock("./components/chat/timelineScrollAnchoring", () => ({ | ||
| 12 | persistTimelinePositionsForReload: mocks.timeline, | ||
| 13 | })); | ||
| 14 | vi.mock("./browser/browserRecording", () => ({ | ||
| 15 | readActiveBrowserRecordingTabIds: mocks.recordings, | ||
| 16 | })); | ||
| 17 | |||
| 18 | import { PREPARE_UI_REFRESH_EVENT, prepareUiRefresh, startAutomaticUiRefresh } from "./uiRefresh"; | ||
| 19 | |||
| 20 | let focused = true; | ||
| 21 | let controller: ReturnType<typeof startAutomaticUiRefresh> | undefined; | ||
| 22 | |||
| 23 | beforeEach(() => { | ||
| 24 | vi.useFakeTimers(); | ||
| 25 | focused = true; | ||
| 26 | vi.spyOn(document, "hasFocus").mockImplementation(() => focused); | ||
| 27 | Object.defineProperty(document, "hidden", { configurable: true, value: false }); | ||
| 28 | document.body.replaceChildren(); | ||
| 29 | mocks.drafts.mockReset().mockReturnValue(true); | ||
| 30 | mocks.timeline.mockReset(); | ||
| 31 | mocks.recordings.mockReset().mockReturnValue(new Set()); | ||
| 32 | }); | ||
| 33 | afterEach(() => { | ||
| 34 | controller?.dispose(); | ||
| 35 | controller = undefined; | ||
| 36 | vi.restoreAllMocks(); | ||
| 37 | vi.useRealTimers(); | ||
| 38 | }); | ||
| 39 | |||
| 40 | describe("automatic UI refresh", () => { | ||
| 41 | it("waits one uninterrupted unfocused minute with an update pending", async () => { | ||
| 42 | const apply = vi.fn(async () => {}); | ||
| 43 | controller = startAutomaticUiRefresh({ pending: () => true, apply }); | ||
| 44 | focused = false; | ||
| 45 | window.dispatchEvent(new Event("blur")); | ||
| 46 | await vi.advanceTimersByTimeAsync(59_999); | ||
| 47 | expect(apply).not.toHaveBeenCalled(); | ||
| 48 | focused = true; | ||
| 49 | window.dispatchEvent(new Event("focus")); | ||
| 50 | await vi.advanceTimersByTimeAsync(60_000); | ||
| 51 | expect(apply).not.toHaveBeenCalled(); | ||
| 52 | focused = false; | ||
| 53 | window.dispatchEvent(new Event("blur")); | ||
| 54 | await vi.advanceTimersByTimeAsync(60_000); | ||
| 55 | expect(apply).toHaveBeenCalledOnce(); | ||
| 56 | }); | ||
| 57 | |||
| 58 | it("resets the ten-minute idle threshold on input, and never applies a nonpending update", async () => { | ||
| 59 | let pending = false; | ||
| 60 | const apply = vi.fn(async () => { | ||
| 61 | pending = false; | ||
| 62 | }); | ||
| 63 | controller = startAutomaticUiRefresh({ pending: () => pending, apply }); | ||
| 64 | await vi.advanceTimersByTimeAsync(9 * 60_000); | ||
| 65 | window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" })); | ||
| 66 | pending = true; | ||
| 67 | controller.check(); | ||
| 68 | await vi.advanceTimersByTimeAsync(10 * 60_000 - 1); | ||
| 69 | expect(apply).not.toHaveBeenCalled(); | ||
| 70 | await vi.advanceTimersByTimeAsync(1); | ||
| 71 | expect(apply).toHaveBeenCalledOnce(); | ||
| 72 | await vi.advanceTimersByTimeAsync(20 * 60_000); | ||
| 73 | expect(apply).toHaveBeenCalledOnce(); | ||
| 74 | }); | ||
| 75 | |||
| 76 | it("retries an eligible refresh after unsafe local state clears", async () => { | ||
| 77 | const apply = vi.fn(async () => {}); | ||
| 78 | const guest = document.createElement("webview"); | ||
| 79 | document.body.append(guest); | ||
| 80 | controller = startAutomaticUiRefresh({ pending: () => true, apply }); | ||
| 81 | await vi.advanceTimersByTimeAsync(10 * 60_000); | ||
| 82 | expect(apply).not.toHaveBeenCalled(); | ||
| 83 | guest.remove(); | ||
| 84 | await vi.advanceTimersByTimeAsync(30_000); | ||
| 85 | expect(apply).toHaveBeenCalledOnce(); | ||
| 86 | }); | ||
| 87 | |||
| 88 | it("does not interrupt IME composition or outlive its owner", async () => { | ||
| 89 | const apply = vi.fn(async () => {}); | ||
| 90 | controller = startAutomaticUiRefresh({ pending: () => true, apply }); | ||
| 91 | window.dispatchEvent(new CompositionEvent("compositionstart")); | ||
| 92 | await vi.advanceTimersByTimeAsync(10 * 60_000); | ||
| 93 | expect(apply).not.toHaveBeenCalled(); | ||
| 94 | controller.dispose(); | ||
| 95 | window.dispatchEvent(new CompositionEvent("compositionend")); | ||
| 96 | await vi.advanceTimersByTimeAsync(20 * 60_000); | ||
| 97 | expect(apply).not.toHaveBeenCalled(); | ||
| 98 | }); | ||
| 99 | }); | ||
| 100 | |||
| 101 | describe("refresh preservation", () => { | ||
| 102 | it.each(["webview", "iframe", "[data-thread-terminal-drawer]", '[role="dialog"]'])( | ||
| 103 | "defers while %s owns live state", | ||
| 104 | (selector) => { | ||
| 105 | const node = document.createElement(selector.startsWith("[") ? "div" : selector); | ||
| 106 | if (selector === "[data-thread-terminal-drawer]") node.dataset.threadTerminalDrawer = ""; | ||
| 107 | if (selector === '[role="dialog"]') node.setAttribute("role", "dialog"); | ||
| 108 | document.body.append(node); | ||
| 109 | expect(prepareUiRefresh()).toBe(false); | ||
| 110 | expect(mocks.timeline).not.toHaveBeenCalled(); | ||
| 111 | }, | ||
| 112 | ); | ||
| 113 | |||
| 114 | it("defers when drafts cannot be durably saved or an editor vetoes its snapshot", () => { | ||
| 115 | mocks.drafts.mockReturnValue(false); | ||
| 116 | expect(prepareUiRefresh()).toBe(false); | ||
| 117 | mocks.drafts.mockReturnValue(true); | ||
| 118 | const veto = (event: Event) => event.preventDefault(); | ||
| 119 | window.addEventListener(PREPARE_UI_REFRESH_EVENT, veto); | ||
| 120 | expect(prepareUiRefresh()).toBe(false); | ||
| 121 | window.removeEventListener(PREPARE_UI_REFRESH_EVENT, veto); | ||
| 122 | expect(prepareUiRefresh()).toBe(true); | ||
| 123 | expect(mocks.timeline).toHaveBeenCalledOnce(); | ||
| 124 | }); | ||
| 125 | |||
| 126 | it("keeps unsaved form input, recordings and unhandled scroll positions intact", () => { | ||
| 127 | const input = document.createElement("input"); | ||
| 128 | input.value = "unsaved edit"; | ||
| 129 | document.body.append(input); | ||
| 130 | expect(prepareUiRefresh()).toBe(false); | ||
| 131 | input.remove(); | ||
| 132 | mocks.recordings.mockReturnValue(new Set(["recording"])); | ||
| 133 | expect(prepareUiRefresh()).toBe(false); | ||
| 134 | mocks.recordings.mockReturnValue(new Set()); | ||
| 135 | const sidebar = document.createElement("div"); | ||
| 136 | sidebar.scrollTop = 140; | ||
| 137 | document.body.append(sidebar); | ||
| 138 | expect(prepareUiRefresh()).toBe(false); | ||
| 139 | sidebar.className = "messages-timeline-scroll"; | ||
| 140 | expect(prepareUiRefresh()).toBe(true); | ||
| 141 | }); | ||
| 142 | |||
| 143 | it("allows hidden controls that reflect saved selections", () => { | ||
| 144 | const hidden = document.createElement("input"); | ||
| 145 | hidden.type = "hidden"; | ||
| 146 | hidden.value = "selected-model"; | ||
| 147 | const toggle = document.createElement("input"); | ||
| 148 | toggle.type = "checkbox"; | ||
| 149 | toggle.checked = true; | ||
| 150 | document.body.append(hidden, toggle); | ||
| 151 | expect(prepareUiRefresh()).toBe(true); | ||
| 152 | }); | ||
| 153 | |||
| 154 | it("ignores Base UI's hidden text controls while retaining visible edits", () => { | ||
| 155 | for (const value of ["all", "full-access"]) { | ||
| 156 | const hidden = document.createElement("input"); | ||
| 157 | hidden.type = "text"; | ||
| 158 | hidden.value = value; | ||
| 159 | hidden.setAttribute("aria-hidden", "true"); | ||
| 160 | hidden.style.cssText = "position:fixed;left:-1px;top:-1px;width:1px;height:1px"; | ||
| 161 | document.body.append(hidden); | ||
| 162 | } | ||
| 163 | expect(prepareUiRefresh()).toBe(true); | ||
| 164 | const edited = document.createElement("input"); | ||
| 165 | edited.value = "Unsaved settings edit"; | ||
| 166 | document.body.append(edited); | ||
| 167 | expect(prepareUiRefresh()).toBe(false); | ||
| 168 | }); | ||
| 169 | |||
| 170 | it("ignores retained closed Base UI popovers but defers for an open dialog", () => { | ||
| 171 | for (const attribute of ["data-closed", "inert", "hidden"]) { | ||
| 172 | const popover = document.createElement("div"); | ||
| 173 | popover.setAttribute("role", "dialog"); | ||
| 174 | popover.setAttribute(attribute, ""); | ||
| 175 | const input = document.createElement("input"); | ||
| 176 | input.value = "retained menu selection"; | ||
| 177 | popover.append(input); | ||
| 178 | const editor = document.createElement("div"); | ||
| 179 | editor.className = "cm-editor"; | ||
| 180 | popover.append(editor); | ||
| 181 | popover.scrollTop = 30; | ||
| 182 | document.body.append(popover); | ||
| 183 | } | ||
| 184 | expect(prepareUiRefresh()).toBe(true); | ||
| 185 | const open = document.createElement("div"); | ||
| 186 | open.setAttribute("role", "dialog"); | ||
| 187 | document.body.append(open); | ||
| 188 | expect(prepareUiRefresh()).toBe(false); | ||
| 189 | }); | ||
| 190 | }); | ||
apps/web/src/uiRefresh.ts created+134| ... | @@ -0,0 +1,134 @@ | ||
| 1 | import { prepareComposerDraftsForReload } from "./composerDraftStore"; | ||
| 2 | import { persistTimelinePositionsForReload } from "./components/chat/timelineScrollAnchoring"; | ||
| 3 | import { readActiveBrowserRecordingTabIds } from "./browser/browserRecording"; | ||
| 4 | import { useThemeEditorStore } from "./components/settings/themeEditorStore"; | ||
| 5 | |||
| 6 | export const PREPARE_UI_REFRESH_EVENT = "t3:prepare-ui-refresh"; | ||
| 7 | const HIDDEN_UI_SELECTOR = '[data-closed], [aria-hidden="true"], [hidden], [inert]'; | ||
| 8 | |||
| 9 | /** DOM-owned guests and terminal viewports cannot survive a renderer replacement. */ | ||
| 10 | export function prepareUiRefresh(): boolean { | ||
| 11 | const selection = document.getSelection(); | ||
| 12 | const selectionElement = selection?.anchorNode?.parentElement; | ||
| 13 | if ( | ||
| 14 | document.querySelector( | ||
| 15 | "webview, iframe, [data-thread-terminal-drawer], [data-timeline-loading]", | ||
| 16 | ) || | ||
| 17 | [...document.querySelectorAll('[role="dialog"], [role="alertdialog"]')].some( | ||
| 18 | (dialog) => !dialog.closest(HIDDEN_UI_SELECTOR), | ||
| 19 | ) || | ||
| 20 | readActiveBrowserRecordingTabIds().size > 0 || | ||
| 21 | useThemeEditorStore.getState().session !== null || | ||
| 22 | (selection && | ||
| 23 | !selection.isCollapsed && | ||
| 24 | !selectionElement?.closest("[data-ui-refresh-composer]")) || | ||
| 25 | [...document.querySelectorAll(".cm-editor")].some( | ||
| 26 | (editor) => | ||
| 27 | !editor.closest("[data-ui-refresh-composer]") && | ||
| 28 | !editor.closest(HIDDEN_UI_SELECTOR), | ||
| 29 | ) || | ||
| 30 | [ | ||
| 31 | ...document.querySelectorAll( | ||
| 32 | 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]):not([type="button"]):not([type="submit"]):not([type="reset"]), textarea, [contenteditable=true]', | ||
| 33 | ), | ||
| 34 | ].some( | ||
| 35 | (element) => | ||
| 36 | !element.closest("[data-ui-refresh-composer]") && | ||
| 37 | !element.closest(HIDDEN_UI_SELECTOR) && | ||
| 38 | (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement | ||
| 39 | ? element.value.length > 0 | ||
| 40 | : (element.textContent?.length ?? 0) > 0), | ||
| 41 | ) || | ||
| 42 | [...document.querySelectorAll<HTMLElement>("*")].some( | ||
| 43 | (element) => | ||
| 44 | (element.scrollTop !== 0 || element.scrollLeft !== 0) && | ||
| 45 | !element.closest(HIDDEN_UI_SELECTOR) && | ||
| 46 | !element.matches(".messages-timeline-scroll, [data-tool-group-scroll]") && | ||
| 47 | !(element.matches(".cm-scroller") && element.closest("[data-ui-refresh-composer]")), | ||
| 48 | ) | ||
| 49 | ) | ||
| 50 | return false; | ||
| 51 | |||
| 52 | try { | ||
| 53 | if (!window.dispatchEvent(new Event(PREPARE_UI_REFRESH_EVENT, { cancelable: true }))) | ||
| 54 | return false; | ||
| 55 | if (!prepareComposerDraftsForReload()) return false; | ||
| 56 | persistTimelinePositionsForReload(); | ||
| 57 | return true; | ||
| 58 | } catch { | ||
| 59 | return false; | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | export function startAutomaticUiRefresh(options: { | ||
| 64 | readonly pending: () => boolean; | ||
| 65 | readonly apply: () => Promise<unknown>; | ||
| 66 | }) { | ||
| 67 | let lastActivity = Date.now(); | ||
| 68 | let unfocusedSince = document.hasFocus() && !document.hidden ? null : lastActivity; | ||
| 69 | let composing = false; | ||
| 70 | let applying = false; | ||
| 71 | let disposed = false; | ||
| 72 | let timer: ReturnType<typeof setTimeout> | undefined; | ||
| 73 | |||
| 74 | const check = () => { | ||
| 75 | clearTimeout(timer); | ||
| 76 | if (disposed || applying || !options.pending()) return; | ||
| 77 | const now = Date.now(); | ||
| 78 | const idleDelay = 10 * 60_000 - (now - lastActivity); | ||
| 79 | const unfocusedDelay = unfocusedSince === null ? Infinity : 60_000 - (now - unfocusedSince); | ||
| 80 | const delay = Math.min(idleDelay, unfocusedDelay); | ||
| 81 | if (delay > 0) { | ||
| 82 | timer = setTimeout(check, delay); | ||
| 83 | return; | ||
| 84 | } | ||
| 85 | if (composing || !prepareUiRefresh()) { | ||
| 86 | timer = setTimeout(check, 30_000); | ||
| 87 | return; | ||
| 88 | } | ||
| 89 | applying = true; | ||
| 90 | void options | ||
| 91 | .apply() | ||
| 92 | .catch(() => undefined) | ||
| 93 | .finally(() => { | ||
| 94 | applying = false; | ||
| 95 | if (!disposed) timer = setTimeout(check, 30_000); | ||
| 96 | }); | ||
| 97 | }; | ||
| 98 | const activity = () => { | ||
| 99 | lastActivity = Date.now(); | ||
| 100 | check(); | ||
| 101 | }; | ||
| 102 | const focus = () => { | ||
| 103 | unfocusedSince = | ||
| 104 | document.hasFocus() && !document.hidden ? null : (unfocusedSince ?? Date.now()); | ||
| 105 | activity(); | ||
| 106 | }; | ||
| 107 | const composition = (event: Event) => { | ||
| 108 | composing = event.type === "compositionstart"; | ||
| 109 | activity(); | ||
| 110 | }; | ||
| 111 | const activityEvents = ["pointerdown", "pointermove", "keydown", "wheel", "input", "touchstart"]; | ||
| 112 | for (const name of activityEvents) | ||
| 113 | window.addEventListener(name, activity, { capture: true, passive: true }); | ||
| 114 | window.addEventListener("focus", focus); | ||
| 115 | window.addEventListener("blur", focus); | ||
| 116 | document.addEventListener("visibilitychange", focus); | ||
| 117 | window.addEventListener("compositionstart", composition, true); | ||
| 118 | window.addEventListener("compositionend", composition, true); | ||
| 119 | check(); | ||
| 120 | |||
| 121 | return { | ||
| 122 | check, | ||
| 123 | dispose: () => { | ||
| 124 | disposed = true; | ||
| 125 | clearTimeout(timer); | ||
| 126 | for (const name of activityEvents) window.removeEventListener(name, activity, true); | ||
| 127 | window.removeEventListener("focus", focus); | ||
| 128 | window.removeEventListener("blur", focus); | ||
| 129 | document.removeEventListener("visibilitychange", focus); | ||
| 130 | window.removeEventListener("compositionstart", composition, true); | ||
| 131 | window.removeEventListener("compositionend", composition, true); | ||
| 132 | }, | ||
| 133 | }; | ||
| 134 | } | ||
docs/operations/development.md+159| ... | @@ -214,3 +214,162 @@ Licensed Name Sans and Name Mono files live in the ignored | ... | @@ -214,3 +214,162 @@ Licensed Name Sans and Name Mono files live in the ignored |
| 214 | build workspace before building with these fonts. The web and desktop builds | 214 | build workspace before building with these fonts. The web and desktop builds |
| 215 | bundle the local files automatically; source checkouts without them use system | 215 | bundle the local files automatically; source checkouts without them use system |
| 216 | fallback fonts. Keep the font binaries out of source history. | 216 | fallback fonts. Keep the font binaries out of source history. |
| 217 | |||
| 218 | Create `~/.config/t3-fork-release.json` with permissions `600`: | ||
| 219 | |||
| 220 | ```json | ||
| 221 | { | ||
| 222 | "feedUrl": "https://files.example.com/read-only-share/", | ||
| 223 | "uploadUrl": "https://files.example.com/private-write-share/", | ||
| 224 | "uploadApiKey": "<Copyparty API password>", | ||
| 225 | "signingIdentity": "<40-character certificate fingerprint>", | ||
| 226 | "signingKeychain": "/Users/you/.config/t3-fork-signing.keychain-db" | ||
| 227 | } | ||
| 228 | ``` | ||
| 229 | |||
| 230 | Only the public feed URL is bundled in the app. Keep the upload URL, API key, and | ||
| 231 | signing credentials outside the repository. Uploads authenticate through Copyparty's | ||
| 232 | `PW` header. The publishing account needs to create, overwrite, and delete files; | ||
| 233 | the public share needs GET, HEAD, and Copyparty's `?ls` listing access to the same | ||
| 234 | files. No application server or package registry is required for the feed. | ||
| 235 | |||
| 236 | Import the signing certificate's `.p12` into a dedicated build keychain so signing | ||
| 237 | works from Terminal without granting access to other login-keychain keys. Put its | ||
| 238 | password in a private file such as `/tmp/certpass.txt`: | ||
| 239 | |||
| 240 | ```sh | ||
| 241 | umask 077 | ||
| 242 | fork_keychain="$HOME/.config/t3-fork-signing.keychain-db" | ||
| 243 | openssl rand -base64 32 > "$fork_keychain.password" | ||
| 244 | security create-keychain -p "$(cat "$fork_keychain.password")" "$fork_keychain" | ||
| 245 | security unlock-keychain -p "$(cat "$fork_keychain.password")" "$fork_keychain" | ||
| 246 | security import "$HOME/Certificates.p12" -k "$fork_keychain" \ | ||
| 247 | -P "$(cat /tmp/certpass.txt)" -T /usr/bin/codesign | ||
| 248 | security set-key-partition-list -S apple-tool:,apple: -s \ | ||
| 249 | -k "$(cat "$fork_keychain.password")" "$fork_keychain" | ||
| 250 | security find-identity -v -p codesigning "$fork_keychain" | ||
| 251 | ``` | ||
| 252 | |||
| 253 | Copy the certificate fingerprint into `signingIdentity`. Releases unlock this | ||
| 254 | keychain using its adjacent `.password` file. The fingerprint avoids selecting | ||
| 255 | an older or revoked certificate with the same display name. | ||
| 256 | |||
| 257 | Direct downloads that pass Gatekeeper need a **Developer ID Application** | ||
| 258 | certificate from the same Apple Developer account used for iOS, plus Apple's | ||
| 259 | notarization service. Apple Development and iOS distribution certificates do not | ||
| 260 | replace Developer ID. Store notarization credentials in the build keychain with | ||
| 261 | `xcrun notarytool store-credentials knife-notary --keychain "$fork_keychain"`, | ||
| 262 | then add `"notarizationKeychainProfile": "knife-notary"` to the private release | ||
| 263 | config and select the Developer ID certificate fingerprint. The release checks | ||
| 264 | the certificate and credentials before building, signs with the hardened runtime, | ||
| 265 | notarizes and staples the app, then verifies Gatekeeper acceptance. It stops if | ||
| 266 | any of those checks fail. The notarization profile stays outside the repository. | ||
| 267 | |||
| 268 | ```sh | ||
| 269 | vp run fork:build | ||
| 270 | vp run fork:publish release/fork/0.0.<timestamp> | ||
| 271 | # Build and publish together: | ||
| 272 | vp run fork:release | ||
| 273 | ``` | ||
| 274 | |||
| 275 | Builds still verify a signed Apple Silicon app locally. Publishing separates UI, | ||
| 276 | desktop application code, portable server code, and static assets into checksum-verified archives; | ||
| 277 | unchanged archives are reused. The full app ZIP is uploaded only when Electron, | ||
| 278 | the bootstrap, signing policy, or shell resources change. A UI edit therefore uploads | ||
| 279 | the UI code archive and small release manifests. Static models, fonts, and images | ||
| 280 | are cached separately across UI edits. Versions increase independently of | ||
| 281 | upstream versions and rebases. Publishing makes every referenced archive readable, | ||
| 282 | then creates the immutable version manifest. Clients select the newest completed | ||
| 283 | version from the directory listing. Run one publisher at a time: installer URLs are | ||
| 284 | mutable, so simultaneous installer replacement is unsupported. | ||
| 285 | The root `preadme.md` links to the available platform installers and is refreshed | ||
| 286 | after a completed publication. Version-free download entrypoints use | ||
| 287 | `The-Knife-darwin-arm64.zip`, `The-Knife-linux-x64.AppImage`, and | ||
| 288 | `The-Knife-win32-x64.exe`. Publication uploads replacements under temporary names, | ||
| 289 | verifies their checksums, then promotes them with WebDAV MOVE. Native versions | ||
| 290 | and checksums remain in the completion JSON and per-version updater YAML. | ||
| 291 | Before replacement, conflicting completed releases and unreferenced updater YAML | ||
| 292 | are retired. The two newest nonconflicting completed releases and their referenced | ||
| 293 | components are retained; unrelated files and incomplete uploads are left alone. | ||
| 294 | If publication fails after retirement, retry the same local release directory. | ||
| 295 | Its atomically written `publication.json` preserves the merged release and cleanup | ||
| 296 | ownership until the exact completion and all dependencies are verified. | ||
| 297 | |||
| 298 | Build Linux installers on Linux for the native capture and credential helpers. | ||
| 299 | Windows builds can run natively, or use matching prebuilt helpers with | ||
| 300 | `T3CODE_DESKTOP_WINDOWS_RUNTIME` pointing to a Wine launcher for the isolated | ||
| 301 | backend probe. Native Windows builds also exercise the packaged file index. | ||
| 302 | Export the packaged resources alongside the installer and updater manifest: | ||
| 303 | |||
| 304 | ```sh | ||
| 305 | # On Linux (use --platform win --target nsis on Windows): | ||
| 306 | T3CODE_DESKTOP_FORK_RESOURCES=true T3CODE_DESKTOP_UPDATE_URL=https://files.example.com/read-only-share/ \ | ||
| 307 | node scripts/build-desktop-artifact.ts --platform linux --target AppImage \ | ||
| 308 | --arch x64 --build-version 0.0.<timestamp> --output-dir /path/to/native-build | ||
| 309 | ``` | ||
| 310 | |||
| 311 | Copy that output directory to the publishing host, then run: | ||
| 312 | |||
| 313 | ```sh | ||
| 314 | node scripts/release-fork.ts ingest linux-x64 /path/to/native-build | ||
| 315 | # Windows: | ||
| 316 | node scripts/release-fork.ts ingest win32-x64 /path/to/native-build | ||
| 317 | ``` | ||
| 318 | |||
| 319 | Ingestion verifies the installer and resource versions, separates reusable | ||
| 320 | components, and publishes a new completed manifest. Each platform keeps its own | ||
| 321 | backend, UI, and asset versions until its next native build is published. | ||
| 322 | |||
| 323 | Install the generated `.app` once on each Mac to bootstrap this updater. Subsequent | ||
| 324 | code updates download without replacing Electron. Application code and shell changes | ||
| 325 | require a confirmed restart. UI updates can apply after one minute unfocused or ten | ||
| 326 | minutes idle once restorable state is saved; live browser guests and other state | ||
| 327 | that cannot be restored defer automatic refresh. | ||
| 328 | The Knife retains the shipped bundle ID, data directory, credential storage, and | ||
| 329 | Connect configuration. Its display name and new artifacts use The Knife; the | ||
| 330 | existing updater accepts the new artifact filenames. The initial | ||
| 331 | build must include any public Connect configuration used by that installation; | ||
| 332 | the build loads the repository's local environment files. | ||
| 333 | |||
| 334 | `security find-identity -v -p codesigning` lists usable signing identities. A | ||
| 335 | certificate needs its private key in the keychain; a `.p8` App Store Connect key | ||
| 336 | alone cannot sign an app. Keep the same signing identity across releases. A local | ||
| 337 | Apple Development identity still signs private builds, with a build warning that | ||
| 338 | Gatekeeper may block downloaded copies. Native passkeys additionally need the | ||
| 339 | provisioned signing procedure linked above. | ||
| 340 | |||
| 341 | ### Private server container | ||
| 342 | |||
| 343 | The [Compose configuration](../../compose.yaml) runs a persistent Knife server, | ||
| 344 | including the private shared-account authority. The public | ||
| 345 | `knife-server-linux-amd64.tar.gz` deployment bundle contains the image, this Compose | ||
| 346 | configuration, and a public-feed environment example. Verify it against its | ||
| 347 | adjacent `.sha256` file, extract it, and load `image.tar` with | ||
| 348 | `docker image load -i image.tar`. Copy `.env.example` to `.env` and prepare the | ||
| 349 | archive and workspace directories with write access for UID 1000. Start it with | ||
| 350 | `docker compose up -d --no-build`; the deployment bundle needs no source checkout | ||
| 351 | or registry account. | ||
| 352 | |||
| 353 | To build the base image from source, run `docker build -t knife:local .` on the | ||
| 354 | target Linux architecture. `docker image save knife:local` exports a loadable image. | ||
| 355 | A registry can supply `KNIFE_IMAGE` instead. | ||
| 356 | |||
| 357 | Set `KNIFE_UPDATE_URL` to the public release directory, | ||
| 358 | `KNIFE_ARCHIVES_DIRECTORY` and `KNIFE_WORKSPACES_DIRECTORY` to host directories, | ||
| 359 | then run `docker compose up -d`. The default port binds to localhost for a reverse | ||
| 360 | proxy; set `KNIFE_BIND_ADDRESS` for a different binding. `/data` and the provider home | ||
| 361 | use persistent volumes. Keep both private and backed up; account credentials never | ||
| 362 | belong on the public build share. | ||
| 363 | |||
| 364 | At startup the container downloads only missing server/UI/assets archives from the same | ||
| 365 | feed, checks their hashes and native dependency contract, and runs the selected | ||
| 366 | release. `docker compose restart knife` applies a published code update after | ||
| 367 | confirming the restart with Clover. While running it prefetches new archives every | ||
| 368 | four minutes without restarting or replacing the running code. A feed outage uses the cached release or bundled | ||
| 369 | baseline; a checksum or dependency mismatch stops startup. Rebuild the base image | ||
| 370 | when the server's locked native dependency closure or container bootstrap changes. | ||
| 371 | |||
| 372 | Enroll execution hosts with the private authority using the existing | ||
| 373 | [`t3 shared` commands](../user/shared-server.md). Each machine gets its own | ||
| 374 | revocable session credential. The authority owns Codex token refreshes; Claude | ||
| 375 | accounts need setup tokens. Connected hosts synchronize accounts automatically. |
infra/container/knife created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | #!/bin/sh | ||
| 2 | set -eu | ||
| 3 | |||
| 4 | if [ "${1:-serve}" = serve ]; then | ||
| 5 | node /opt/knife/scripts/container-runtime.ts | ||
| 6 | fi | ||
| 7 | |||
| 8 | runtime="${T3CODE_HOME:-/data}/runtime/knife-container/current" | ||
| 9 | if [ ! -f "$runtime/apps/server/dist/bin.mjs" ] || | ||
| 10 | [ "$(cat "$runtime/knife-server-runtime")" != "$(cat /opt/knife/knife-server-runtime)" ]; then | ||
| 11 | runtime=/opt/knife | ||
| 12 | fi | ||
| 13 | if [ -f "$runtime/version" ]; then | ||
| 14 | T3CODE_VERSION_OVERRIDE="$(cat "$runtime/version")" | ||
| 15 | export T3CODE_VERSION_OVERRIDE | ||
| 16 | fi | ||
| 17 | if [ "${1:-serve}" = serve ]; then | ||
| 18 | exec node --import /opt/knife/scripts/container-runtime.ts "$runtime/apps/server/dist/bin.mjs" "$@" | ||
| 19 | fi | ||
| 20 | exec node "$runtime/apps/server/dist/bin.mjs" "$@" | ||
package.json+3| ... | @@ -38,6 +38,9 @@ | ... | @@ -38,6 +38,9 @@ |
| 38 | "fmt": "vp fmt", | 38 | "fmt": "vp fmt", |
| 39 | "fmt:check": "vp fmt --check", | 39 | "fmt:check": "vp fmt --check", |
| 40 | "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", | 40 | "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", |
| 41 | "fork:build": "node scripts/release-fork.ts build", | ||
| 42 | "fork:publish": "node scripts/release-fork.ts publish", | ||
| 43 | "fork:release": "node scripts/release-fork.ts release", | ||
| 41 | "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", | 44 | "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", |
| 42 | "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", | 45 | "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", |
| 43 | "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", | 46 | "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", |
packages/contracts/src/ipc.ts+4| ... | @@ -290,6 +290,8 @@ export interface DesktopUpdateState { | ... | @@ -290,6 +290,8 @@ export interface DesktopUpdateState { |
| 290 | * not install it again; the user can retry it from the update button. | 290 | * not install it again; the user can retry it from the update button. |
| 291 | */ | 291 | */ |
| 292 | notAppliedVersion?: string; | 292 | notAppliedVersion?: string; |
| 293 | installKind?: "ui" | "app"; | ||
| 294 | uiVersion?: string; | ||
| 293 | } | 295 | } |
| 294 | 296 | ||
| 295 | export interface DesktopUpdateReleaseNote { | 297 | export interface DesktopUpdateReleaseNote { |
| ... | @@ -322,6 +324,8 @@ export const DesktopUpdateStateSchema = Schema.Struct({ | ... | @@ -322,6 +324,8 @@ export const DesktopUpdateStateSchema = Schema.Struct({ |
| 322 | errorContext: Schema.NullOr(Schema.Literals(["check", "download", "install"])), | 324 | errorContext: Schema.NullOr(Schema.Literals(["check", "download", "install"])), |
| 323 | canRetry: Schema.Boolean, | 325 | canRetry: Schema.Boolean, |
| 324 | notAppliedVersion: Schema.optionalKey(Schema.String), | 326 | notAppliedVersion: Schema.optionalKey(Schema.String), |
| 327 | installKind: Schema.optionalKey(Schema.Literals(["ui", "app"])), | ||
| 328 | uiVersion: Schema.optionalKey(Schema.String), | ||
| 325 | }); | 329 | }); |
| 326 | 330 | ||
| 327 | /** The desktop app's `t3` command on PATH, managed from Settings. */ | 331 | /** The desktop app's `t3` command on PATH, managed from Settings. */ |
packages/shared/package.json+4| ... | @@ -3,6 +3,10 @@ | ... | @@ -3,6 +3,10 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./forkUpdateDirectory": { | ||
| 7 | "types": "./src/forkUpdateDirectory.ts", | ||
| 8 | "import": "./src/forkUpdateDirectory.ts" | ||
| 9 | }, | ||
| 6 | "./githubMarkdownLinks": { | 10 | "./githubMarkdownLinks": { |
| 7 | "types": "./src/githubMarkdownLinks.ts", | 11 | "types": "./src/githubMarkdownLinks.ts", |
| 8 | "import": "./src/githubMarkdownLinks.ts" | 12 | "import": "./src/githubMarkdownLinks.ts" |
packages/shared/src/forkUpdateDirectory.test.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { expect, it } from "vite-plus/test"; | ||
| 2 | import { latestForkRelease } from "./forkUpdateDirectory.ts"; | ||
| 3 | |||
| 4 | it("selects the newest immutable release regardless of upload completion order", () => { | ||
| 5 | const listing = { | ||
| 6 | files: [ | ||
| 7 | { href: "0.0.2000000001.json" }, | ||
| 8 | { href: "0.0.2000000000.json" }, | ||
| 9 | { href: "0.0.3000000000.json.PARTIAL" }, | ||
| 10 | { href: "../0.0.4000000000.json" }, | ||
| 11 | { href: "0.0.5000000000-mac.yml" }, | ||
| 12 | ], | ||
| 13 | }; | ||
| 14 | expect(latestForkRelease(listing)).toBe("0.0.2000000001"); | ||
| 15 | expect(latestForkRelease({ files: [] })).toBeUndefined(); | ||
| 16 | }); | ||
packages/shared/src/forkUpdateDirectory.ts created+53| ... | @@ -0,0 +1,53 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | |||
| 3 | const ForkVersion = Schema.String.check(Schema.isPattern(/^0\.0\.[0-9]+$/)); | ||
| 4 | |||
| 5 | export const ForkReleaseListing = Schema.Struct({ | ||
| 6 | files: Schema.Array(Schema.Struct({ href: Schema.String })), | ||
| 7 | }); | ||
| 8 | |||
| 9 | export function latestForkRelease(listing: typeof ForkReleaseListing.Type): string | undefined { | ||
| 10 | let latest: string | undefined; | ||
| 11 | for (const { href } of listing.files) { | ||
| 12 | const version = /^(0\.0\.[0-9]+)\.json$/.exec(href)?.[1]; | ||
| 13 | if (version && (!latest || BigInt(version.slice(4)) > BigInt(latest.slice(4)))) | ||
| 14 | latest = version; | ||
| 15 | } | ||
| 16 | return latest; | ||
| 17 | } | ||
| 18 | |||
| 19 | export const FORK_RUNTIME_MANIFEST_FILE = "knife-runtime.json"; | ||
| 20 | export const ForkRuntimeManifest = Schema.Struct({ | ||
| 21 | version: ForkVersion, | ||
| 22 | sha256: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), | ||
| 23 | shell: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), | ||
| 24 | }); | ||
| 25 | |||
| 26 | export const ForkArtifact = Schema.Struct({ | ||
| 27 | file: Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)), | ||
| 28 | sha256: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), | ||
| 29 | size: Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)), | ||
| 30 | }); | ||
| 31 | |||
| 32 | export const ForkReleaseManifest = Schema.Struct({ | ||
| 33 | version: ForkVersion, | ||
| 34 | ui: ForkArtifact, | ||
| 35 | assets: ForkArtifact, | ||
| 36 | server: Schema.Struct({ | ||
| 37 | ...ForkArtifact.fields, | ||
| 38 | version: ForkVersion, | ||
| 39 | runtime: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), | ||
| 40 | }), | ||
| 41 | desktop: Schema.Record( | ||
| 42 | Schema.String, | ||
| 43 | Schema.Struct({ | ||
| 44 | version: ForkVersion, | ||
| 45 | ui: ForkArtifact, | ||
| 46 | assets: ForkArtifact, | ||
| 47 | shell: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), | ||
| 48 | runtime: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), | ||
| 49 | code: ForkArtifact, | ||
| 50 | app: Schema.Struct({ ...ForkArtifact.fields, version: ForkVersion }), | ||
| 51 | }), | ||
| 52 | ), | ||
| 53 | }); | ||
scripts/build-desktop-artifact.test.ts+168-1| ... | @@ -24,6 +24,7 @@ import { | ... | @@ -24,6 +24,7 @@ import { |
| 24 | createStagePatchedDependencies, | 24 | createStagePatchedDependencies, |
| 25 | readWorkspaceConfig, | 25 | readWorkspaceConfig, |
| 26 | createBuildConfig, | 26 | createBuildConfig, |
| 27 | exportForkResources, | ||
| 27 | DESKTOP_ELECTRON_LANGUAGES, | 28 | DESKTOP_ELECTRON_LANGUAGES, |
| 28 | DESKTOP_FILE_EXCLUSIONS, | 29 | DESKTOP_FILE_EXCLUSIONS, |
| 29 | DESKTOP_EXTRA_RESOURCES, | 30 | DESKTOP_EXTRA_RESOURCES, |
| ... | @@ -98,6 +99,54 @@ import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; | ... | @@ -98,6 +99,54 @@ import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; |
| 98 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 99 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 99 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; | 100 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 100 | 101 | ||
| 102 | it.effect.each(["linux", "win"] as const)( | ||
| 103 | "exports the actual %s packaged archives and their native sidecars", | ||
| 104 | (platform) => | ||
| 105 | Effect.scoped( | ||
| 106 | Effect.gen(function* () { | ||
| 107 | const fs = yield* FileSystem.FileSystem; | ||
| 108 | const path = yield* Path.Path; | ||
| 109 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 110 | const root = yield* fs.makeTempDirectoryScoped(); | ||
| 111 | const stageDistDir = path.join(root, "dist"); | ||
| 112 | const resources = path.join(stageDistDir, `${platform}-unpacked/resources`); | ||
| 113 | const files = [ | ||
| 114 | "app.asar", | ||
| 115 | "app.asar.unpacked/native.node", | ||
| 116 | ...(platform === "win" ? ["server.asar", "server.asar.unpacked/server.node"] : []), | ||
| 117 | ]; | ||
| 118 | for (const file of [...files, "unrelated-electron-resource"]) { | ||
| 119 | yield* fs.makeDirectory(path.dirname(path.join(resources, file)), { recursive: true }); | ||
| 120 | yield* fs.writeFileString(path.join(resources, file), file); | ||
| 121 | } | ||
| 122 | const archive = yield* exportForkResources({ | ||
| 123 | stageDistDir, | ||
| 124 | outputDir: path.join(root, "output"), | ||
| 125 | platform, | ||
| 126 | arch: "x64", | ||
| 127 | serverRuntime: "a".repeat(64), | ||
| 128 | }); | ||
| 129 | assert.equal( | ||
| 130 | path.basename(archive), | ||
| 131 | `knife-resources-${platform === "win" ? "win32" : platform}-x64.tar.gz`, | ||
| 132 | ); | ||
| 133 | const extracted = path.join(root, "extracted"); | ||
| 134 | yield* fs.makeDirectory(extracted); | ||
| 135 | const child = yield* spawner.spawn( | ||
| 136 | ChildProcess.make("tar", ["-xzf", archive, "-C", extracted]), | ||
| 137 | ); | ||
| 138 | assert.equal(Number(yield* child.exitCode), 0); | ||
| 139 | for (const file of files) | ||
| 140 | assert.equal(yield* fs.readFileString(path.join(extracted, file)), file); | ||
| 141 | assert.equal( | ||
| 142 | yield* fs.readFileString(path.join(extracted, "knife-server-runtime")), | ||
| 143 | "a".repeat(64), | ||
| 144 | ); | ||
| 145 | assert.equal(yield* fs.exists(path.join(extracted, "unrelated-electron-resource")), false); | ||
| 146 | }), | ||
| 147 | ).pipe(Effect.provide(NodeServices.layer)), | ||
| 148 | ); | ||
| 149 | |||
| 101 | // Keeps pnpm from auto-installing TypeScript, a types-only peer, into the app. | 150 | // Keeps pnpm from auto-installing TypeScript, a types-only peer, into the app. |
| 102 | const stagePackageExtensions = { | 151 | const stagePackageExtensions = { |
| 103 | "electron-webauthn": { peerDependenciesMeta: { typescript: { optional: true } } }, | 152 | "electron-webauthn": { peerDependenciesMeta: { typescript: { optional: true } } }, |
| ... | @@ -267,6 +316,77 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu | ... | @@ -267,6 +316,77 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu |
| 267 | }); | 316 | }); |
| 268 | 317 | ||
| 269 | it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | 318 | it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 319 | it.effect("packages a signed private feed instead of the upstream release channel", () => | ||
| 320 | Effect.gen(function* () { | ||
| 321 | const config = yield* createBuildConfig( | ||
| 322 | "mac", | ||
| 323 | "zip", | ||
| 324 | "0.0.1791936000", | ||
| 325 | false, | ||
| 326 | false, | ||
| 327 | undefined, | ||
| 328 | undefined, | ||
| 329 | ); | ||
| 330 | assert.deepStrictEqual(config.publish, [ | ||
| 331 | { provider: "generic", url: "https://updates.example.test/fork/?ls=" }, | ||
| 332 | ]); | ||
| 333 | assert.equal(config.forceCodeSigning, true); | ||
| 334 | assert.containsSubset(config.mac, { | ||
| 335 | identity: "Apple Development: Private Owner (TESTTEAM)", | ||
| 336 | type: "development", | ||
| 337 | }); | ||
| 338 | assert.property(config.mac, "sign"); | ||
| 339 | assert.notProperty(config.mac, "provisioningProfile"); | ||
| 340 | }).pipe( | ||
| 341 | Effect.provide( | ||
| 342 | ConfigProvider.layer( | ||
| 343 | ConfigProvider.fromEnv({ | ||
| 344 | env: { | ||
| 345 | GITHUB_REPOSITORY: "pingdotgg/t3code", | ||
| 346 | T3CODE_DESKTOP_UPDATE_URL: "https://updates.example.test/fork/", | ||
| 347 | T3CODE_DESKTOP_MAC_SIGN_IDENTITY: "Apple Development: Private Owner (TESTTEAM)", | ||
| 348 | }, | ||
| 349 | }), | ||
| 350 | ), | ||
| 351 | ), | ||
| 352 | ), | ||
| 353 | ); | ||
| 354 | |||
| 355 | it.effect("notarizes Developer ID fork builds without requiring passkey provisioning", () => | ||
| 356 | Effect.gen(function* () { | ||
| 357 | const config = yield* createBuildConfig( | ||
| 358 | "mac", | ||
| 359 | "zip", | ||
| 360 | "0.0.1791936000", | ||
| 361 | false, | ||
| 362 | false, | ||
| 363 | undefined, | ||
| 364 | undefined, | ||
| 365 | ); | ||
| 366 | |||
| 367 | assert.equal(config.forceCodeSigning, true); | ||
| 368 | assert.containsSubset(config.mac, { | ||
| 369 | identity: "A".repeat(40), | ||
| 370 | type: "distribution", | ||
| 371 | hardenedRuntime: true, | ||
| 372 | notarize: true, | ||
| 373 | }); | ||
| 374 | assert.property(config.mac, "sign"); | ||
| 375 | assert.notProperty(config.mac, "provisioningProfile"); | ||
| 376 | }).pipe( | ||
| 377 | Effect.provide( | ||
| 378 | ConfigProvider.layer( | ||
| 379 | ConfigProvider.fromEnv({ | ||
| 380 | env: { | ||
| 381 | T3CODE_DESKTOP_MAC_SIGN_IDENTITY: "A".repeat(40), | ||
| 382 | T3CODE_DESKTOP_MAC_SIGN_TYPE: "distribution", | ||
| 383 | }, | ||
| 384 | }), | ||
| 385 | ), | ||
| 386 | ), | ||
| 387 | ), | ||
| 388 | ); | ||
| 389 | |||
| 270 | it("resolves the dedicated nightly updater channel from nightly versions", () => { | 390 | it("resolves the dedicated nightly updater channel from nightly versions", () => { |
| 271 | assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly"); | 391 | assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly"); |
| 272 | assert.equal(resolveDesktopUpdateChannel("0.0.17"), "latest"); | 392 | assert.equal(resolveDesktopUpdateChannel("0.0.17"), "latest"); |
| ... | @@ -1608,6 +1728,53 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1608,6 +1728,53 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1608 | ); | 1728 | ); |
| 1609 | }); | 1729 | }); |
| 1610 | 1730 | ||
| 1731 | it.effect("checks the isolated Windows bundle through an explicit Wine runtime", () => { | ||
| 1732 | const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = []; | ||
| 1733 | const spawnerLayer = Layer.succeed( | ||
| 1734 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 1735 | ChildProcessSpawner.make((command) => { | ||
| 1736 | if (!ChildProcess.isStandardCommand(command)) { | ||
| 1737 | return Effect.die(new Error("Expected a standard command")); | ||
| 1738 | } | ||
| 1739 | commands.push(command); | ||
| 1740 | return Effect.succeed(mockProcess(0)); | ||
| 1741 | }), | ||
| 1742 | ); | ||
| 1743 | return Effect.scoped( | ||
| 1744 | Effect.gen(function* () { | ||
| 1745 | const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); | ||
| 1746 | yield* validateWindowsPackagedPayload({ | ||
| 1747 | stageDistDir: fixture.stageDistDir, | ||
| 1748 | appExecutableName: fixture.appExecutableName, | ||
| 1749 | targetArch: "x64", | ||
| 1750 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1751 | }); | ||
| 1752 | const probes = commands.filter((command) => command.command === "/tools/wine"); | ||
| 1753 | assert.lengthOf(probes, 1); | ||
| 1754 | const [isolated] = probes; | ||
| 1755 | if (isolated === undefined) return assert.fail("The Windows bundle probe must run"); | ||
| 1756 | assert.isTrue( | ||
| 1757 | probes.every((command) => command.args[0]?.endsWith(fixture.appExecutableName)), | ||
| 1758 | ); | ||
| 1759 | assert.equal(isolated.args.at(-1), "--version"); | ||
| 1760 | assert.isTrue( | ||
| 1761 | isolated.args.some((arg) => arg.startsWith("Z:\\") && arg.endsWith("bin.mjs")), | ||
| 1762 | ); | ||
| 1763 | }), | ||
| 1764 | ).pipe( | ||
| 1765 | Effect.provide( | ||
| 1766 | Layer.mergeAll( | ||
| 1767 | spawnerLayer, | ||
| 1768 | Layer.succeed(HostProcess.Platform, "darwin"), | ||
| 1769 | Layer.succeed(HostProcess.Architecture, "arm64"), | ||
| 1770 | ConfigProvider.layer( | ||
| 1771 | ConfigProvider.fromEnv({ env: { T3CODE_DESKTOP_WINDOWS_RUNTIME: "/tools/wine" } }), | ||
| 1772 | ), | ||
| 1773 | ), | ||
| 1774 | ), | ||
| 1775 | ); | ||
| 1776 | }); | ||
| 1777 | |||
| 1611 | it.effect("refuses a Linux build on a host that cannot build the browser secret helper", () => { | 1778 | it.effect("refuses a Linux build on a host that cannot build the browser secret helper", () => { |
| 1612 | const commands: Array<{ readonly command: string }> = []; | 1779 | const commands: Array<{ readonly command: string }> = []; |
| 1613 | const spawnerLayer = Layer.succeed( | 1780 | const spawnerLayer = Layer.succeed( |
| ... | @@ -1670,7 +1837,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1670,7 +1837,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1670 | }); | 1837 | }); |
| 1671 | 1838 | ||
| 1672 | assert.isFalse( | 1839 | assert.isFalse( |
| 1673 | commands.some((command) => command.options.env?.ELECTRON_RUN_AS_NODE === "1"), | 1840 | commands.some((command) => command.command.endsWith(fixture.appExecutableName)), |
| 1674 | ); | 1841 | ); |
| 1675 | assert.isTrue( | 1842 | assert.isTrue( |
| 1676 | commands.some( | 1843 | commands.some( |
scripts/build-desktop-artifact.ts+144-24| ... | @@ -1,4 +1,6 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | #!/usr/bin/env node | 1 | #!/usr/bin/env node |
| 2 | import { fingerprintForkRuntime, fingerprintForkServerRuntime } from "./lib/fork-runtime.ts"; | ||
| 3 | import { FORK_RUNTIME_MANIFEST_FILE } from "@t3tools/shared/forkUpdateDirectory"; | ||
| 2 | // @effect-diagnostics nodeBuiltinImport:off - Node's typed junction API avoids Windows symlink privileges while keeping the probe isolated. | 4 | // @effect-diagnostics nodeBuiltinImport:off - Node's typed junction API avoids Windows symlink privileges while keeping the probe isolated. |
| 3 | 5 | ||
| 4 | import * as NodeFSP from "node:fs/promises"; | 6 | import * as NodeFSP from "node:fs/promises"; |
| ... | @@ -845,23 +847,19 @@ const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* ( | ... | @@ -845,23 +847,19 @@ const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* ( |
| 845 | return { stdout, stderr, exitCode } as const; | 847 | return { stdout, stderr, exitCode } as const; |
| 846 | }); | 848 | }); |
| 847 | 849 | ||
| 848 | const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRoot: string) { | 850 | const resolveCommitHash = Effect.fn("resolveCommitHash")(function* (repoRoot: string) { |
| 849 | const result = yield* spawnAndCollectOutput( | 851 | const configuredHash = yield* Config.String("T3CODE_COMMIT_HASH").pipe(Config.option); |
| 850 | ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], { | 852 | const output = yield* Option.match(configuredHash, { |
| 851 | cwd: repoRoot, | 853 | onSome: Effect.succeed, |
| 852 | }), | 854 | onNone: () => |
| 853 | ).pipe( | 855 | spawnAndCollectOutput( |
| 854 | Effect.orElseSucceed(() => ({ | 856 | ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], { cwd: repoRoot }), |
| 855 | stdout: "", | 857 | ).pipe( |
| 856 | stderr: "", | 858 | Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), |
| 857 | exitCode: 1, | 859 | Effect.orElseSucceed(() => ""), |
| 858 | })), | 860 | ), |
| 859 | ); | 861 | }); |
| 860 | 862 | const hash = output.trim(); | |
| 861 | if (result.exitCode !== 0) { | ||
| 862 | return "unknown"; | ||
| 863 | } | ||
| 864 | const hash = result.stdout.trim(); | ||
| 865 | if (!/^[0-9a-f]{7,40}$/i.test(hash)) { | 863 | if (!/^[0-9a-f]{7,40}$/i.test(hash)) { |
| 866 | return "unknown"; | 864 | return "unknown"; |
| 867 | } | 865 | } |
| ... | @@ -2158,7 +2156,11 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin | ... | @@ -2158,7 +2156,11 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin |
| 2158 | ); | 2156 | ); |
| 2159 | 2157 | ||
| 2160 | const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")( | 2158 | const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")( |
| 2161 | function* (input: { readonly asarPath: string; readonly verbose: boolean }) { | 2159 | function* (input: { |
| 2160 | readonly asarPath: string; | ||
| 2161 | readonly executablePath: string; | ||
| 2162 | readonly verbose: boolean; | ||
| 2163 | }) { | ||
| 2162 | const fs = yield* FileSystem.FileSystem; | 2164 | const fs = yield* FileSystem.FileSystem; |
| 2163 | const path = yield* Path.Path; | 2165 | const path = yield* Path.Path; |
| 2164 | 2166 | ||
| ... | @@ -2206,14 +2208,23 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel | ... | @@ -2206,14 +2208,23 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel |
| 2206 | // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node | 2208 | // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node |
| 2207 | // and the bun adapters are covered by the shared runtime-external closure | 2209 | // and the bun adapters are covered by the shared runtime-external closure |
| 2208 | // and emitted-bundle checks. | 2210 | // and emitted-bundle checks. |
| 2211 | const wine = Option.getOrUndefined( | ||
| 2212 | yield* Config.String("T3CODE_DESKTOP_WINDOWS_RUNTIME").pipe(Config.option), | ||
| 2213 | ); | ||
| 2214 | const entry = wine === undefined ? entryPoint : `Z:${entryPoint.replaceAll("/", "\\")}`; | ||
| 2209 | yield* runCommand( | 2215 | yield* runCommand( |
| 2210 | ChildProcess.make( | 2216 | ChildProcess.make( |
| 2211 | process.execPath, | 2217 | wine ?? process.execPath, |
| 2212 | // --no-global-search-paths because clearing NODE_PATH is not enough: | 2218 | // --no-global-search-paths because clearing NODE_PATH is not enough: |
| 2213 | // CommonJS resolution still falls back to $HOME/.node_modules, | 2219 | // CommonJS resolution still falls back to $HOME/.node_modules, |
| 2214 | // $HOME/.node_libraries and the install prefix, so a globally installed | 2220 | // $HOME/.node_libraries and the install prefix, so a globally installed |
| 2215 | // copy of a missing dependency would quietly satisfy this check. | 2221 | // copy of a missing dependency would quietly satisfy this check. |
| 2216 | ["--no-global-search-paths", entryPoint, "--version"], | 2222 | [ |
| 2223 | ...(wine === undefined ? [] : [input.executablePath]), | ||
| 2224 | "--no-global-search-paths", | ||
| 2225 | entry, | ||
| 2226 | "--version", | ||
| 2227 | ], | ||
| 2217 | { | 2228 | { |
| 2218 | cwd: probeApp, | 2229 | cwd: probeApp, |
| 2219 | stdout: "pipe", | 2230 | stdout: "pipe", |
| ... | @@ -2221,7 +2232,7 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel | ... | @@ -2221,7 +2232,7 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel |
| 2221 | // NODE_PATH would let a createRequire call inside the bundle resolve | 2232 | // NODE_PATH would let a createRequire call inside the bundle resolve |
| 2222 | // a missing external from outside the packaged tree, which is the | 2233 | // a missing external from outside the packaged tree, which is the |
| 2223 | // whole thing this is trying to rule out. | 2234 | // whole thing this is trying to rule out. |
| 2224 | env: { ...process.env, NODE_PATH: "" }, | 2235 | env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", NODE_PATH: "" }, |
| 2225 | }, | 2236 | }, |
| 2226 | ), | 2237 | ), |
| 2227 | { | 2238 | { |
| ... | @@ -2800,8 +2811,12 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( | ... | @@ -2800,8 +2811,12 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 2800 | }; | 2811 | }; |
| 2801 | const updateChannel = resolveDesktopUpdateChannel(version); | 2812 | const updateChannel = resolveDesktopUpdateChannel(version); |
| 2802 | if (!isDesktopPreviewVersion(version)) { | 2813 | if (!isDesktopPreviewVersion(version)) { |
| 2814 | const updateUrl = yield* Config.URL("T3CODE_DESKTOP_UPDATE_URL").pipe(Config.option); | ||
| 2803 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); | 2815 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); |
| 2804 | if (publishConfig) { | 2816 | if (Option.isSome(updateUrl)) { |
| 2817 | updateUrl.value.searchParams.set("ls", ""); | ||
| 2818 | buildConfig.publish = [{ provider: "generic", url: updateUrl.value.href }]; | ||
| 2819 | } else if (publishConfig) { | ||
| 2805 | buildConfig.publish = [publishConfig]; | 2820 | buildConfig.publish = [publishConfig]; |
| 2806 | } else if (mockUpdates) { | 2821 | } else if (mockUpdates) { |
| 2807 | buildConfig.publish = [ | 2822 | buildConfig.publish = [ |
| ... | @@ -2816,6 +2831,14 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( | ... | @@ -2816,6 +2831,14 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 2816 | if (platform === "mac") { | 2831 | if (platform === "mac") { |
| 2817 | const path = yield* Path.Path; | 2832 | const path = yield* Path.Path; |
| 2818 | const repoRoot = yield* RepoRoot; | 2833 | const repoRoot = yield* RepoRoot; |
| 2834 | const configuredIdentity = yield* Config.String("T3CODE_DESKTOP_MAC_SIGN_IDENTITY").pipe( | ||
| 2835 | Config.option, | ||
| 2836 | ); | ||
| 2837 | const identity = Option.getOrUndefined(configuredIdentity)?.trim(); | ||
| 2838 | const signingType = yield* Config.Literals( | ||
| 2839 | ["development", "distribution"], | ||
| 2840 | "T3CODE_DESKTOP_MAC_SIGN_TYPE", | ||
| 2841 | ).pipe(Config.withDefault("development")); | ||
| 2819 | buildConfig.mac = { | 2842 | buildConfig.mac = { |
| 2820 | target: target === "dmg" ? [target, "zip"] : [target], | 2843 | target: target === "dmg" ? [target, "zip"] : [target], |
| 2821 | icon: "icon.icns", | 2844 | icon: "icon.icns", |
| ... | @@ -2851,7 +2874,15 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( | ... | @@ -2851,7 +2874,15 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 2851 | role: "Viewer", | 2874 | role: "Viewer", |
| 2852 | }, | 2875 | }, |
| 2853 | ], | 2876 | ], |
| 2854 | ...(signed ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}), | 2877 | ...(signed || identity ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}), |
| 2878 | ...(identity | ||
| 2879 | ? { | ||
| 2880 | identity, | ||
| 2881 | type: signingType, | ||
| 2882 | hardenedRuntime: true, | ||
| 2883 | notarize: signingType === "distribution", | ||
| 2884 | } | ||
| 2885 | : {}), | ||
| 2855 | ...(macPasskeySigning | 2886 | ...(macPasskeySigning |
| 2856 | ? { | 2887 | ? { |
| 2857 | entitlements: macPasskeySigning.entitlementsPath, | 2888 | entitlements: macPasskeySigning.entitlementsPath, |
| ... | @@ -2859,6 +2890,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( | ... | @@ -2859,6 +2890,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 2859 | } | 2890 | } |
| 2860 | : {}), | 2891 | : {}), |
| 2861 | }; | 2892 | }; |
| 2893 | if (identity) buildConfig.forceCodeSigning = true; | ||
| 2862 | } | 2894 | } |
| 2863 | 2895 | ||
| 2864 | if (platform === "mac" && target === "dmg") { | 2896 | if (platform === "mac" && target === "dmg") { |
| ... | @@ -3524,6 +3556,7 @@ export const validateWindowsPackagedPayload = Effect.fn( | ... | @@ -3524,6 +3556,7 @@ export const validateWindowsPackagedPayload = Effect.fn( |
| 3524 | 3556 | ||
| 3525 | yield* verifyPackagedBundleIsSelfContained({ | 3557 | yield* verifyPackagedBundleIsSelfContained({ |
| 3526 | asarPath, | 3558 | asarPath, |
| 3559 | executablePath: path.join(packagedAppDir, input.appExecutableName), | ||
| 3527 | verbose: input.verbose ?? false, | 3560 | verbose: input.verbose ?? false, |
| 3528 | }); | 3561 | }); |
| 3529 | 3562 | ||
| ... | @@ -3533,6 +3566,53 @@ export const validateWindowsPackagedPayload = Effect.fn( | ... | @@ -3533,6 +3566,53 @@ export const validateWindowsPackagedPayload = Effect.fn( |
| 3533 | return { packagedAppDir, fileCount, unpackedFiles } as const; | 3566 | return { packagedAppDir, fileCount, unpackedFiles } as const; |
| 3534 | }); | 3567 | }); |
| 3535 | 3568 | ||
| 3569 | export const exportForkResources = Effect.fn("desktopArtifact.exportForkResources")( | ||
| 3570 | function* (input: { | ||
| 3571 | readonly stageDistDir: string; | ||
| 3572 | readonly outputDir: string; | ||
| 3573 | readonly platform: "linux" | "win"; | ||
| 3574 | readonly arch: typeof BuildArch.Type; | ||
| 3575 | readonly serverRuntime: string; | ||
| 3576 | }) { | ||
| 3577 | const fs = yield* FileSystem.FileSystem; | ||
| 3578 | const path = yield* Path.Path; | ||
| 3579 | const entries = yield* fs.readDirectory(input.stageDistDir); | ||
| 3580 | const unpacked = entries.find((entry) => entry.endsWith("-unpacked")); | ||
| 3581 | const resources = path.join( | ||
| 3582 | input.stageDistDir, | ||
| 3583 | unpacked ?? `${input.platform}-unpacked`, | ||
| 3584 | "resources", | ||
| 3585 | ); | ||
| 3586 | const required = input.platform === "win" ? ["app.asar", "server.asar"] : ["app.asar"]; | ||
| 3587 | const members: string[] = []; | ||
| 3588 | for (const file of required) { | ||
| 3589 | yield* fs.stat(path.join(resources, file)); | ||
| 3590 | members.push(file); | ||
| 3591 | if (yield* fs.exists(path.join(resources, `${file}.unpacked`))) | ||
| 3592 | members.push(`${file}.unpacked`); | ||
| 3593 | } | ||
| 3594 | const platform = input.platform === "win" ? "win32" : "linux"; | ||
| 3595 | const archive = path.join(input.outputDir, `knife-resources-${platform}-${input.arch}.tar.gz`); | ||
| 3596 | const metadata = yield* fs.makeTempDirectoryScoped(); | ||
| 3597 | yield* fs.writeFileString(path.join(metadata, "knife-server-runtime"), input.serverRuntime); | ||
| 3598 | yield* fs.makeDirectory(input.outputDir, { recursive: true }); | ||
| 3599 | yield* runCommand( | ||
| 3600 | ChildProcess.make("tar", [ | ||
| 3601 | "-czf", | ||
| 3602 | archive, | ||
| 3603 | "-C", | ||
| 3604 | resources, | ||
| 3605 | ...members, | ||
| 3606 | "-C", | ||
| 3607 | metadata, | ||
| 3608 | "knife-server-runtime", | ||
| 3609 | ]), | ||
| 3610 | { label: "Archive desktop resources for publishing", verbose: false }, | ||
| 3611 | ); | ||
| 3612 | return archive; | ||
| 3613 | }, | ||
| 3614 | ); | ||
| 3615 | |||
| 3536 | const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | 3616 | const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3537 | options: ResolvedBuildOptions, | 3617 | options: ResolvedBuildOptions, |
| 3538 | ) { | 3618 | ) { |
| ... | @@ -3611,7 +3691,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -3611,7 +3691,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3611 | 3691 | ||
| 3612 | const appVersion = options.version ?? serverPackageJson.version; | 3692 | const appVersion = options.version ?? serverPackageJson.version; |
| 3613 | const iconAssets = resolveDesktopBuildIconAssets(appVersion); | 3693 | const iconAssets = resolveDesktopBuildIconAssets(appVersion); |
| 3614 | const commitHash = yield* resolveGitCommitHash(repoRoot); | 3694 | const commitHash = yield* resolveCommitHash(repoRoot); |
| 3615 | const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped; | 3695 | const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped; |
| 3616 | const stageRoot = yield* mkdir({ | 3696 | const stageRoot = yield* mkdir({ |
| 3617 | prefix: `t3code-desktop-${options.platform}-stage-`, | 3697 | prefix: `t3code-desktop-${options.platform}-stage-`, |
| ... | @@ -3964,6 +4044,32 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -3964,6 +4044,32 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3964 | }); | 4044 | }); |
| 3965 | } | 4045 | } |
| 3966 | 4046 | ||
| 4047 | { | ||
| 4048 | const runtime = yield* Effect.tryPromise(async () => { | ||
| 4049 | const input = { | ||
| 4050 | platform: options.platform, | ||
| 4051 | arch: options.arch, | ||
| 4052 | signingFiles: | ||
| 4053 | options.platform === "mac" | ||
| 4054 | ? [ | ||
| 4055 | path.join(repoRoot, "scripts/sign-macos.ts"), | ||
| 4056 | ...(macEntitlementsPath ? [macEntitlementsPath] : []), | ||
| 4057 | ...(macPasskeySigning ? [macPasskeySigning.provisioningProfilePath] : []), | ||
| 4058 | ] | ||
| 4059 | : [], | ||
| 4060 | ...(options.platform === "win" ? { serverDirectory: path.join(stageRoot, "server") } : {}), | ||
| 4061 | }; | ||
| 4062 | return { | ||
| 4063 | sha256: await fingerprintForkRuntime(stageAppDir, input), | ||
| 4064 | shell: await fingerprintForkRuntime(stageAppDir, { ...input, shellOnly: true }), | ||
| 4065 | }; | ||
| 4066 | }); | ||
| 4067 | yield* fs.writeFileString( | ||
| 4068 | path.join(stageAppDir, FORK_RUNTIME_MANIFEST_FILE), | ||
| 4069 | yield* encodeJsonString({ version: appVersion, ...runtime }), | ||
| 4070 | ); | ||
| 4071 | } | ||
| 4072 | |||
| 3967 | // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") | 4073 | // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") |
| 3968 | // as enabled, so copy the host env and scrub empty values instead of relying | 4074 | // as enabled, so copy the host env and scrub empty values instead of relying |
| 3969 | // on `extendEnv` merging. | 4075 | // on `extendEnv` merging. |
| ... | @@ -4076,6 +4182,20 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -4076,6 +4182,20 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 4076 | yield* fs.makeDirectory(options.outputDir, { recursive: true }); | 4182 | yield* fs.makeDirectory(options.outputDir, { recursive: true }); |
| 4077 | 4183 | ||
| 4078 | const copiedArtifacts: string[] = []; | 4184 | const copiedArtifacts: string[] = []; |
| 4185 | if ( | ||
| 4186 | options.platform !== "mac" && | ||
| 4187 | (yield* Config.Boolean("T3CODE_DESKTOP_FORK_RESOURCES").pipe(Config.withDefault(false))) | ||
| 4188 | ) { | ||
| 4189 | copiedArtifacts.push( | ||
| 4190 | yield* exportForkResources({ | ||
| 4191 | stageDistDir, | ||
| 4192 | outputDir: options.outputDir, | ||
| 4193 | platform: options.platform, | ||
| 4194 | arch: options.arch, | ||
| 4195 | serverRuntime: yield* Effect.tryPromise(() => fingerprintForkServerRuntime(repoRoot)), | ||
| 4196 | }), | ||
| 4197 | ); | ||
| 4198 | } | ||
| 4079 | for (const entry of stageEntries) { | 4199 | for (const entry of stageEntries) { |
| 4080 | const from = path.join(stageDistDir, entry); | 4200 | const from = path.join(stageDistDir, entry); |
| 4081 | const stat = yield* fs.stat(from).pipe(Effect.orElseSucceed(() => null)); | 4201 | const stat = yield* fs.stat(from).pipe(Effect.orElseSucceed(() => null)); |
scripts/container-runtime.test.ts created+460| ... | @@ -0,0 +1,460 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Exercises real HTTP bodies and tar archives, including deterministic connection interruption. | ||
| 2 | import * as NodeAssert from "node:assert/strict"; | ||
| 3 | import * as NodeCrypto from "node:crypto"; | ||
| 4 | import * as NodeChildProcess from "node:child_process"; | ||
| 5 | import * as NodeFSP from "node:fs/promises"; | ||
| 6 | import * as NodeHttp from "node:http"; | ||
| 7 | import * as NodeOS from "node:os"; | ||
| 8 | import * as NodePath from "node:path"; | ||
| 9 | import * as NodeUtil from "node:util"; | ||
| 10 | import * as NodeURL from "node:url"; | ||
| 11 | import { afterEach, it, vi } from "vite-plus/test"; | ||
| 12 | |||
| 13 | import { prepareRuntime } from "./container-runtime.ts"; | ||
| 14 | |||
| 15 | const run = NodeUtil.promisify(NodeChildProcess.execFile); | ||
| 16 | const cleanups: Array<() => Promise<void>> = []; | ||
| 17 | |||
| 18 | afterEach(async () => { | ||
| 19 | vi.restoreAllMocks(); | ||
| 20 | for (const cleanup of cleanups.splice(0).toReversed()) await cleanup(); | ||
| 21 | }); | ||
| 22 | |||
| 23 | async function fixture() { | ||
| 24 | const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "knife-container-")); | ||
| 25 | cleanups.push(() => NodeFSP.rm(root, { recursive: true, force: true })); | ||
| 26 | const baseDirectory = NodePath.join(root, "base"); | ||
| 27 | const cacheDirectory = NodePath.join(root, "cache"); | ||
| 28 | await NodeFSP.mkdir(NodePath.join(baseDirectory, "apps/server/node_modules/container-native"), { | ||
| 29 | recursive: true, | ||
| 30 | }); | ||
| 31 | await NodeFSP.writeFile(NodePath.join(baseDirectory, "knife-server-runtime"), "a".repeat(64)); | ||
| 32 | await NodeFSP.writeFile( | ||
| 33 | NodePath.join(baseDirectory, "apps/server/node_modules/container-native/package.json"), | ||
| 34 | '{"main":"index.js"}', | ||
| 35 | ); | ||
| 36 | await NodeFSP.writeFile( | ||
| 37 | NodePath.join(baseDirectory, "apps/server/node_modules/container-native/index.js"), | ||
| 38 | 'module.exports = "native dependency resolved";', | ||
| 39 | ); | ||
| 40 | const serverRoot = NodePath.join(root, "server"); | ||
| 41 | const uiRoot = NodePath.join(root, "ui"); | ||
| 42 | const assetsRoot = NodePath.join(root, "assets"); | ||
| 43 | await NodeFSP.mkdir(NodePath.join(serverRoot, "apps/server/dist"), { recursive: true }); | ||
| 44 | await NodeFSP.mkdir(uiRoot); | ||
| 45 | await NodeFSP.mkdir(NodePath.join(assetsRoot, "fonts"), { recursive: true }); | ||
| 46 | await NodeFSP.writeFile(NodePath.join(assetsRoot, "fonts/ui.woff2"), "static font bytes"); | ||
| 47 | await NodeFSP.writeFile( | ||
| 48 | NodePath.join(serverRoot, "apps/server/dist/bin.mjs"), | ||
| 49 | 'import native from "container-native"; console.log(native);', | ||
| 50 | ); | ||
| 51 | await NodeFSP.writeFile(NodePath.join(uiRoot, "index.html"), "<html>latest client</html>"); | ||
| 52 | |||
| 53 | const archive = async (name: string, directory: string, members: string[]) => { | ||
| 54 | const file = `${name}.tar.gz`; | ||
| 55 | const target = NodePath.join(root, file); | ||
| 56 | await run("tar", ["-czf", target, "-C", directory, ...members]); | ||
| 57 | const bytes = await NodeFSP.readFile(target); | ||
| 58 | return { | ||
| 59 | artifact: { | ||
| 60 | file, | ||
| 61 | size: bytes.length, | ||
| 62 | sha256: NodeCrypto.createHash("sha256").update(bytes).digest("hex"), | ||
| 63 | }, | ||
| 64 | bytes, | ||
| 65 | }; | ||
| 66 | }; | ||
| 67 | const server = await archive("server", serverRoot, ["."]); | ||
| 68 | const ui = await archive("ui", uiRoot, ["."]); | ||
| 69 | const assets = await archive("assets", assetsRoot, ["."]); | ||
| 70 | const release = { | ||
| 71 | version: "0.0.123", | ||
| 72 | server: { ...server.artifact, version: "0.0.123", runtime: "a".repeat(64) }, | ||
| 73 | ui: ui.artifact, | ||
| 74 | assets: assets.artifact, | ||
| 75 | desktop: {}, | ||
| 76 | }; | ||
| 77 | const requests: string[] = []; | ||
| 78 | const statuses = new Map<string, number>(); | ||
| 79 | const interrupted = new Map<string, NodeHttp.ServerResponse | undefined>(); | ||
| 80 | const responses = new Map<string, Buffer>([ | ||
| 81 | [ | ||
| 82 | "/?ls=", | ||
| 83 | Buffer.from( | ||
| 84 | JSON.stringify({ | ||
| 85 | files: [ | ||
| 86 | { href: "0.0.9.json" }, | ||
| 87 | { href: "0.0.123.json" }, | ||
| 88 | { href: "0.0.100-mac.yml" }, | ||
| 89 | { href: "notes.json" }, | ||
| 90 | ], | ||
| 91 | }), | ||
| 92 | ), | ||
| 93 | ], | ||
| 94 | ["/0.0.123.json", Buffer.from(JSON.stringify(release))], | ||
| 95 | ["/server.tar.gz", server.bytes], | ||
| 96 | ["/ui.tar.gz", ui.bytes], | ||
| 97 | ["/assets.tar.gz", assets.bytes], | ||
| 98 | ]); | ||
| 99 | const listener = NodeHttp.createServer((request, response) => { | ||
| 100 | requests.push(request.url ?? ""); | ||
| 101 | const bytes = responses.get(request.url ?? ""); | ||
| 102 | if (bytes && interrupted.has(request.url ?? "")) { | ||
| 103 | interrupted.set(request.url ?? "", response); | ||
| 104 | response.writeHead(200, { "Content-Length": bytes.length }); | ||
| 105 | response.write(bytes.subarray(0, Math.max(1, Math.floor(bytes.length / 2)))); | ||
| 106 | return; | ||
| 107 | } | ||
| 108 | response.writeHead(statuses.get(request.url ?? "") ?? (bytes ? 200 : 404)); | ||
| 109 | response.end(bytes); | ||
| 110 | }); | ||
| 111 | await new Promise<void>((resolve) => listener.listen(0, "127.0.0.1", resolve)); | ||
| 112 | cleanups.push( | ||
| 113 | () => | ||
| 114 | new Promise<void>((resolve, reject) => | ||
| 115 | listener.close((error) => (error ? reject(error) : resolve())), | ||
| 116 | ), | ||
| 117 | ); | ||
| 118 | const address = listener.address(); | ||
| 119 | if (!address || typeof address === "string") throw new Error("HTTP fixture has no port."); | ||
| 120 | return { | ||
| 121 | root, | ||
| 122 | input: { baseDirectory, cacheDirectory, feedUrl: `http://127.0.0.1:${address.port}` }, | ||
| 123 | release, | ||
| 124 | responses, | ||
| 125 | requests, | ||
| 126 | statuses, | ||
| 127 | interrupted, | ||
| 128 | archive, | ||
| 129 | }; | ||
| 130 | } | ||
| 131 | |||
| 132 | it("installs the published server and client, resolving native modules from the base image", async () => { | ||
| 133 | const { input, requests } = await fixture(); | ||
| 134 | const entrypoint = await prepareRuntime(input); | ||
| 135 | const { stdout } = await run(process.execPath, [entrypoint]); | ||
| 136 | NodeAssert.equal(stdout.trim(), "native dependency resolved"); | ||
| 137 | NodeAssert.equal( | ||
| 138 | await NodeFSP.readFile( | ||
| 139 | NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/index.html"), | ||
| 140 | "utf8", | ||
| 141 | ), | ||
| 142 | "<html>latest client</html>", | ||
| 143 | ); | ||
| 144 | NodeAssert.equal( | ||
| 145 | await NodeFSP.readFile(NodePath.join(input.cacheDirectory, "current/version"), "utf8"), | ||
| 146 | "0.0.123", | ||
| 147 | ); | ||
| 148 | await prepareRuntime(input); | ||
| 149 | NodeAssert.deepEqual(requests, [ | ||
| 150 | "/?ls=", | ||
| 151 | "/0.0.123.json", | ||
| 152 | "/server.tar.gz", | ||
| 153 | "/assets.tar.gz", | ||
| 154 | "/ui.tar.gz", | ||
| 155 | "/?ls=", | ||
| 156 | "/0.0.123.json", | ||
| 157 | ]); | ||
| 158 | }); | ||
| 159 | |||
| 160 | it("keeps the compiled server version when a newer feed manifest includes an older native build", async () => { | ||
| 161 | const { input, root, responses, release, archive } = await fixture(); | ||
| 162 | await NodeFSP.writeFile( | ||
| 163 | NodePath.join(root, "server/apps/server/dist/bin.mjs"), | ||
| 164 | 'if (process.env.T3CODE_VERSION_OVERRIDE !== "0.0.123") process.exit(23);', | ||
| 165 | ); | ||
| 166 | const server = await archive("versioned-server", NodePath.join(root, "server"), ["."]); | ||
| 167 | const newer = { | ||
| 168 | ...release, | ||
| 169 | version: "0.0.124", | ||
| 170 | server: { ...release.server, ...server.artifact }, | ||
| 171 | }; | ||
| 172 | responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] }))); | ||
| 173 | responses.set("/0.0.124.json", Buffer.from(JSON.stringify(newer))); | ||
| 174 | responses.set("/versioned-server.tar.gz", server.bytes); | ||
| 175 | |||
| 176 | await prepareRuntime(input); | ||
| 177 | |||
| 178 | NodeAssert.equal( | ||
| 179 | await NodeFSP.readFile(NodePath.join(input.cacheDirectory, "current/version"), "utf8"), | ||
| 180 | "0.0.123", | ||
| 181 | ); | ||
| 182 | }); | ||
| 183 | |||
| 184 | it("keeps the installed runtime when a later archive fails its checksum", async () => { | ||
| 185 | const { input, responses, release } = await fixture(); | ||
| 186 | const original = await prepareRuntime(input); | ||
| 187 | const changed = { | ||
| 188 | ...release, | ||
| 189 | version: "0.0.124", | ||
| 190 | server: { ...release.server, sha256: "f".repeat(64) }, | ||
| 191 | }; | ||
| 192 | responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] }))); | ||
| 193 | responses.set("/0.0.124.json", Buffer.from(JSON.stringify(changed))); | ||
| 194 | await NodeAssert.rejects(prepareRuntime(input), /checksum verification/); | ||
| 195 | NodeAssert.equal( | ||
| 196 | await NodeFSP.realpath(NodePath.join(input.cacheDirectory, "current/apps/server/dist/bin.mjs")), | ||
| 197 | await NodeFSP.realpath(original), | ||
| 198 | ); | ||
| 199 | NodeAssert.equal( | ||
| 200 | (await NodeFSP.readdir(input.cacheDirectory)).some((name) => name.startsWith(".download-")), | ||
| 201 | false, | ||
| 202 | ); | ||
| 203 | }); | ||
| 204 | |||
| 205 | it("rejects native dependency changes before downloading either archive", async () => { | ||
| 206 | const { input, responses, release, requests } = await fixture(); | ||
| 207 | responses.set( | ||
| 208 | "/0.0.123.json", | ||
| 209 | Buffer.from( | ||
| 210 | JSON.stringify({ ...release, server: { ...release.server, runtime: "b".repeat(64) } }), | ||
| 211 | ), | ||
| 212 | ); | ||
| 213 | await NodeAssert.rejects(prepareRuntime(input), /newer container image/); | ||
| 214 | NodeAssert.deepEqual(requests, ["/?ls=", "/0.0.123.json"]); | ||
| 215 | }); | ||
| 216 | |||
| 217 | it("rejects artifact paths that leave the public release directory", async () => { | ||
| 218 | const { input, responses, release } = await fixture(); | ||
| 219 | responses.set( | ||
| 220 | "/0.0.123.json", | ||
| 221 | Buffer.from(JSON.stringify({ ...release, ui: { ...release.ui, file: "../ui.tar.gz" } })), | ||
| 222 | ); | ||
| 223 | await NodeAssert.rejects(prepareRuntime(input), /ForkReleaseManifest|Expected|file|Invalid/); | ||
| 224 | }); | ||
| 225 | |||
| 226 | it("lets the launcher use its installed runtime while the release share is unavailable", async () => { | ||
| 227 | const { input, statuses } = await fixture(); | ||
| 228 | statuses.set("/?ls=", 503); | ||
| 229 | const result = await run( | ||
| 230 | process.execPath, | ||
| 231 | [NodeURL.fileURLToPath(new URL("./container-runtime.ts", import.meta.url))], | ||
| 232 | { | ||
| 233 | env: { ...process.env, KNIFE_UPDATE_URL: input.feedUrl }, | ||
| 234 | }, | ||
| 235 | ); | ||
| 236 | NodeAssert.match(result.stderr, /Starting the installed runtime/); | ||
| 237 | }); | ||
| 238 | |||
| 239 | it("keeps the installed runtime when downloaded server code cannot start", async () => { | ||
| 240 | const { input, root, responses, release } = await fixture(); | ||
| 241 | const original = await prepareRuntime(input); | ||
| 242 | await NodeFSP.writeFile( | ||
| 243 | NodePath.join(root, "server/apps/server/dist/bin.mjs"), | ||
| 244 | 'throw new Error("broken release");', | ||
| 245 | ); | ||
| 246 | const archive = NodePath.join(root, "broken.tar.gz"); | ||
| 247 | await run("tar", ["-czf", archive, "-C", NodePath.join(root, "server"), "."]); | ||
| 248 | const bytes = await NodeFSP.readFile(archive); | ||
| 249 | const changed = { | ||
| 250 | ...release, | ||
| 251 | version: "0.0.124", | ||
| 252 | server: { | ||
| 253 | ...release.server, | ||
| 254 | size: bytes.length, | ||
| 255 | sha256: NodeCrypto.createHash("sha256").update(bytes).digest("hex"), | ||
| 256 | }, | ||
| 257 | }; | ||
| 258 | responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] }))); | ||
| 259 | responses.set("/0.0.124.json", Buffer.from(JSON.stringify(changed))); | ||
| 260 | responses.set("/server.tar.gz", bytes); | ||
| 261 | await NodeAssert.rejects(prepareRuntime(input), /broken release/); | ||
| 262 | NodeAssert.equal( | ||
| 263 | await NodeFSP.realpath(NodePath.join(input.cacheDirectory, "current/apps/server/dist/bin.mjs")), | ||
| 264 | await NodeFSP.realpath(original), | ||
| 265 | ); | ||
| 266 | }); | ||
| 267 | |||
| 268 | it("downloads only changed UI or static assets and bounds the verified artifact cache", async () => { | ||
| 269 | const { input, root, responses, requests, release, archive } = await fixture(); | ||
| 270 | const original = await prepareRuntime(input); | ||
| 271 | await NodeFSP.writeFile(NodePath.join(root, "ui/index.html"), "<html>updated client</html>"); | ||
| 272 | const nextUi = await archive("next-ui", NodePath.join(root, "ui"), ["."]); | ||
| 273 | const next = { | ||
| 274 | ...release, | ||
| 275 | version: "0.0.124", | ||
| 276 | server: { ...release.server, version: "0.0.124" }, | ||
| 277 | ui: nextUi.artifact, | ||
| 278 | }; | ||
| 279 | responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] }))); | ||
| 280 | responses.set("/0.0.124.json", Buffer.from(JSON.stringify(next))); | ||
| 281 | responses.set("/next-ui.tar.gz", nextUi.bytes); | ||
| 282 | const nextEntrypoint = await prepareRuntime(input); | ||
| 283 | NodeAssert.notEqual(nextEntrypoint, original); | ||
| 284 | NodeAssert.equal(requests.filter((url) => url === "/server.tar.gz").length, 1); | ||
| 285 | NodeAssert.equal(requests.filter((url) => url === "/assets.tar.gz").length, 1); | ||
| 286 | NodeAssert.equal(requests.filter((url) => url === "/next-ui.tar.gz").length, 1); | ||
| 287 | NodeAssert.equal( | ||
| 288 | await NodeFSP.readFile( | ||
| 289 | NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/index.html"), | ||
| 290 | "utf8", | ||
| 291 | ), | ||
| 292 | "<html>updated client</html>", | ||
| 293 | ); | ||
| 294 | NodeAssert.equal( | ||
| 295 | await NodeFSP.readFile( | ||
| 296 | NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/fonts/ui.woff2"), | ||
| 297 | "utf8", | ||
| 298 | ), | ||
| 299 | "static font bytes", | ||
| 300 | ); | ||
| 301 | |||
| 302 | await NodeFSP.writeFile(NodePath.join(root, "assets/fonts/ui.woff2"), "updated font bytes"); | ||
| 303 | const nextAssets = await archive("next-assets", NodePath.join(root, "assets"), ["."]); | ||
| 304 | const final = { | ||
| 305 | ...next, | ||
| 306 | version: "0.0.125", | ||
| 307 | server: { ...next.server, version: "0.0.125" }, | ||
| 308 | assets: nextAssets.artifact, | ||
| 309 | }; | ||
| 310 | responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.125.json" }] }))); | ||
| 311 | responses.set("/0.0.125.json", Buffer.from(JSON.stringify(final))); | ||
| 312 | responses.set("/next-assets.tar.gz", nextAssets.bytes); | ||
| 313 | const finalEntrypoint = await prepareRuntime(input); | ||
| 314 | NodeAssert.notEqual(finalEntrypoint, nextEntrypoint); | ||
| 315 | NodeAssert.equal(requests.filter((url) => url === "/server.tar.gz").length, 1); | ||
| 316 | NodeAssert.equal(requests.filter((url) => url === "/next-ui.tar.gz").length, 1); | ||
| 317 | NodeAssert.equal(requests.filter((url) => url === "/next-assets.tar.gz").length, 1); | ||
| 318 | NodeAssert.equal( | ||
| 319 | await NodeFSP.readFile( | ||
| 320 | NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/fonts/ui.woff2"), | ||
| 321 | "utf8", | ||
| 322 | ), | ||
| 323 | "updated font bytes", | ||
| 324 | ); | ||
| 325 | const cached = await NodeFSP.readdir(NodePath.join(input.cacheDirectory, "artifacts")); | ||
| 326 | NodeAssert.deepEqual( | ||
| 327 | cached.sort(), | ||
| 328 | [final.server, final.assets, final.ui].map(({ sha256 }) => `${sha256}.tar.gz`).sort(), | ||
| 329 | ); | ||
| 330 | NodeAssert.equal( | ||
| 331 | (await NodeFSP.readdir(input.cacheDirectory)).filter((name) => name.startsWith("release-")) | ||
| 332 | .length, | ||
| 333 | 1, | ||
| 334 | ); | ||
| 335 | }); | ||
| 336 | |||
| 337 | it("prefetches updates without changing the running release, then activates on restart", async () => { | ||
| 338 | const { input, root, responses, requests, release, archive } = await fixture(); | ||
| 339 | const original = await prepareRuntime(input); | ||
| 340 | const current = NodePath.join(input.cacheDirectory, "current"); | ||
| 341 | const pointer = await NodeFSP.readlink(current); | ||
| 342 | let pending = original; | ||
| 343 | for (const version of ["0.0.124", "0.0.125"]) { | ||
| 344 | await NodeFSP.writeFile(NodePath.join(root, "ui/index.html"), `<html>${version}</html>`); | ||
| 345 | const nextUi = await archive(`ui-${version}`, NodePath.join(root, "ui"), ["."]); | ||
| 346 | const next = { | ||
| 347 | ...release, | ||
| 348 | version, | ||
| 349 | server: { ...release.server, version }, | ||
| 350 | ui: nextUi.artifact, | ||
| 351 | }; | ||
| 352 | responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: `${version}.json` }] }))); | ||
| 353 | responses.set(`/${version}.json`, Buffer.from(JSON.stringify(next))); | ||
| 354 | responses.set(`/${nextUi.artifact.file}`, nextUi.bytes); | ||
| 355 | const staged = await prepareRuntime({ ...input, activate: false }); | ||
| 356 | NodeAssert.notEqual(staged, original); | ||
| 357 | if (pending !== original) await NodeAssert.rejects(NodeFSP.access(pending)); | ||
| 358 | pending = staged; | ||
| 359 | NodeAssert.equal(await NodeFSP.readlink(current), pointer); | ||
| 360 | NodeAssert.equal(await NodeFSP.readFile(NodePath.join(current, "version"), "utf8"), "0.0.123"); | ||
| 361 | NodeAssert.equal( | ||
| 362 | await NodeFSP.readFile(NodePath.join(current, "apps/server/dist/client/index.html"), "utf8"), | ||
| 363 | "<html>latest client</html>", | ||
| 364 | ); | ||
| 365 | NodeAssert.equal( | ||
| 366 | (await run(process.execPath, [original])).stdout.trim(), | ||
| 367 | "native dependency resolved", | ||
| 368 | ); | ||
| 369 | NodeAssert.equal( | ||
| 370 | (await NodeFSP.readdir(input.cacheDirectory)).filter((name) => name.startsWith("release-")) | ||
| 371 | .length, | ||
| 372 | 2, | ||
| 373 | ); | ||
| 374 | } | ||
| 375 | const downloads = requests.length; | ||
| 376 | NodeAssert.equal(await prepareRuntime(input), pending); | ||
| 377 | NodeAssert.deepEqual(requests.slice(downloads), ["/?ls=", "/0.0.125.json"]); | ||
| 378 | NodeAssert.equal(await NodeFSP.readFile(NodePath.join(current, "version"), "utf8"), "0.0.125"); | ||
| 379 | await NodeAssert.rejects(NodeFSP.access(original)); | ||
| 380 | }); | ||
| 381 | |||
| 382 | it("preserves the running version when prefetching identical artifacts under a newer release", async () => { | ||
| 383 | const { input, responses, release } = await fixture(); | ||
| 384 | await prepareRuntime(input); | ||
| 385 | responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] }))); | ||
| 386 | responses.set("/0.0.124.json", Buffer.from(JSON.stringify({ ...release, version: "0.0.124" }))); | ||
| 387 | await prepareRuntime({ ...input, activate: false }); | ||
| 388 | NodeAssert.equal( | ||
| 389 | await NodeFSP.readFile(NodePath.join(input.cacheDirectory, "current/version"), "utf8"), | ||
| 390 | "0.0.123", | ||
| 391 | ); | ||
| 392 | }); | ||
| 393 | |||
| 394 | it("lets the actual serve --version command exit with the background downloader loaded", async () => { | ||
| 395 | const { input, root, requests } = await fixture(); | ||
| 396 | const result = await run( | ||
| 397 | process.execPath, | ||
| 398 | [ | ||
| 399 | "--import", | ||
| 400 | NodeURL.fileURLToPath(new URL("./container-runtime.ts", import.meta.url)), | ||
| 401 | NodeURL.fileURLToPath(new URL("../apps/server/src/bin.ts", import.meta.url)), | ||
| 402 | "serve", | ||
| 403 | "--version", | ||
| 404 | ], | ||
| 405 | { | ||
| 406 | env: { | ||
| 407 | ...process.env, | ||
| 408 | KNIFE_UPDATE_URL: input.feedUrl, | ||
| 409 | T3CODE_HOME: NodePath.join(root, "home"), | ||
| 410 | }, | ||
| 411 | timeout: 10_000, | ||
| 412 | }, | ||
| 413 | ); | ||
| 414 | NodeAssert.match(result.stdout, /^t3 v/); | ||
| 415 | NodeAssert.deepEqual(requests, []); | ||
| 416 | }); | ||
| 417 | |||
| 418 | it.each(["/?ls=", "/0.0.124.json", "/server.tar.gz"])( | ||
| 419 | "classifies a mid-body disconnect at %s as a transient outage while preserving the installed release", | ||
| 420 | async (interruptedPath) => { | ||
| 421 | const { input, release, responses, interrupted } = await fixture(); | ||
| 422 | const original = await prepareRuntime(input); | ||
| 423 | responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] }))); | ||
| 424 | responses.set( | ||
| 425 | "/0.0.124.json", | ||
| 426 | Buffer.from( | ||
| 427 | JSON.stringify({ | ||
| 428 | ...release, | ||
| 429 | version: "0.0.124", | ||
| 430 | server: { ...release.server, sha256: "f".repeat(64) }, | ||
| 431 | }), | ||
| 432 | ), | ||
| 433 | ); | ||
| 434 | interrupted.set(interruptedPath, undefined); | ||
| 435 | const fetch = globalThis.fetch; | ||
| 436 | vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => { | ||
| 437 | const response = await fetch(url, options); | ||
| 438 | const requested = new URL(response.url); | ||
| 439 | interrupted.get(`${requested.pathname}${requested.search}`)?.destroy(); | ||
| 440 | return response; | ||
| 441 | }); | ||
| 442 | await NodeAssert.rejects(prepareRuntime(input), /release share is unavailable/); | ||
| 443 | NodeAssert.equal( | ||
| 444 | await NodeFSP.realpath( | ||
| 445 | NodePath.join(input.cacheDirectory, "current/apps/server/dist/bin.mjs"), | ||
| 446 | ), | ||
| 447 | await NodeFSP.realpath(original), | ||
| 448 | ); | ||
| 449 | NodeAssert.equal( | ||
| 450 | (await NodeFSP.readdir(input.cacheDirectory)).some((name) => name.startsWith(".download-")), | ||
| 451 | false, | ||
| 452 | ); | ||
| 453 | }, | ||
| 454 | ); | ||
| 455 | |||
| 456 | it("keeps malformed JSON fatal instead of treating it as a connection outage", async () => { | ||
| 457 | const { input, responses } = await fixture(); | ||
| 458 | responses.set("/0.0.123.json", Buffer.from("{")); | ||
| 459 | await NodeAssert.rejects(prepareRuntime(input), SyntaxError); | ||
| 460 | }); | ||
scripts/container-runtime.ts created+259| ... | @@ -0,0 +1,259 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off globalFetch:off globalConsole:off globalTimers:off - Container bootstrap runs before the Effect server starts. | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | import * as NodeFS from "node:fs"; | ||
| 4 | import * as NodeFSP from "node:fs/promises"; | ||
| 5 | import * as NodePath from "node:path"; | ||
| 6 | import * as NodeStreamPromises from "node:stream/promises"; | ||
| 7 | import * as NodeChildProcess from "node:child_process"; | ||
| 8 | import * as NodeUtil from "node:util"; | ||
| 9 | import * as NodeURL from "node:url"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import { | ||
| 12 | ForkReleaseManifest, | ||
| 13 | ForkReleaseListing, | ||
| 14 | latestForkRelease, | ||
| 15 | } from "@t3tools/shared/forkUpdateDirectory"; | ||
| 16 | |||
| 17 | const run = NodeUtil.promisify(NodeChildProcess.execFile); | ||
| 18 | const decodeManifest = Schema.decodeUnknownSync(ForkReleaseManifest); | ||
| 19 | const decodeListing = Schema.decodeUnknownSync(ForkReleaseListing); | ||
| 20 | |||
| 21 | class FeedUnavailableError extends Error { | ||
| 22 | constructor(message = "The release share is unavailable. Starting the installed runtime.") { | ||
| 23 | super(message); | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | function rethrowBodyError(error: unknown): never { | ||
| 28 | if ( | ||
| 29 | error instanceof TypeError || | ||
| 30 | (error instanceof DOMException && ["AbortError", "TimeoutError"].includes(error.name)) | ||
| 31 | ) { | ||
| 32 | throw new FeedUnavailableError(); | ||
| 33 | } | ||
| 34 | throw error; | ||
| 35 | } | ||
| 36 | |||
| 37 | async function request(url: URL, timeout: number) { | ||
| 38 | const response = await fetch(url, { | ||
| 39 | signal: AbortSignal.timeout(timeout), | ||
| 40 | cache: "no-store", | ||
| 41 | }).catch(() => { | ||
| 42 | throw new FeedUnavailableError(); | ||
| 43 | }); | ||
| 44 | if (response.status >= 500) { | ||
| 45 | throw new FeedUnavailableError(); | ||
| 46 | } | ||
| 47 | if (!response.ok) throw new Error(`Release share returned HTTP ${response.status}.`); | ||
| 48 | return response; | ||
| 49 | } | ||
| 50 | |||
| 51 | export async function prepareRuntime(input: { | ||
| 52 | feedUrl: string; | ||
| 53 | baseDirectory: string; | ||
| 54 | cacheDirectory: string; | ||
| 55 | activate?: boolean; | ||
| 56 | }): Promise<string> { | ||
| 57 | const baseUrl = new URL(input.feedUrl.endsWith("/") ? input.feedUrl : `${input.feedUrl}/`); | ||
| 58 | if (baseUrl.protocol !== "https:" && baseUrl.hostname !== "127.0.0.1") { | ||
| 59 | throw new Error("KNIFE_UPDATE_URL must use HTTPS."); | ||
| 60 | } | ||
| 61 | |||
| 62 | const listingUrl = new URL(baseUrl); | ||
| 63 | listingUrl.searchParams.set("ls", ""); | ||
| 64 | const listing = await request(listingUrl, 30_000); | ||
| 65 | const version = latestForkRelease(decodeListing(await listing.json().catch(rethrowBodyError))); | ||
| 66 | if (version === undefined) { | ||
| 67 | throw new FeedUnavailableError("No releases are published. Starting the installed runtime."); | ||
| 68 | } | ||
| 69 | const response = await request(new URL(`${version}.json`, baseUrl), 30_000); | ||
| 70 | const release = decodeManifest(await response.json().catch(rethrowBodyError)); | ||
| 71 | if (release.version !== version) | ||
| 72 | throw new Error("The published release version does not match its filename."); | ||
| 73 | const runtime = ( | ||
| 74 | await NodeFSP.readFile(NodePath.join(input.baseDirectory, "knife-server-runtime"), "utf8") | ||
| 75 | ).trim(); | ||
| 76 | if (runtime !== release.server.runtime) { | ||
| 77 | throw new Error( | ||
| 78 | "The release needs a newer container image. Rebuild the image before restarting.", | ||
| 79 | ); | ||
| 80 | } | ||
| 81 | |||
| 82 | const artifacts = [ | ||
| 83 | ["server", release.server], | ||
| 84 | ["assets", release.assets], | ||
| 85 | ["ui", release.ui], | ||
| 86 | ] as const; | ||
| 87 | const identity = NodeCrypto.createHash("sha256") | ||
| 88 | .update(artifacts.map(([, { sha256 }]) => sha256).join("\0")) | ||
| 89 | .digest("hex"); | ||
| 90 | const blobs = NodePath.join(input.cacheDirectory, "artifacts"); | ||
| 91 | await NodeFSP.mkdir(blobs, { recursive: true }); | ||
| 92 | const directory = NodePath.join(input.cacheDirectory, `release-${identity}`); | ||
| 93 | const entrypoint = NodePath.join(directory, "apps/server/dist/bin.mjs"); | ||
| 94 | const ready = await NodeFSP.readFile(NodePath.join(directory, "version"), "utf8").catch( | ||
| 95 | () => null, | ||
| 96 | ); | ||
| 97 | if (ready === null) { | ||
| 98 | const staging = await NodeFSP.mkdtemp(NodePath.join(input.cacheDirectory, ".download-")); | ||
| 99 | try { | ||
| 100 | for (const [kind, artifact] of artifacts) { | ||
| 101 | const archive = NodePath.join(blobs, `${artifact.sha256}.tar.gz`); | ||
| 102 | const cached = await NodeFSP.stat(archive).then( | ||
| 103 | (stat) => stat.isFile() && stat.size === artifact.size, | ||
| 104 | () => false, | ||
| 105 | ); | ||
| 106 | if (!cached) { | ||
| 107 | const partial = NodePath.join(staging, `${kind}.tar.gz`); | ||
| 108 | const downloaded = await request(new URL(artifact.file, baseUrl), 180_000); | ||
| 109 | const body = downloaded.body; | ||
| 110 | if (!body) { | ||
| 111 | throw new Error(`Could not download the ${kind} archive (HTTP ${downloaded.status}).`); | ||
| 112 | } | ||
| 113 | const hash = NodeCrypto.createHash("sha256"); | ||
| 114 | let size = 0; | ||
| 115 | await NodeStreamPromises.pipeline( | ||
| 116 | async function* () { | ||
| 117 | try { | ||
| 118 | for await (const chunk of body) { | ||
| 119 | size += chunk.length; | ||
| 120 | if (size > artifact.size) | ||
| 121 | throw new Error(`The ${kind} archive exceeds its published size.`); | ||
| 122 | hash.update(chunk); | ||
| 123 | yield chunk; | ||
| 124 | } | ||
| 125 | } catch (error) { | ||
| 126 | rethrowBodyError(error); | ||
| 127 | } | ||
| 128 | }, | ||
| 129 | NodeFS.createWriteStream(partial, { flags: "wx" }), | ||
| 130 | ); | ||
| 131 | if (size !== artifact.size || hash.digest("hex") !== artifact.sha256) { | ||
| 132 | throw new Error(`The ${kind} archive failed checksum verification.`); | ||
| 133 | } | ||
| 134 | |||
| 135 | await NodeFSP.rename(partial, archive); | ||
| 136 | } | ||
| 137 | |||
| 138 | const { stdout } = await run("tar", ["-tzf", archive]); | ||
| 139 | const members = stdout.split(/\r?\n/).filter(Boolean); | ||
| 140 | if ( | ||
| 141 | members.some((member) => { | ||
| 142 | const normalized = member.replace(/^\.\//, "").replace(/\/$/, ""); | ||
| 143 | return ( | ||
| 144 | normalized.startsWith("/") || | ||
| 145 | normalized.split("/").includes("..") || | ||
| 146 | (kind === "server" && | ||
| 147 | !["", ".", "apps", "apps/server", "apps/server/dist"].includes(normalized) && | ||
| 148 | !normalized.startsWith("apps/server/dist/")) | ||
| 149 | ); | ||
| 150 | }) | ||
| 151 | ) { | ||
| 152 | throw new Error(`The ${kind} archive contains an unexpected path.`); | ||
| 153 | } | ||
| 154 | const destination = | ||
| 155 | kind === "server" ? staging : NodePath.join(staging, "apps/server/dist/client"); | ||
| 156 | await NodeFSP.mkdir(destination, { recursive: true }); | ||
| 157 | await run("tar", [ | ||
| 158 | "-xzf", | ||
| 159 | archive, | ||
| 160 | "-C", | ||
| 161 | destination, | ||
| 162 | "--no-same-owner", | ||
| 163 | "--no-same-permissions", | ||
| 164 | ]); | ||
| 165 | } | ||
| 166 | |||
| 167 | await NodeFSP.access(NodePath.join(staging, "apps/server/dist/bin.mjs")); | ||
| 168 | await NodeFSP.access(NodePath.join(staging, "apps/server/dist/client/index.html")); | ||
| 169 | await NodeFSP.symlink( | ||
| 170 | NodePath.join(input.baseDirectory, "apps/server/node_modules"), | ||
| 171 | NodePath.join(staging, "node_modules"), | ||
| 172 | "dir", | ||
| 173 | ); | ||
| 174 | await run( | ||
| 175 | process.execPath, | ||
| 176 | [NodePath.join(staging, "apps/server/dist/bin.mjs"), "--version"], | ||
| 177 | { | ||
| 178 | env: { ...process.env, T3CODE_VERSION_OVERRIDE: release.server.version }, | ||
| 179 | timeout: 30_000, | ||
| 180 | }, | ||
| 181 | ); | ||
| 182 | await NodeFSP.writeFile( | ||
| 183 | NodePath.join(staging, "knife-server-runtime"), | ||
| 184 | release.server.runtime, | ||
| 185 | ); | ||
| 186 | await NodeFSP.writeFile(NodePath.join(staging, "version"), release.server.version); | ||
| 187 | await NodeFSP.rm(directory, { recursive: true, force: true }); | ||
| 188 | await NodeFSP.rename(staging, directory); | ||
| 189 | } finally { | ||
| 190 | await NodeFSP.rm(staging, { recursive: true, force: true }); | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | const keepDirectories = new Set([NodePath.basename(directory)]); | ||
| 195 | if (input.activate !== false) { | ||
| 196 | await NodeFSP.writeFile(NodePath.join(directory, "version"), release.server.version); | ||
| 197 | const pointer = NodePath.join(input.cacheDirectory, ".current"); | ||
| 198 | await NodeFSP.rm(pointer, { force: true }); | ||
| 199 | await NodeFSP.symlink(directory, pointer, "dir"); | ||
| 200 | await NodeFSP.rename(pointer, NodePath.join(input.cacheDirectory, "current")); | ||
| 201 | } else { | ||
| 202 | const active = await NodeFSP.realpath(NodePath.join(input.cacheDirectory, "current")).catch( | ||
| 203 | () => null, | ||
| 204 | ); | ||
| 205 | if (active !== null) keepDirectories.add(NodePath.basename(active)); | ||
| 206 | } | ||
| 207 | for (const name of await NodeFSP.readdir(input.cacheDirectory)) { | ||
| 208 | if (/^release-[a-f0-9]{64}$/.test(name) && !keepDirectories.has(name)) { | ||
| 209 | await NodeFSP.rm(NodePath.join(input.cacheDirectory, name), { recursive: true, force: true }); | ||
| 210 | } | ||
| 211 | } | ||
| 212 | const keep = new Set(artifacts.map(([, { sha256 }]) => `${sha256}.tar.gz`)); | ||
| 213 | for (const name of await NodeFSP.readdir(blobs)) { | ||
| 214 | if (/^[a-f0-9]{64}\.tar\.gz$/.test(name) && !keep.has(name)) { | ||
| 215 | await NodeFSP.rm(NodePath.join(blobs, name)); | ||
| 216 | } | ||
| 217 | } | ||
| 218 | return entrypoint; | ||
| 219 | } | ||
| 220 | |||
| 221 | if ( | ||
| 222 | process.argv[1] && | ||
| 223 | NodeURL.fileURLToPath(import.meta.url) === NodePath.resolve(process.argv[1]) | ||
| 224 | ) { | ||
| 225 | const feedUrl = process.env.KNIFE_UPDATE_URL; | ||
| 226 | if (feedUrl) { | ||
| 227 | await prepareRuntime({ | ||
| 228 | feedUrl, | ||
| 229 | baseDirectory: "/opt/knife", | ||
| 230 | cacheDirectory: NodePath.join(process.env.T3CODE_HOME ?? "/data", "runtime/knife-container"), | ||
| 231 | }).catch((error: unknown) => { | ||
| 232 | console.error( | ||
| 233 | error instanceof Error ? error.message : "Could not prepare the container update.", | ||
| 234 | ); | ||
| 235 | if (!(error instanceof FeedUnavailableError)) process.exitCode = 1; | ||
| 236 | }); | ||
| 237 | } | ||
| 238 | } else if (process.argv[2] === "serve" && process.env.KNIFE_UPDATE_URL) { | ||
| 239 | const feedUrl = process.env.KNIFE_UPDATE_URL; | ||
| 240 | let downloading = false; | ||
| 241 | setInterval(() => { | ||
| 242 | if (downloading) return; | ||
| 243 | downloading = true; | ||
| 244 | void prepareRuntime({ | ||
| 245 | feedUrl, | ||
| 246 | baseDirectory: "/opt/knife", | ||
| 247 | cacheDirectory: NodePath.join(process.env.T3CODE_HOME ?? "/data", "runtime/knife-container"), | ||
| 248 | activate: false, | ||
| 249 | }) | ||
| 250 | .catch((error: unknown) => { | ||
| 251 | console.error( | ||
| 252 | error instanceof Error ? error.message : "Could not download the container update.", | ||
| 253 | ); | ||
| 254 | }) | ||
| 255 | .finally(() => { | ||
| 256 | downloading = false; | ||
| 257 | }); | ||
| 258 | }, 4 * 60_000).unref(); | ||
| 259 | } | ||
scripts/lib/fork-runtime.test.ts created+187| ... | @@ -0,0 +1,187 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Exercises actual staged build inputs. | ||
| 2 | import * as NodeFSP from "node:fs/promises"; | ||
| 3 | import * as NodeOS from "node:os"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | import { afterEach, beforeEach, expect, it } from "vite-plus/test"; | ||
| 6 | import { fingerprintForkRuntime, fingerprintForkServerRuntime } from "./fork-runtime.ts"; | ||
| 7 | |||
| 8 | let root: string; | ||
| 9 | let signing: string; | ||
| 10 | const fingerprint = () => | ||
| 11 | fingerprintForkRuntime(root, { platform: "mac", arch: "arm64", signingFiles: [signing] }); | ||
| 12 | |||
| 13 | beforeEach(async () => { | ||
| 14 | root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "knife-runtime-")); | ||
| 15 | signing = NodePath.join(root, "sign.ts"); | ||
| 16 | await NodeFSP.mkdir(NodePath.join(root, "apps/server/dist/client"), { recursive: true }); | ||
| 17 | await NodeFSP.mkdir(NodePath.join(root, "apps/desktop/dist-electron"), { recursive: true }); | ||
| 18 | await NodeFSP.mkdir(NodePath.join(root, "node_modules/native"), { recursive: true }); | ||
| 19 | await NodeFSP.writeFile(signing, "signing policy"); | ||
| 20 | await NodeFSP.writeFile( | ||
| 21 | NodePath.join(root, "package.json"), | ||
| 22 | JSON.stringify({ | ||
| 23 | version: "0.0.1", | ||
| 24 | buildVersion: "0.0.1", | ||
| 25 | t3codeCommitHash: "one", | ||
| 26 | devDependencies: { electron: "44.4.5" }, | ||
| 27 | build: { mac: { sign: signing } }, | ||
| 28 | }), | ||
| 29 | ); | ||
| 30 | await NodeFSP.writeFile(NodePath.join(root, "apps/server/dist/bin.mjs"), "server"); | ||
| 31 | await NodeFSP.writeFile(NodePath.join(root, "apps/desktop/dist-electron/preload.cjs"), "preload"); | ||
| 32 | await NodeFSP.writeFile(NodePath.join(root, "apps/desktop/dist-electron/boot.cjs"), "bootstrap"); | ||
| 33 | await NodeFSP.writeFile(NodePath.join(root, "node_modules/native/binary.node"), "native"); | ||
| 34 | for (const file of [ | ||
| 35 | "scripts/container-runtime.ts", | ||
| 36 | "infra/container/knife", | ||
| 37 | "packages/shared/src/forkUpdateDirectory.ts", | ||
| 38 | ]) { | ||
| 39 | await NodeFSP.mkdir(NodePath.dirname(NodePath.join(root, file)), { recursive: true }); | ||
| 40 | await NodeFSP.writeFile(NodePath.join(root, file), "stable bootstrap"); | ||
| 41 | } | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("keeps the installed shell for backend, renderer, and runtime dependency changes", async () => { | ||
| 45 | const input = { platform: "mac", arch: "arm64", signingFiles: [signing], shellOnly: true }; | ||
| 46 | const initial = await fingerprintForkRuntime(root, input); | ||
| 47 | for (const file of [ | ||
| 48 | "apps/server/dist/bin.mjs", | ||
| 49 | "apps/desktop/dist-electron/preload.cjs", | ||
| 50 | "node_modules/native/binary.node", | ||
| 51 | ]) | ||
| 52 | await NodeFSP.appendFile(NodePath.join(root, file), "new code"); | ||
| 53 | expect(await fingerprintForkRuntime(root, input)).toBe(initial); | ||
| 54 | await NodeFSP.appendFile( | ||
| 55 | NodePath.join(root, "apps/desktop/dist-electron/boot.cjs"), | ||
| 56 | "new bootstrap", | ||
| 57 | ); | ||
| 58 | expect(await fingerprintForkRuntime(root, input)).not.toBe(initial); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it("ignores package-manager launchers that are excluded from the packaged runtime", async () => { | ||
| 62 | const initial = await fingerprint(); | ||
| 63 | await NodeFSP.mkdir(NodePath.join(root, "node_modules/.bin")); | ||
| 64 | await NodeFSP.writeFile( | ||
| 65 | NodePath.join(root, "node_modules/.bin/yaml"), | ||
| 66 | `#!/bin/sh\nexport NODE_PATH=${root}/node_modules`, | ||
| 67 | ); | ||
| 68 | expect(await fingerprint()).toBe(initial); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("fingerprints Windows server code without making renderer changes replace the shell", async () => { | ||
| 72 | const serverDirectory = NodePath.join(root, "server-sidecar"); | ||
| 73 | for (const directory of ["apps/server/dist/client", "node_modules/native"]) { | ||
| 74 | await NodeFSP.mkdir(NodePath.join(serverDirectory, directory), { recursive: true }); | ||
| 75 | } | ||
| 76 | await NodeFSP.writeFile(NodePath.join(serverDirectory, "apps/server/dist/bin.mjs"), "server"); | ||
| 77 | await NodeFSP.writeFile( | ||
| 78 | NodePath.join(serverDirectory, "node_modules/native/binary.node"), | ||
| 79 | "native", | ||
| 80 | ); | ||
| 81 | const sidecar = NodePath.join(root, "apps/desktop/prod-resources/windows-server/server.asar"); | ||
| 82 | await NodeFSP.mkdir(NodePath.dirname(sidecar), { recursive: true }); | ||
| 83 | await NodeFSP.writeFile(sidecar, "packed server and renderer"); | ||
| 84 | const input = { platform: "win", arch: "x64", signingFiles: [], serverDirectory }; | ||
| 85 | const initial = await fingerprintForkRuntime(root, input); | ||
| 86 | const shell = await fingerprintForkRuntime(root, { ...input, shellOnly: true }); | ||
| 87 | |||
| 88 | await NodeFSP.writeFile( | ||
| 89 | NodePath.join(serverDirectory, "apps/server/dist/client/index.html"), | ||
| 90 | "new UI", | ||
| 91 | ); | ||
| 92 | await NodeFSP.writeFile(sidecar, "repacked server and new renderer"); | ||
| 93 | expect(await fingerprintForkRuntime(root, input)).toBe(initial); | ||
| 94 | expect(await fingerprintForkRuntime(root, { ...input, shellOnly: true })).toBe(shell); | ||
| 95 | |||
| 96 | await NodeFSP.appendFile(NodePath.join(serverDirectory, "apps/server/dist/bin.mjs"), "new code"); | ||
| 97 | const changed = await fingerprintForkRuntime(root, input); | ||
| 98 | expect(changed).not.toBe(initial); | ||
| 99 | expect(await fingerprintForkRuntime(root, { ...input, shellOnly: true })).toBe(shell); | ||
| 100 | await NodeFSP.appendFile( | ||
| 101 | NodePath.join(serverDirectory, "node_modules/native/binary.node"), | ||
| 102 | "new native", | ||
| 103 | ); | ||
| 104 | expect(await fingerprintForkRuntime(root, input)).not.toBe(changed); | ||
| 105 | }); | ||
| 106 | |||
| 107 | it("matches the image to locked external dependencies, including their transitives", async () => { | ||
| 108 | const lock = `importers: | ||
| 109 | apps/server: | ||
| 110 | dependencies: | ||
| 111 | node-pty: {version: 1.0.0} | ||
| 112 | effect: {version: 4.0.0} | ||
| 113 | packages: | ||
| 114 | node-pty@1.0.0: {resolution: {integrity: pty}} | ||
| 115 | native-helper@2.0.0: {resolution: {integrity: helper}} | ||
| 116 | effect@4.0.0: {resolution: {integrity: effect}} | ||
| 117 | snapshots: | ||
| 118 | node-pty@1.0.0: | ||
| 119 | dependencies: {native-helper: 2.0.0} | ||
| 120 | native-helper@2.0.0: {} | ||
| 121 | effect@4.0.0: {} | ||
| 122 | `; | ||
| 123 | const path = NodePath.join(root, "pnpm-lock.yaml"); | ||
| 124 | await NodeFSP.writeFile(path, lock); | ||
| 125 | const initial = await fingerprintForkServerRuntime(root); | ||
| 126 | await NodeFSP.writeFile(path, lock.replaceAll("4.0.0", "4.0.1")); | ||
| 127 | expect(await fingerprintForkServerRuntime(root)).toBe(initial); | ||
| 128 | await NodeFSP.writeFile(path, lock.replaceAll("2.0.0", "2.0.1")); | ||
| 129 | expect(await fingerprintForkServerRuntime(root)).not.toBe(initial); | ||
| 130 | }); | ||
| 131 | |||
| 132 | it("refuses an incomplete external dependency closure", async () => { | ||
| 133 | await NodeFSP.writeFile( | ||
| 134 | NodePath.join(root, "pnpm-lock.yaml"), | ||
| 135 | `importers: | ||
| 136 | apps/server: | ||
| 137 | dependencies: | ||
| 138 | node-pty: {version: 1.0.0} | ||
| 139 | packages: {} | ||
| 140 | snapshots: {} | ||
| 141 | `, | ||
| 142 | ); | ||
| 143 | await expect(fingerprintForkServerRuntime(root)).rejects.toThrow( | ||
| 144 | "Missing locked runtime dependency", | ||
| 145 | ); | ||
| 146 | }); | ||
| 147 | |||
| 148 | afterEach(async () => NodeFSP.rm(root, { recursive: true, force: true })); | ||
| 149 | |||
| 150 | it("keeps the runtime identity across consecutive UI-only release versions", async () => { | ||
| 151 | const initial = await fingerprint(); | ||
| 152 | const metadata = JSON.parse(await NodeFSP.readFile(NodePath.join(root, "package.json"), "utf8")); | ||
| 153 | metadata.version = metadata.buildVersion = "0.0.2"; | ||
| 154 | metadata.t3codeCommitHash = "two"; | ||
| 155 | await NodeFSP.writeFile(NodePath.join(root, "package.json"), JSON.stringify(metadata)); | ||
| 156 | await NodeFSP.writeFile(NodePath.join(root, "apps/server/dist/client/index.html"), "new UI"); | ||
| 157 | await NodeFSP.writeFile( | ||
| 158 | NodePath.join(root, "apps/desktop/dist-electron/main.cjs.map"), | ||
| 159 | "new sources", | ||
| 160 | ); | ||
| 161 | expect(await fingerprint()).toBe(initial); | ||
| 162 | }); | ||
| 163 | |||
| 164 | it.each([ | ||
| 165 | "apps/server/dist/bin.mjs", | ||
| 166 | "apps/desktop/dist-electron/preload.cjs", | ||
| 167 | "node_modules/native/binary.node", | ||
| 168 | "sign.ts", | ||
| 169 | ])("requires a restart for changed runtime bytes in %s", async (file) => { | ||
| 170 | const initial = await fingerprint(); | ||
| 171 | await NodeFSP.appendFile(NodePath.join(root, file), "changed"); | ||
| 172 | expect(await fingerprint()).not.toBe(initial); | ||
| 173 | }); | ||
| 174 | |||
| 175 | it("includes architecture, Electron and executable permissions", async () => { | ||
| 176 | const initial = await fingerprint(); | ||
| 177 | expect( | ||
| 178 | await fingerprintForkRuntime(root, { platform: "mac", arch: "x64", signingFiles: [signing] }), | ||
| 179 | ).not.toBe(initial); | ||
| 180 | const metadata = JSON.parse(await NodeFSP.readFile(NodePath.join(root, "package.json"), "utf8")); | ||
| 181 | metadata.devDependencies.electron = "45.0.0"; | ||
| 182 | await NodeFSP.writeFile(NodePath.join(root, "package.json"), JSON.stringify(metadata)); | ||
| 183 | const newerElectron = await fingerprint(); | ||
| 184 | expect(newerElectron).not.toBe(initial); | ||
| 185 | await NodeFSP.chmod(NodePath.join(root, "node_modules/native/binary.node"), 0o755); | ||
| 186 | expect(await fingerprint()).not.toBe(newerElectron); | ||
| 187 | }); | ||
scripts/lib/fork-runtime.ts created+151| ... | @@ -0,0 +1,151 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Fingerprints unsigned release files at the packaging boundary. | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | import * as NodeFSP from "node:fs/promises"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | |||
| 7 | import { fromYaml } from "@t3tools/shared/schemaYaml"; | ||
| 8 | |||
| 9 | import { isRuntimeExternalCliDependency } from "./cli-external-packages.ts"; | ||
| 10 | |||
| 11 | /** Release bookkeeping and renderer files cannot change the running backend's compatibility. */ | ||
| 12 | export async function fingerprintForkRuntime( | ||
| 13 | directory: string, | ||
| 14 | input: { | ||
| 15 | platform: string; | ||
| 16 | arch: string; | ||
| 17 | signingFiles: readonly string[]; | ||
| 18 | shellOnly?: boolean; | ||
| 19 | serverDirectory?: string; | ||
| 20 | }, | ||
| 21 | ): Promise<string> { | ||
| 22 | const hash = NodeCrypto.createHash("sha256").update( | ||
| 23 | JSON.stringify({ platform: input.platform, arch: input.arch }), | ||
| 24 | ); | ||
| 25 | const visit = async ( | ||
| 26 | root: string, | ||
| 27 | relative: string, | ||
| 28 | ancestors: ReadonlySet<string>, | ||
| 29 | ): Promise<void> => { | ||
| 30 | if ( | ||
| 31 | relative === "apps/server/dist/client" || | ||
| 32 | relative === "apps/desktop/prod-resources/windows-server" || | ||
| 33 | /^apps\/(?:server\/dist|desktop\/dist-electron)\/.*\.(?:mjs|cjs|js)\.map$/.test(relative) | ||
| 34 | ) | ||
| 35 | return; | ||
| 36 | if ( | ||
| 37 | input.shellOnly && | ||
| 38 | (relative === "node_modules" || | ||
| 39 | relative === "apps/server" || | ||
| 40 | (relative.startsWith("apps/desktop/dist-electron/") && | ||
| 41 | !["boot.cjs", "compileCache.cjs"].includes(NodePath.basename(relative)))) | ||
| 42 | ) | ||
| 43 | return; | ||
| 44 | const file = NodePath.join(root, relative); | ||
| 45 | const real = await NodeFSP.realpath(file); | ||
| 46 | const stat = await NodeFSP.stat(real); | ||
| 47 | if (stat.isDirectory()) { | ||
| 48 | if (ancestors.has(real)) return; | ||
| 49 | const next = new Set([...ancestors, real]); | ||
| 50 | for (const name of (await NodeFSP.readdir(real)).sort()) { | ||
| 51 | if (name === ".bin" || name === ".modules.yaml" || name === ".pnpm-workspace-state-v1.json") | ||
| 52 | continue; | ||
| 53 | await visit(root, NodePath.posix.join(relative, name), next); | ||
| 54 | } | ||
| 55 | return; | ||
| 56 | } | ||
| 57 | hash | ||
| 58 | .update(relative) | ||
| 59 | .update("\0") | ||
| 60 | .update(String(stat.mode & 0o777)) | ||
| 61 | .update("\0") | ||
| 62 | .update(await NodeFSP.readFile(real)) | ||
| 63 | .update("\0"); | ||
| 64 | }; | ||
| 65 | |||
| 66 | const metadata: Record<string, unknown> = JSON.parse( | ||
| 67 | await NodeFSP.readFile(NodePath.join(directory, "package.json"), "utf8"), | ||
| 68 | ); | ||
| 69 | delete metadata.version; | ||
| 70 | delete metadata.buildVersion; | ||
| 71 | delete metadata.t3codeCommitHash; | ||
| 72 | if (input.shellOnly) delete metadata.dependencies; | ||
| 73 | let normalized = JSON.stringify(metadata).replaceAll(directory, "<app>"); | ||
| 74 | for (const [index, file] of input.signingFiles.entries()) { | ||
| 75 | normalized = normalized.replaceAll(file, `<signing-${index}>`); | ||
| 76 | hash.update(await NodeFSP.readFile(file)); | ||
| 77 | } | ||
| 78 | hash.update(normalized); | ||
| 79 | for (const root of input.shellOnly ? ["apps"] : ["apps", "node_modules"]) | ||
| 80 | await visit(directory, root, new Set()); | ||
| 81 | if (!input.shellOnly && input.serverDirectory !== undefined) { | ||
| 82 | hash.update("server-sidecar\0"); | ||
| 83 | for (const root of ["apps", "node_modules"]) | ||
| 84 | await visit(input.serverDirectory, root, new Set()); | ||
| 85 | } | ||
| 86 | return hash.digest("hex"); | ||
| 87 | } | ||
| 88 | |||
| 89 | const LockedDependencies = Schema.Record(Schema.String, Schema.String); | ||
| 90 | const RuntimeLock = fromYaml( | ||
| 91 | Schema.Struct({ | ||
| 92 | importers: Schema.Record( | ||
| 93 | Schema.String, | ||
| 94 | Schema.Struct({ | ||
| 95 | dependencies: Schema.optionalKey( | ||
| 96 | Schema.Record(Schema.String, Schema.Struct({ version: Schema.String })), | ||
| 97 | ), | ||
| 98 | }), | ||
| 99 | ), | ||
| 100 | packages: Schema.Record(Schema.String, Schema.Unknown), | ||
| 101 | snapshots: Schema.Record( | ||
| 102 | Schema.String, | ||
| 103 | Schema.Struct({ | ||
| 104 | dependencies: Schema.optionalKey(LockedDependencies), | ||
| 105 | optionalDependencies: Schema.optionalKey(LockedDependencies), | ||
| 106 | }), | ||
| 107 | ), | ||
| 108 | }), | ||
| 109 | ); | ||
| 110 | const decodeRuntimeLock = Schema.decodeSync(RuntimeLock); | ||
| 111 | |||
| 112 | /** Matches portable server code to the image's complete locked external dependency closure. */ | ||
| 113 | export async function fingerprintForkServerRuntime(directory: string): Promise<string> { | ||
| 114 | const lock = decodeRuntimeLock( | ||
| 115 | await NodeFSP.readFile(NodePath.join(directory, "pnpm-lock.yaml"), "utf8"), | ||
| 116 | ); | ||
| 117 | const roots = Object.entries(lock.importers["apps/server"]?.dependencies ?? {}) | ||
| 118 | .filter(([name]) => isRuntimeExternalCliDependency(name)) | ||
| 119 | .map(([name, { version }]) => `${name}@${version}`) | ||
| 120 | .sort(); | ||
| 121 | if (roots.length === 0) throw new Error("The server runtime has no locked dependencies."); | ||
| 122 | |||
| 123 | const visited = new Set<string>(); | ||
| 124 | const visit = (key: string): void => { | ||
| 125 | if (visited.has(key)) return; | ||
| 126 | visited.add(key); | ||
| 127 | const snapshot = lock.snapshots[key]; | ||
| 128 | if (!snapshot) throw new Error(`Missing locked runtime dependency: ${key}.`); | ||
| 129 | for (const [name, version] of Object.entries({ | ||
| 130 | ...snapshot.dependencies, | ||
| 131 | ...snapshot.optionalDependencies, | ||
| 132 | })) { | ||
| 133 | visit(`${name}@${version}`); | ||
| 134 | } | ||
| 135 | }; | ||
| 136 | roots.forEach(visit); | ||
| 137 | |||
| 138 | const closure = [...visited].sort().map((key) => { | ||
| 139 | const packageKey = key.replace(/\(.*$/, ""); | ||
| 140 | return [key, lock.packages[packageKey], lock.snapshots[key]]; | ||
| 141 | }); | ||
| 142 | const hash = NodeCrypto.createHash("sha256").update(JSON.stringify({ roots, closure })); | ||
| 143 | for (const file of [ | ||
| 144 | "scripts/container-runtime.ts", | ||
| 145 | "infra/container/knife", | ||
| 146 | "packages/shared/src/forkUpdateDirectory.ts", | ||
| 147 | ]) { | ||
| 148 | hash.update(await NodeFSP.readFile(NodePath.join(directory, file))); | ||
| 149 | } | ||
| 150 | return hash.digest("hex"); | ||
| 151 | } | ||
scripts/lib/update-manifest.ts+2| ... | @@ -113,6 +113,8 @@ export function parseUpdateManifest( | ... | @@ -113,6 +113,8 @@ export function parseUpdateManifest( |
| 113 | continue; | 113 | continue; |
| 114 | } | 114 | } |
| 115 | 115 | ||
| 116 | if (currentFile !== null && /^ blockMapSize:\s*\d+$/.test(line)) continue; | ||
| 117 | |||
| 116 | if (line === "files:") { | 118 | if (line === "files:") { |
| 117 | inFiles = true; | 119 | inFiles = true; |
| 118 | continue; | 120 | continue; |
scripts/release-fork.test.ts created+1210| ... | @@ -0,0 +1,1210 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Exercises publishing against a real HTTP fixture. | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | import * as NodeChildProcess from "node:child_process"; | ||
| 4 | import * as NodeFSP from "node:fs/promises"; | ||
| 5 | import * as NodeHttp from "node:http"; | ||
| 6 | import * as NodeOS from "node:os"; | ||
| 7 | import * as NodePath from "node:path"; | ||
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import { FetchHttpClient } from "effect/http"; | ||
| 13 | import { afterEach, beforeEach, expect } from "vite-plus/test"; | ||
| 14 | import { it } from "@effect/vitest"; | ||
| 15 | import { createPackage } from "@electron/asar"; | ||
| 16 | |||
| 17 | import { ForkReleaseManifest, latestForkRelease } from "@t3tools/shared/forkUpdateDirectory"; | ||
| 18 | |||
| 19 | import { | ||
| 20 | ingestForkRelease, | ||
| 21 | publishForkRelease, | ||
| 22 | resolveForkMacSigningType, | ||
| 23 | } from "./release-fork.ts"; | ||
| 24 | import { parseUpdateManifest } from "./lib/update-manifest.ts"; | ||
| 25 | |||
| 26 | const decodeRelease = Schema.decodeEffect(Schema.fromJsonString(ForkReleaseManifest)); | ||
| 27 | |||
| 28 | it("selects the configured certificate fingerprint and requires notarization for Developer ID", () => { | ||
| 29 | const developmentIdentity = "A".repeat(40); | ||
| 30 | const distributionIdentity = "B".repeat(40); | ||
| 31 | const identities = [ | ||
| 32 | ` 1) ${developmentIdentity} "Apple Development: Private Owner (TESTTEAM)"`, | ||
| 33 | ` 2) ${distributionIdentity} "Developer ID Application: Private Owner (TESTTEAM)"`, | ||
| 34 | " 2 valid identities found", | ||
| 35 | ].join("\n"); | ||
| 36 | |||
| 37 | expect(resolveForkMacSigningType(developmentIdentity.toLowerCase(), identities, undefined)).toBe( | ||
| 38 | "development", | ||
| 39 | ); | ||
| 40 | expect(resolveForkMacSigningType(distributionIdentity, identities, "knife-notary")).toBe( | ||
| 41 | "distribution", | ||
| 42 | ); | ||
| 43 | expect(() => resolveForkMacSigningType(distributionIdentity, identities, undefined)).toThrow( | ||
| 44 | "Developer ID releases need notarization", | ||
| 45 | ); | ||
| 46 | expect(() => resolveForkMacSigningType(developmentIdentity, identities, "knife-notary")).toThrow( | ||
| 47 | "Notarization requires a Developer ID Application certificate", | ||
| 48 | ); | ||
| 49 | expect(() => resolveForkMacSigningType("C".repeat(40), identities, undefined)).toThrow( | ||
| 50 | "release signing identity is unavailable", | ||
| 51 | ); | ||
| 52 | expect(() => | ||
| 53 | resolveForkMacSigningType( | ||
| 54 | distributionIdentity, | ||
| 55 | ` 1) ${distributionIdentity} "Developer ID Installer: Private Owner (TESTTEAM)"`, | ||
| 56 | "knife-notary", | ||
| 57 | ), | ||
| 58 | ).toThrow("Notarization requires a Developer ID Application certificate"); | ||
| 59 | }); | ||
| 60 | |||
| 61 | let directory: string; | ||
| 62 | let origin: string; | ||
| 63 | let release: Awaited<ReturnType<typeof makeRelease>>; | ||
| 64 | let deniedMethod: string | undefined; | ||
| 65 | let deniedRequest: ((method: string, name: string) => boolean) | undefined; | ||
| 66 | let beforeManifestPut: (() => void) | undefined; | ||
| 67 | let beforePut: ((name: string) => void) | undefined; | ||
| 68 | let beforeListing: (() => void) | undefined; | ||
| 69 | let afterReadmePut: (() => void) | undefined; | ||
| 70 | let beforeDelete: (() => void) | undefined; | ||
| 71 | let downloadFailure: "rejected" | "corrupted" | undefined; | ||
| 72 | let missingDeleteStatus = 400; | ||
| 73 | const stored = new Map<string, Buffer>(); | ||
| 74 | const requests: string[] = []; | ||
| 75 | const server = NodeHttp.createServer(async (request, response) => { | ||
| 76 | const url = new URL(request.url!, origin); | ||
| 77 | if (url.searchParams.has("ls")) { | ||
| 78 | beforeListing?.(); | ||
| 79 | requests.push("GET listing"); | ||
| 80 | response | ||
| 81 | .writeHead(200, { "Content-Type": "application/json" }) | ||
| 82 | .end(JSON.stringify({ files: [...stored.keys()].map((href) => ({ href })) })); | ||
| 83 | return; | ||
| 84 | } | ||
| 85 | const name = decodeURIComponent(url.pathname.split("/").at(-1)!); | ||
| 86 | requests.push(`${request.method} ${name}`); | ||
| 87 | if ( | ||
| 88 | request.method === deniedMethod || | ||
| 89 | deniedRequest?.(request.method!, name) || | ||
| 90 | (downloadFailure === "rejected" && request.method === "PUT" && name.endsWith(".PARTIAL")) || | ||
| 91 | (["PUT", "DELETE", "MOVE"].includes(request.method!) && request.headers.pw !== "fixture-key") | ||
| 92 | ) { | ||
| 93 | response.writeHead(401).end(); | ||
| 94 | return; | ||
| 95 | } | ||
| 96 | if (!["PUT", "DELETE", "MOVE"].includes(request.method!)) | ||
| 97 | expect(request.headers.pw).toBeUndefined(); | ||
| 98 | if (request.method === "DELETE") { | ||
| 99 | expect(url.pathname.startsWith("/write/")).toBe(true); | ||
| 100 | beforeDelete?.(); | ||
| 101 | response.writeHead(stored.delete(name) ? 200 : missingDeleteStatus).end(); | ||
| 102 | return; | ||
| 103 | } | ||
| 104 | if (request.method === "MOVE") { | ||
| 105 | expect(url.pathname.startsWith("/write/")).toBe(true); | ||
| 106 | const destination = new URL(request.headers.destination!); | ||
| 107 | expect(destination.pathname.startsWith("/write/")).toBe(true); | ||
| 108 | const target = decodeURIComponent(destination.pathname.split("/").at(-1)!); | ||
| 109 | const content = stored.get(name); | ||
| 110 | if (!content) { | ||
| 111 | response.writeHead(404).end(); | ||
| 112 | return; | ||
| 113 | } | ||
| 114 | if (stored.has(target) && request.headers.overwrite === "F") { | ||
| 115 | response.writeHead(412).end(); | ||
| 116 | return; | ||
| 117 | } | ||
| 118 | stored.set(target, content); | ||
| 119 | stored.delete(name); | ||
| 120 | response.writeHead(201).end(); | ||
| 121 | return; | ||
| 122 | } | ||
| 123 | if (request.method === "PUT") { | ||
| 124 | const chunks: Buffer[] = []; | ||
| 125 | for await (const chunk of request) chunks.push(Buffer.from(chunk)); | ||
| 126 | beforePut?.(name); | ||
| 127 | const target = stored.has(name) && !request.headers.replace ? `${name}.1` : name; | ||
| 128 | stored.set( | ||
| 129 | target, | ||
| 130 | downloadFailure === "corrupted" && name.endsWith(".PARTIAL") | ||
| 131 | ? Buffer.alloc(Buffer.concat(chunks).length, 120) | ||
| 132 | : Buffer.concat(chunks), | ||
| 133 | ); | ||
| 134 | if (name === `${release.version}.json`) beforeManifestPut?.(); | ||
| 135 | if (name === "preadme.md") afterReadmePut?.(); | ||
| 136 | response.writeHead(201).end(); | ||
| 137 | return; | ||
| 138 | } | ||
| 139 | const content = stored.get(name); | ||
| 140 | if (!content) response.writeHead(404).end(); | ||
| 141 | else | ||
| 142 | response | ||
| 143 | .writeHead(200, { "Content-Length": content.length }) | ||
| 144 | .end(request.method === "HEAD" ? undefined : content); | ||
| 145 | }); | ||
| 146 | |||
| 147 | async function makeArtifact(name: string, content: string, filename?: string) { | ||
| 148 | const bytes = Buffer.from(content); | ||
| 149 | const sha256 = NodeCrypto.createHash("sha256").update(bytes).digest("hex"); | ||
| 150 | const file = filename ?? `knife-${name}-${sha256}.tar.gz`; | ||
| 151 | await NodeFSP.writeFile(NodePath.join(directory, file), bytes); | ||
| 152 | return { file, sha256, size: bytes.length }; | ||
| 153 | } | ||
| 154 | |||
| 155 | async function makeRelease(): Promise<typeof ForkReleaseManifest.Type> { | ||
| 156 | return { | ||
| 157 | version: "0.0.2000000000", | ||
| 158 | ui: await makeArtifact("ui", "UI fixture"), | ||
| 159 | assets: await makeArtifact("assets", "static asset fixture"), | ||
| 160 | server: { | ||
| 161 | ...(await makeArtifact("server", "backend fixture")), | ||
| 162 | version: "0.0.2000000000", | ||
| 163 | runtime: "a".repeat(64), | ||
| 164 | }, | ||
| 165 | desktop: {}, | ||
| 166 | }; | ||
| 167 | } | ||
| 168 | |||
| 169 | async function makeDesktop(content = "new signed app bytes") { | ||
| 170 | const code = await makeArtifact("code", "new application code bytes"); | ||
| 171 | const app = await makeArtifact("app", content, "The-Knife-darwin-arm64.zip"); | ||
| 172 | const bytes = await NodeFSP.readFile(NodePath.join(directory, app.file)); | ||
| 173 | const sha512 = NodeCrypto.createHash("sha512").update(bytes).digest("base64"); | ||
| 174 | await NodeFSP.writeFile(NodePath.join(directory, `${app.file}.blockmap`), "blockmap fixture"); | ||
| 175 | await NodeFSP.writeFile( | ||
| 176 | NodePath.join(directory, "latest-mac.yml"), | ||
| 177 | `version: ${release.version}\nfiles:\n - url: ${app.file}\n sha512: ${sha512}\n size: ${app.size}\nreleaseDate: '2026-10-16T00:00:00.000Z'\n`, | ||
| 178 | ); | ||
| 179 | return { | ||
| 180 | version: release.version, | ||
| 181 | ui: release.ui, | ||
| 182 | assets: release.assets, | ||
| 183 | shell: "b".repeat(64), | ||
| 184 | runtime: "c".repeat(64), | ||
| 185 | code, | ||
| 186 | app: { ...app, version: release.version }, | ||
| 187 | }; | ||
| 188 | } | ||
| 189 | |||
| 190 | async function writeRelease() { | ||
| 191 | await NodeFSP.writeFile(NodePath.join(directory, "release.json"), JSON.stringify(release)); | ||
| 192 | } | ||
| 193 | |||
| 194 | beforeEach(async () => { | ||
| 195 | directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-fork-release-test-")); | ||
| 196 | stored.clear(); | ||
| 197 | requests.length = 0; | ||
| 198 | deniedMethod = undefined; | ||
| 199 | deniedRequest = undefined; | ||
| 200 | beforeManifestPut = undefined; | ||
| 201 | beforePut = undefined; | ||
| 202 | beforeListing = undefined; | ||
| 203 | afterReadmePut = undefined; | ||
| 204 | beforeDelete = undefined; | ||
| 205 | downloadFailure = undefined; | ||
| 206 | missingDeleteStatus = 400; | ||
| 207 | release = await makeRelease(); | ||
| 208 | await writeRelease(); | ||
| 209 | await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); | ||
| 210 | const address = server.address(); | ||
| 211 | if (!address || typeof address === "string") throw new Error("Missing fixture address"); | ||
| 212 | origin = `http://127.0.0.1:${address.port}`; | ||
| 213 | }); | ||
| 214 | |||
| 215 | afterEach(async () => { | ||
| 216 | await new Promise<void>((resolve, reject) => | ||
| 217 | server.close((error) => (error ? reject(error) : resolve())), | ||
| 218 | ); | ||
| 219 | await NodeFSP.rm(directory, { recursive: true, force: true }); | ||
| 220 | }); | ||
| 221 | |||
| 222 | const publish = () => | ||
| 223 | publishForkRelease(directory, { | ||
| 224 | feedUrl: `${origin}/read/`, | ||
| 225 | uploadUrl: `${origin}/write/`, | ||
| 226 | uploadApiKey: "fixture-key", | ||
| 227 | }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))); | ||
| 228 | |||
| 229 | it.effect("publishes verified payloads before the immutable release manifest", () => | ||
| 230 | Effect.gen(function* () { | ||
| 231 | yield* publish(); | ||
| 232 | expect(JSON.parse(stored.get(`${release.version}.json`)!.toString())).toEqual(release); | ||
| 233 | expect(stored.has("latest.json")).toBe(false); | ||
| 234 | expect(requests.indexOf(`HEAD ${release.server.file}`)).toBeLessThan( | ||
| 235 | requests.indexOf(`PUT ${release.version}.json`), | ||
| 236 | ); | ||
| 237 | expect(requests.indexOf(`PUT ${release.ui.file}`)).toBeLessThan( | ||
| 238 | requests.indexOf(`PUT ${release.version}.json`), | ||
| 239 | ); | ||
| 240 | }), | ||
| 241 | ); | ||
| 242 | |||
| 243 | it.effect("a UI release reuses the published backend without uploading it again", () => | ||
| 244 | Effect.gen(function* () { | ||
| 245 | stored.set( | ||
| 246 | "0.0.1999999999.json", | ||
| 247 | Buffer.from(JSON.stringify({ ...release, version: "0.0.1999999999" })), | ||
| 248 | ); | ||
| 249 | stored.set( | ||
| 250 | release.server.file, | ||
| 251 | yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, release.server.file))), | ||
| 252 | ); | ||
| 253 | yield* publish(); | ||
| 254 | expect(requests).not.toContain(`PUT ${release.server.file}`); | ||
| 255 | expect(requests).toContain(`PUT ${release.ui.file}`); | ||
| 256 | expect(JSON.parse(stored.get(`${release.version}.json`)!.toString()).version).toBe( | ||
| 257 | release.version, | ||
| 258 | ); | ||
| 259 | }), | ||
| 260 | ); | ||
| 261 | |||
| 262 | it.effect("rejects a corrupt local payload before touching the share", () => | ||
| 263 | Effect.gen(function* () { | ||
| 264 | yield* Effect.promise(() => | ||
| 265 | NodeFSP.writeFile(NodePath.join(directory, release.ui.file), "corrupt"), | ||
| 266 | ); | ||
| 267 | const error = yield* publish().pipe(Effect.flip); | ||
| 268 | expect(error.message).toContain("Artifact verification failed"); | ||
| 269 | expect(requests).toEqual([]); | ||
| 270 | }), | ||
| 271 | ); | ||
| 272 | |||
| 273 | it.effect("refuses to replace a newer published release", () => | ||
| 274 | Effect.gen(function* () { | ||
| 275 | stored.set( | ||
| 276 | "0.0.2000000001.json", | ||
| 277 | Buffer.from(JSON.stringify({ ...release, version: "0.0.2000000001" })), | ||
| 278 | ); | ||
| 279 | const error = yield* publish().pipe(Effect.flip); | ||
| 280 | expect(error.message).toContain("newer version"); | ||
| 281 | expect(requests).toEqual(["GET listing", "GET 0.0.2000000001.json"]); | ||
| 282 | }), | ||
| 283 | ); | ||
| 284 | |||
| 285 | it.effect("retries an identical completed publication without rewriting it", () => | ||
| 286 | Effect.gen(function* () { | ||
| 287 | yield* publish(); | ||
| 288 | requests.length = 0; | ||
| 289 | yield* publish(); | ||
| 290 | expect(requests.filter((value) => value.startsWith("PUT "))).toEqual([]); | ||
| 291 | }), | ||
| 292 | ); | ||
| 293 | |||
| 294 | it.effect( | ||
| 295 | "an equivalent completed manifest with different JSON formatting still refreshes the download page", | ||
| 296 | () => | ||
| 297 | Effect.gen(function* () { | ||
| 298 | const desktop = yield* Effect.promise(() => makeDesktop()); | ||
| 299 | release = { ...release, desktop: { "darwin-arm64": desktop } }; | ||
| 300 | yield* Effect.promise(writeRelease); | ||
| 301 | const existing = Buffer.from( | ||
| 302 | JSON.stringify(Object.fromEntries(Object.entries(release).toReversed()), null, 2), | ||
| 303 | ); | ||
| 304 | stored.set(`${release.version}.json`, existing); | ||
| 305 | for (const artifact of [release.ui, release.assets, release.server, desktop.code]) | ||
| 306 | stored.set( | ||
| 307 | artifact.file, | ||
| 308 | yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))), | ||
| 309 | ); | ||
| 310 | stored.set(`${desktop.app.file}.blockmap`, Buffer.from("blockmap fixture")); | ||
| 311 | stored.set( | ||
| 312 | desktop.app.file, | ||
| 313 | yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, desktop.app.file))), | ||
| 314 | ); | ||
| 315 | yield* publish(); | ||
| 316 | expect(stored.get(`${release.version}.json`)).toEqual(existing); | ||
| 317 | expect(requests).not.toContain(`PUT ${release.version}.json`); | ||
| 318 | expect(stored.get("preadme.md")!.toString()).toContain('href="The-Knife-darwin-arm64.zip"'); | ||
| 319 | expect(requests.filter((entry) => entry.startsWith("PUT "))).toEqual(["PUT preadme.md"]); | ||
| 320 | expect(stored.get("The-Knife-darwin-arm64.zip")).toEqual(stored.get(desktop.app.file)); | ||
| 321 | }), | ||
| 322 | ); | ||
| 323 | |||
| 324 | it.effect("does not advertise a release whose payload upload is rejected", () => | ||
| 325 | Effect.gen(function* () { | ||
| 326 | deniedMethod = "PUT"; | ||
| 327 | const error = yield* publish().pipe(Effect.flip); | ||
| 328 | expect(error.message).toContain("HTTP 401"); | ||
| 329 | expect(stored.has("latest.json")).toBe(false); | ||
| 330 | expect(stored.has(`${release.version}.json`)).toBe(false); | ||
| 331 | }), | ||
| 332 | ); | ||
| 333 | |||
| 334 | it.effect("refuses conflicting contents at the same immutable version", () => | ||
| 335 | Effect.gen(function* () { | ||
| 336 | const conflicting = Buffer.from( | ||
| 337 | JSON.stringify({ ...release, server: { ...release.server, runtime: "b".repeat(64) } }), | ||
| 338 | ); | ||
| 339 | stored.set(`${release.version}.json`, conflicting); | ||
| 340 | const error = yield* publish().pipe(Effect.flip); | ||
| 341 | expect(error.message).toContain("different contents"); | ||
| 342 | expect(stored.get(`${release.version}.json`)).toEqual(conflicting); | ||
| 343 | }), | ||
| 344 | ); | ||
| 345 | |||
| 346 | it.effect("a concurrent newer publication remains newest after this release finishes", () => | ||
| 347 | Effect.gen(function* () { | ||
| 348 | beforeManifestPut = () => | ||
| 349 | stored.set( | ||
| 350 | "0.0.2000000001.json", | ||
| 351 | Buffer.from(JSON.stringify({ ...release, version: "0.0.2000000001" })), | ||
| 352 | ); | ||
| 353 | yield* publish(); | ||
| 354 | expect(stored.has(`${release.version}.json`)).toBe(true); | ||
| 355 | expect(stored.has("0.0.2000000001.json")).toBe(true); | ||
| 356 | expect(requests.filter((entry) => entry.startsWith("PUT "))).not.toContain( | ||
| 357 | "PUT 0.0.2000000002.json", | ||
| 358 | ); | ||
| 359 | }), | ||
| 360 | ); | ||
| 361 | |||
| 362 | it.effect("reconciles concurrent native targets and keeps the newest server tuple", () => | ||
| 363 | Effect.gen(function* () { | ||
| 364 | const desktop = yield* Effect.promise(() => makeDesktop()); | ||
| 365 | release = { ...release, desktop: { "darwin-arm64": desktop } }; | ||
| 366 | yield* Effect.promise(writeRelease); | ||
| 367 | const windowsApp = yield* Effect.promise(() => | ||
| 368 | makeArtifact("app", "Windows installer", "The-Knife-0.0.2000000001-x64.exe"), | ||
| 369 | ); | ||
| 370 | const olderUi = yield* Effect.promise(() => makeArtifact("ui", "older native UI")); | ||
| 371 | const olderServer = yield* Effect.promise(() => makeArtifact("server", "older native backend")); | ||
| 372 | const concurrent = { | ||
| 373 | ...release, | ||
| 374 | version: "0.0.2000000001", | ||
| 375 | ui: olderUi, | ||
| 376 | server: { ...olderServer, version: "0.0.1999999999", runtime: release.server.runtime }, | ||
| 377 | desktop: { | ||
| 378 | "win32-x64": { | ||
| 379 | ...desktop, | ||
| 380 | version: "0.0.2000000001", | ||
| 381 | ui: olderUi, | ||
| 382 | app: { ...windowsApp, version: "0.0.2000000001" }, | ||
| 383 | }, | ||
| 384 | }, | ||
| 385 | }; | ||
| 386 | beforeManifestPut = () => { | ||
| 387 | stored.set(`${concurrent.version}.json`, Buffer.from(JSON.stringify(concurrent))); | ||
| 388 | stored.set(windowsApp.file, Buffer.from("Windows installer")); | ||
| 389 | }; | ||
| 390 | yield* publish(); | ||
| 391 | const newest = latestForkRelease({ files: [...stored.keys()].map((href) => ({ href })) })!; | ||
| 392 | const completed = yield* decodeRelease(stored.get(`${newest}.json`)!.toString()); | ||
| 393 | expect(newest).toBe("0.0.2000000002"); | ||
| 394 | expect(completed.desktop["darwin-arm64"]).toEqual(desktop); | ||
| 395 | expect(completed.desktop["win32-x64"]).toEqual(concurrent.desktop["win32-x64"]); | ||
| 396 | expect(completed.server).toEqual(release.server); | ||
| 397 | expect(completed.ui).toEqual(release.ui); | ||
| 398 | expect(completed.assets).toEqual(release.assets); | ||
| 399 | expect(stored.get("preadme.md")!.toString()).toContain("The-Knife-win32-x64.exe"); | ||
| 400 | expect(stored.get("preadme.md")!.toString()).toContain("The-Knife-darwin-arm64.zip"); | ||
| 401 | expect(stored.has(`${release.version}.json`)).toBe(false); | ||
| 402 | }), | ||
| 403 | ); | ||
| 404 | |||
| 405 | it.effect("retries a conflicting immutable repair without overwriting the other publisher", () => | ||
| 406 | Effect.gen(function* () { | ||
| 407 | const desktop = yield* Effect.promise(() => makeDesktop()); | ||
| 408 | release = { ...release, desktop: { "darwin-arm64": desktop } }; | ||
| 409 | yield* Effect.promise(writeRelease); | ||
| 410 | const concurrent = { ...release, version: "0.0.2000000001", desktop: {} }; | ||
| 411 | beforeManifestPut = () => { | ||
| 412 | stored.set(`${concurrent.version}.json`, Buffer.from(JSON.stringify(concurrent))); | ||
| 413 | }; | ||
| 414 | const winner = { | ||
| 415 | ...concurrent, | ||
| 416 | version: "0.0.2000000002", | ||
| 417 | desktop: { "linux-x64": { ...desktop, version: "0.0.2000000002" } }, | ||
| 418 | }; | ||
| 419 | beforePut = (name) => { | ||
| 420 | if (name === `${winner.version}.json`) stored.set(name, Buffer.from(JSON.stringify(winner))); | ||
| 421 | }; | ||
| 422 | yield* publish(); | ||
| 423 | const newest = latestForkRelease({ files: [...stored.keys()].map((href) => ({ href })) })!; | ||
| 424 | const completed = yield* decodeRelease(stored.get(`${newest}.json`)!.toString()); | ||
| 425 | expect(newest).toBe("0.0.2000000003"); | ||
| 426 | expect(completed.desktop["darwin-arm64"]).toEqual(desktop); | ||
| 427 | expect(completed.desktop["linux-x64"]).toEqual(winner.desktop["linux-x64"]); | ||
| 428 | expect(JSON.parse(stored.get(`${winner.version}.json`)!.toString())).toEqual(winner); | ||
| 429 | expect(stored.has(`${winner.version}.json.1`)).toBe(true); | ||
| 430 | }), | ||
| 431 | ); | ||
| 432 | |||
| 433 | it.effect.each(["unchanged", "changed"])( | ||
| 434 | "reuses the published shell while application code is %s", | ||
| 435 | (state) => | ||
| 436 | Effect.gen(function* () { | ||
| 437 | const sameRuntime = state === "unchanged"; | ||
| 438 | const desktop = yield* Effect.promise(() => makeDesktop()); | ||
| 439 | release = { ...release, desktop: { "darwin-arm64": desktop } }; | ||
| 440 | yield* Effect.promise(writeRelease); | ||
| 441 | const code = yield* Effect.promise(() => | ||
| 442 | makeArtifact("old-code", "old code archive metadata"), | ||
| 443 | ); | ||
| 444 | const app = yield* Effect.promise(() => | ||
| 445 | makeArtifact("old-app", "old signed app archive", "The-Knife-0.0.1999999999-arm64.zip"), | ||
| 446 | ); | ||
| 447 | const previous = { | ||
| 448 | ...release, | ||
| 449 | version: "0.0.1999999999", | ||
| 450 | desktop: { | ||
| 451 | "darwin-arm64": { | ||
| 452 | ...desktop, | ||
| 453 | runtime: sameRuntime ? desktop.runtime : "d".repeat(64), | ||
| 454 | code, | ||
| 455 | app: { ...app, file: desktop.app.file, version: "0.0.1999999999" }, | ||
| 456 | }, | ||
| 457 | }, | ||
| 458 | }; | ||
| 459 | stored.set(`${previous.version}.json`, Buffer.from(JSON.stringify(previous))); | ||
| 460 | for (const artifact of [code, app]) { | ||
| 461 | stored.set( | ||
| 462 | artifact === app ? desktop.app.file : artifact.file, | ||
| 463 | yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))), | ||
| 464 | ); | ||
| 465 | yield* Effect.promise(() => NodeFSP.rm(NodePath.join(directory, artifact.file))); | ||
| 466 | } | ||
| 467 | yield* publish(); | ||
| 468 | const published = JSON.parse(stored.get(`${release.version}.json`)!.toString()); | ||
| 469 | expect(published.desktop["darwin-arm64"]).toEqual({ | ||
| 470 | ...desktop, | ||
| 471 | code: sameRuntime ? code : desktop.code, | ||
| 472 | app: previous.desktop["darwin-arm64"].app, | ||
| 473 | }); | ||
| 474 | expect(requests.includes(`PUT ${desktop.code.file}`)).toBe(!sameRuntime); | ||
| 475 | expect(requests).not.toContain(`PUT ${desktop.app.file}`); | ||
| 476 | expect(requests).not.toContain(`PUT ${desktop.app.file}.blockmap`); | ||
| 477 | expect(requests).not.toContain(`PUT ${release.version}-mac.yml`); | ||
| 478 | expect(requests).toContain(`PUT ${release.ui.file}`); | ||
| 479 | requests.length = 0; | ||
| 480 | yield* publish(); | ||
| 481 | expect(requests.filter((value) => value.startsWith("PUT "))).toEqual([]); | ||
| 482 | }), | ||
| 483 | ); | ||
| 484 | |||
| 485 | it.effect("uploads new shell blockmaps before publishing its updater manifest", () => | ||
| 486 | Effect.gen(function* () { | ||
| 487 | const desktop = yield* Effect.promise(() => makeDesktop()); | ||
| 488 | release = { ...release, desktop: { "darwin-arm64": desktop } }; | ||
| 489 | yield* Effect.promise(writeRelease); | ||
| 490 | yield* publish(); | ||
| 491 | expect(stored.get(desktop.app.file)).toEqual(Buffer.from("new signed app bytes")); | ||
| 492 | expect(stored.get(`${desktop.app.file}.blockmap`)).toEqual(Buffer.from("blockmap fixture")); | ||
| 493 | const moves = requests.filter((entry) => entry.startsWith("MOVE ")); | ||
| 494 | expect(moves).toHaveLength(2); | ||
| 495 | expect(requests.indexOf(moves.at(-1)!)).toBeLessThan( | ||
| 496 | requests.indexOf(`PUT ${release.version}-mac.yml`), | ||
| 497 | ); | ||
| 498 | expect(requests.indexOf(`PUT ${release.version}-mac.yml`)).toBeLessThan( | ||
| 499 | requests.indexOf(`PUT ${release.version}.json`), | ||
| 500 | ); | ||
| 501 | }), | ||
| 502 | ); | ||
| 503 | |||
| 504 | it.effect("rejects a missing local built payload before touching the share", () => | ||
| 505 | Effect.gen(function* () { | ||
| 506 | yield* Effect.promise(() => NodeFSP.rm(NodePath.join(directory, release.server.file))); | ||
| 507 | const error = yield* publish().pipe(Effect.flip); | ||
| 508 | expect(error.message).toContain("Could not verify the local artifact"); | ||
| 509 | expect(requests).toEqual([]); | ||
| 510 | }), | ||
| 511 | ); | ||
| 512 | |||
| 513 | it.effect("publishes a root download page with only real platform installers", () => | ||
| 514 | Effect.gen(function* () { | ||
| 515 | const desktop = yield* Effect.promise(() => makeDesktop()); | ||
| 516 | release = { ...release, desktop: { "darwin-arm64": desktop } }; | ||
| 517 | yield* Effect.promise(writeRelease); | ||
| 518 | stored.set("preadme.md", Buffer.from("An older download page")); | ||
| 519 | stored.set("The-Knife-darwin-arm64.html", Buffer.from("Old redirect page")); | ||
| 520 | yield* publish(); | ||
| 521 | const page = stored.get("preadme.md")!.toString(); | ||
| 522 | expect(page).toContain("macOS Apple silicon"); | ||
| 523 | expect(page).toContain('href="The-Knife-darwin-arm64.zip"'); | ||
| 524 | expect(page).not.toContain("Linux"); | ||
| 525 | expect(page).not.toContain("Windows"); | ||
| 526 | expect(stored.has("preadme.md.1")).toBe(false); | ||
| 527 | expect(stored.has("The-Knife-darwin-arm64.html")).toBe(false); | ||
| 528 | expect(requests.indexOf(`PUT ${release.version}.json`)).toBeLessThan( | ||
| 529 | requests.indexOf("PUT preadme.md"), | ||
| 530 | ); | ||
| 531 | expect(page).not.toContain("fixture-key"); | ||
| 532 | expect(page).not.toContain("/write/"); | ||
| 533 | expect(stored.get("The-Knife-darwin-arm64.zip")).toEqual(stored.get(desktop.app.file)); | ||
| 534 | expect(requests.findIndex((entry) => entry.startsWith("MOVE "))).toBeLessThan( | ||
| 535 | requests.indexOf(`PUT ${release.version}.json`), | ||
| 536 | ); | ||
| 537 | expect([...stored.keys()].some((name) => name.endsWith(".PARTIAL"))).toBe(false); | ||
| 538 | const manifest = JSON.parse(stored.get(`${release.version}.json`)!.toString()); | ||
| 539 | expect(manifest.desktop["darwin-arm64"].app.file).toBe(desktop.app.file); | ||
| 540 | requests.length = 0; | ||
| 541 | yield* publish(); | ||
| 542 | expect(requests.some((request) => request.startsWith("PUT "))).toBe(false); | ||
| 543 | }), | ||
| 544 | ); | ||
| 545 | |||
| 546 | it.effect.each(["rejected", "corrupted"] as const)( | ||
| 547 | "retries a stable installer whose upload was %s without advertising an incomplete release", | ||
| 548 | (failure) => | ||
| 549 | Effect.gen(function* () { | ||
| 550 | const desktop = yield* Effect.promise(() => makeDesktop()); | ||
| 551 | release = { ...release, desktop: { "darwin-arm64": desktop } }; | ||
| 552 | yield* Effect.promise(writeRelease); | ||
| 553 | downloadFailure = failure; | ||
| 554 | |||
| 555 | const error = yield* publish().pipe(Effect.flip); | ||
| 556 | expect(error.message).toContain( | ||
| 557 | failure === "rejected" ? "HTTP 401" : "uploaded installer did not match its checksum", | ||
| 558 | ); | ||
| 559 | expect(stored.has(`${release.version}.json`)).toBe(false); | ||
| 560 | expect(stored.has("The-Knife-darwin-arm64.zip")).toBe(false); | ||
| 561 | expect(stored.has("preadme.md")).toBe(false); | ||
| 562 | deniedMethod = undefined; | ||
| 563 | downloadFailure = undefined; | ||
| 564 | requests.length = 0; | ||
| 565 | yield* publish(); | ||
| 566 | expect(stored.get("The-Knife-darwin-arm64.zip")).toEqual(stored.get(desktop.app.file)); | ||
| 567 | expect(requests).toContain(`PUT ${release.version}.json`); | ||
| 568 | expect(stored.get("preadme.md")!.toString()).toContain("The-Knife-darwin-arm64.zip"); | ||
| 569 | }), | ||
| 570 | ); | ||
| 571 | |||
| 572 | it.effect("keeps a newer completion when it arrives during download page maintenance", () => | ||
| 573 | Effect.gen(function* () { | ||
| 574 | const desktop = yield* Effect.promise(() => makeDesktop()); | ||
| 575 | release = { ...release, desktop: { "darwin-arm64": desktop } }; | ||
| 576 | yield* Effect.promise(writeRelease); | ||
| 577 | const newerApp = { | ||
| 578 | ...(yield* Effect.promise(() => | ||
| 579 | makeArtifact("app", "newer signed app", "The-Knife-0.0.2000000001-arm64.zip"), | ||
| 580 | )), | ||
| 581 | file: "The-Knife-darwin-arm64.zip", | ||
| 582 | version: "0.0.2000000001", | ||
| 583 | }; | ||
| 584 | afterReadmePut = () => { | ||
| 585 | afterReadmePut = undefined; | ||
| 586 | stored.set(newerApp.file, Buffer.from("newer signed app")); | ||
| 587 | stored.set( | ||
| 588 | "0.0.2000000001.json", | ||
| 589 | Buffer.from( | ||
| 590 | JSON.stringify({ | ||
| 591 | ...release, | ||
| 592 | version: "0.0.2000000001", | ||
| 593 | desktop: { "darwin-arm64": { ...desktop, version: "0.0.2000000001", app: newerApp } }, | ||
| 594 | }), | ||
| 595 | ), | ||
| 596 | ); | ||
| 597 | }; | ||
| 598 | yield* publish(); | ||
| 599 | const page = stored.get("preadme.md")!.toString(); | ||
| 600 | expect(page).toContain('href="The-Knife-darwin-arm64.zip"'); | ||
| 601 | expect(stored.get("The-Knife-darwin-arm64.zip")).toEqual(Buffer.from("newer signed app")); | ||
| 602 | expect(requests.filter((entry) => entry === "PUT preadme.md")).toHaveLength(1); | ||
| 603 | expect( | ||
| 604 | requests.filter((entry) => entry.startsWith("DELETE ") && !entry.endsWith(".PARTIAL")), | ||
| 605 | ).toEqual([]); | ||
| 606 | }), | ||
| 607 | ); | ||
| 608 | |||
| 609 | it.effect( | ||
| 610 | "retires conflicting installer manifests before replacement and retains matching releases", | ||
| 611 | () => | ||
| 612 | Effect.gen(function* () { | ||
| 613 | const oldDesktop = yield* Effect.promise(() => makeDesktop()); | ||
| 614 | release = { ...release, desktop: { "darwin-arm64": oldDesktop } }; | ||
| 615 | yield* Effect.promise(writeRelease); | ||
| 616 | yield* publish(); | ||
| 617 | const oldVersion = release.version; | ||
| 618 | |||
| 619 | release = { ...release, version: "0.0.2000000001" }; | ||
| 620 | const desktop = { | ||
| 621 | ...(yield* Effect.promise(() => makeDesktop("new signed app byte!"))), | ||
| 622 | shell: "d".repeat(64), | ||
| 623 | }; | ||
| 624 | expect(desktop.app.size).toBe(oldDesktop.app.size); | ||
| 625 | yield* Effect.promise(() => | ||
| 626 | NodeFSP.writeFile( | ||
| 627 | NodePath.join(directory, `${desktop.app.file}.blockmap`), | ||
| 628 | "new blockmap fixture", | ||
| 629 | ), | ||
| 630 | ); | ||
| 631 | release = { ...release, desktop: { "darwin-arm64": desktop } }; | ||
| 632 | yield* Effect.promise(writeRelease); | ||
| 633 | requests.length = 0; | ||
| 634 | yield* publish(); | ||
| 635 | const promoted = requests.findIndex((entry) => entry.startsWith("MOVE ")); | ||
| 636 | expect(requests.indexOf(`DELETE ${oldVersion}.json`)).toBeLessThan(promoted); | ||
| 637 | expect(requests.indexOf(`DELETE ${oldVersion}-mac.yml`)).toBeLessThan(promoted); | ||
| 638 | expect(requests.filter((entry) => entry === `DELETE ${oldVersion}-mac.yml`)).toHaveLength(1); | ||
| 639 | expect(requests.filter((entry) => entry.startsWith("MOVE "))).toHaveLength(2); | ||
| 640 | expect(stored.has(`${oldVersion}.json`)).toBe(false); | ||
| 641 | expect(stored.has(`${oldVersion}-mac.yml`)).toBe(false); | ||
| 642 | expect(stored.get(desktop.app.file)).toEqual(Buffer.from("new signed app byte!")); | ||
| 643 | expect(stored.get(`${desktop.app.file}.blockmap`)?.toString()).toBe("new blockmap fixture"); | ||
| 644 | const nativeManifest = parseUpdateManifest( | ||
| 645 | stored.get(`${release.version}-mac.yml`)!.toString(), | ||
| 646 | "native", | ||
| 647 | "macOS", | ||
| 648 | ); | ||
| 649 | expect(nativeManifest.version).toBe(desktop.app.version); | ||
| 650 | expect(nativeManifest.files).toEqual([ | ||
| 651 | { | ||
| 652 | url: desktop.app.file, | ||
| 653 | size: desktop.app.size, | ||
| 654 | sha512: NodeCrypto.createHash("sha512") | ||
| 655 | .update(stored.get(desktop.app.file)!) | ||
| 656 | .digest("base64"), | ||
| 657 | }, | ||
| 658 | ]); | ||
| 659 | |||
| 660 | for (const version of ["0.0.2000000002", "0.0.2000000003"]) { | ||
| 661 | release = { | ||
| 662 | ...release, | ||
| 663 | version, | ||
| 664 | ui: yield* Effect.promise(() => makeArtifact("ui", version)), | ||
| 665 | }; | ||
| 666 | yield* Effect.promise(writeRelease); | ||
| 667 | yield* publish(); | ||
| 668 | } | ||
| 669 | const completions = [...stored.keys()].filter((name) => /^0\.0\.\d+\.json$/.test(name)); | ||
| 670 | expect(completions).toEqual(["0.0.2000000002.json", "0.0.2000000003.json"]); | ||
| 671 | for (const name of completions) { | ||
| 672 | const completed = yield* decodeRelease(stored.get(name)!.toString()); | ||
| 673 | expect(completed.desktop["darwin-arm64"]!.app.sha256).toBe( | ||
| 674 | NodeCrypto.createHash("sha256").update(stored.get(desktop.app.file)!).digest("hex"), | ||
| 675 | ); | ||
| 676 | } | ||
| 677 | expect([...stored.keys()].filter((name) => name.endsWith(".zip"))).toEqual([ | ||
| 678 | desktop.app.file, | ||
| 679 | ]); | ||
| 680 | }), | ||
| 681 | ); | ||
| 682 | |||
| 683 | it.effect( | ||
| 684 | "repairs same-sized stable installer corruption without rewriting a completed manifest", | ||
| 685 | () => | ||
| 686 | Effect.gen(function* () { | ||
| 687 | const desktop = yield* Effect.promise(() => makeDesktop()); | ||
| 688 | release = { ...release, desktop: { "darwin-arm64": desktop } }; | ||
| 689 | yield* Effect.promise(writeRelease); | ||
| 690 | yield* publish(); | ||
| 691 | const completed = stored.get(`${release.version}.json`); | ||
| 692 | stored.set(desktop.app.file, Buffer.alloc(desktop.app.size, 120)); | ||
| 693 | requests.length = 0; | ||
| 694 | yield* publish(); | ||
| 695 | expect(stored.get(desktop.app.file)).toEqual(Buffer.from("new signed app bytes")); | ||
| 696 | expect(stored.get(`${release.version}.json`)).toBe(completed); | ||
| 697 | expect(requests).not.toContain(`PUT ${release.version}.json`); | ||
| 698 | expect(requests.filter((entry) => entry.startsWith("MOVE "))).toHaveLength(1); | ||
| 699 | }), | ||
| 700 | ); | ||
| 701 | |||
| 702 | it.effect.each(["MOVE", "retired-YML", "YML", "completion", "superseded"] as const)( | ||
| 703 | "recovers a three-platform publication after retirement fails at %s", | ||
| 704 | (failure) => | ||
| 705 | Effect.gen(function* () { | ||
| 706 | const oldDesktop = yield* Effect.promise(() => makeDesktop("old signed app bytes")); | ||
| 707 | const oldCode = yield* Effect.promise(() => makeArtifact("code", "retired Mac runtime")); | ||
| 708 | const windows = { | ||
| 709 | ...oldDesktop, | ||
| 710 | app: { | ||
| 711 | ...(yield* Effect.promise(() => | ||
| 712 | makeArtifact("app", "Windows", "The-Knife-win32-x64.exe"), | ||
| 713 | )), | ||
| 714 | version: release.version, | ||
| 715 | }, | ||
| 716 | }; | ||
| 717 | const linux = { | ||
| 718 | ...oldDesktop, | ||
| 719 | app: { | ||
| 720 | ...(yield* Effect.promise(() => | ||
| 721 | makeArtifact("app", "Linux", "The-Knife-linux-x64.AppImage"), | ||
| 722 | )), | ||
| 723 | version: release.version, | ||
| 724 | }, | ||
| 725 | }; | ||
| 726 | const ui = yield* Effect.promise(() => makeArtifact("ui", "newer portable UI")); | ||
| 727 | const assets = yield* Effect.promise(() => makeArtifact("assets", "newer portable assets")); | ||
| 728 | const server = { | ||
| 729 | ...(yield* Effect.promise(() => makeArtifact("server", "newer portable server"))), | ||
| 730 | version: "0.0.2000000002", | ||
| 731 | runtime: "e".repeat(64), | ||
| 732 | }; | ||
| 733 | const previous = { | ||
| 734 | ...release, | ||
| 735 | ui, | ||
| 736 | assets, | ||
| 737 | server, | ||
| 738 | desktop: { | ||
| 739 | "darwin-arm64": { ...oldDesktop, runtime: "f".repeat(64), code: oldCode }, | ||
| 740 | "win32-x64": windows, | ||
| 741 | "linux-x64": linux, | ||
| 742 | }, | ||
| 743 | }; | ||
| 744 | stored.set(`${previous.version}.json`, Buffer.from(JSON.stringify(previous))); | ||
| 745 | for (const artifact of [ | ||
| 746 | ui, | ||
| 747 | assets, | ||
| 748 | server, | ||
| 749 | oldCode, | ||
| 750 | oldDesktop.app, | ||
| 751 | oldDesktop.code, | ||
| 752 | windows.app, | ||
| 753 | linux.app, | ||
| 754 | ]) | ||
| 755 | stored.set( | ||
| 756 | artifact.file, | ||
| 757 | yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))), | ||
| 758 | ); | ||
| 759 | stored.set(`${previous.version}-mac.yml`, Buffer.from("retired Mac YAML")); | ||
| 760 | stored.set(`${previous.version}.yml`, Buffer.from("retained Windows YAML")); | ||
| 761 | stored.set(`${previous.version}-linux.yml`, Buffer.from("retained Linux YAML")); | ||
| 762 | release = { ...release, version: "0.0.2000000003" }; | ||
| 763 | const desktop = { | ||
| 764 | ...(yield* Effect.promise(() => makeDesktop("new signed app byte!"))), | ||
| 765 | shell: "d".repeat(64), | ||
| 766 | }; | ||
| 767 | release = { ...release, desktop: { "darwin-arm64": desktop } }; | ||
| 768 | yield* Effect.promise(writeRelease); | ||
| 769 | deniedRequest = (method, name) => | ||
| 770 | failure === "retired-YML" | ||
| 771 | ? method === "DELETE" && name === `${previous.version}-mac.yml` | ||
| 772 | : failure === "YML" | ||
| 773 | ? method === "PUT" && name === `${release.version}-mac.yml` | ||
| 774 | : failure === "completion" | ||
| 775 | ? method === "PUT" && name === `${release.version}.json` | ||
| 776 | : method === "MOVE"; | ||
| 777 | expect((yield* publish().pipe(Effect.flip)).message).toContain("HTTP 401"); | ||
| 778 | expect(stored.has(`${previous.version}.json`)).toBe(false); | ||
| 779 | const intentPath = NodePath.join(directory, "publication.json"); | ||
| 780 | const intent = JSON.parse(yield* Effect.promise(() => NodeFSP.readFile(intentPath, "utf8"))); | ||
| 781 | expect(intent.release.desktop["win32-x64"]).toEqual(windows); | ||
| 782 | expect(intent.release.desktop["linux-x64"]).toEqual(linux); | ||
| 783 | expect(intent.release.server).toEqual(server); | ||
| 784 | expect(intent.retiredFiles).toContain(oldCode.file); | ||
| 785 | deniedRequest = undefined; | ||
| 786 | requests.length = 0; | ||
| 787 | if (failure === "superseded") { | ||
| 788 | stored.set( | ||
| 789 | "0.0.2000000004.json", | ||
| 790 | Buffer.from(JSON.stringify({ ...previous, version: "0.0.2000000004" })), | ||
| 791 | ); | ||
| 792 | expect((yield* publish().pipe(Effect.flip)).message).toContain("newer version"); | ||
| 793 | expect(requests.some((entry) => /^(PUT|MOVE|DELETE) /.test(entry))).toBe(false); | ||
| 794 | expect(yield* Effect.promise(() => NodeFSP.stat(intentPath))).toBeDefined(); | ||
| 795 | return; | ||
| 796 | } | ||
| 797 | yield* publish(); | ||
| 798 | if (failure === "retired-YML") | ||
| 799 | expect(requests.indexOf(`DELETE ${previous.version}-mac.yml`)).toBeLessThan( | ||
| 800 | requests.findIndex((entry) => entry.startsWith("MOVE ")), | ||
| 801 | ); | ||
| 802 | const completed = yield* decodeRelease(stored.get(`${release.version}.json`)!.toString()); | ||
| 803 | expect(completed.desktop["win32-x64"]).toEqual(windows); | ||
| 804 | expect(completed.desktop["linux-x64"]).toEqual(linux); | ||
| 805 | expect(completed.server).toEqual(server); | ||
| 806 | expect(completed.ui).toEqual(ui); | ||
| 807 | expect(completed.assets).toEqual(assets); | ||
| 808 | expect(stored.get(desktop.app.file)).toEqual(Buffer.from("new signed app byte!")); | ||
| 809 | expect(stored.has(oldCode.file)).toBe(false); | ||
| 810 | expect(stored.has(`${previous.version}-mac.yml`)).toBe(false); | ||
| 811 | expect(stored.has(`${previous.version}.yml`)).toBe(true); | ||
| 812 | expect(stored.has(`${previous.version}-linux.yml`)).toBe(true); | ||
| 813 | expect( | ||
| 814 | yield* Effect.promise(() => | ||
| 815 | NodeFSP.stat(intentPath).then( | ||
| 816 | () => true, | ||
| 817 | () => false, | ||
| 818 | ), | ||
| 819 | ), | ||
| 820 | ).toBe(false); | ||
| 821 | }), | ||
| 822 | ); | ||
| 823 | |||
| 824 | async function storeOldRelease(version: string, content: string) { | ||
| 825 | const ui = await makeArtifact("ui", content); | ||
| 826 | const old = { ...release, version, ui }; | ||
| 827 | stored.set(`${version}.json`, Buffer.from(JSON.stringify(old))); | ||
| 828 | for (const entry of [old.ui, old.assets, old.server]) { | ||
| 829 | stored.set(entry.file, await NodeFSP.readFile(NodePath.join(directory, entry.file))); | ||
| 830 | } | ||
| 831 | return old; | ||
| 832 | } | ||
| 833 | |||
| 834 | it.effect( | ||
| 835 | "bounds completed releases while preserving referenced, unknown, and incomplete files", | ||
| 836 | () => | ||
| 837 | Effect.gen(function* () { | ||
| 838 | const oldest = yield* Effect.promise(() => storeOldRelease("0.0.1999999997", "obsolete UI")); | ||
| 839 | const middle = yield* Effect.promise(() => | ||
| 840 | storeOldRelease("0.0.1999999998", "other obsolete UI"), | ||
| 841 | ); | ||
| 842 | const previous = yield* Effect.promise(() => | ||
| 843 | storeOldRelease("0.0.1999999999", "previous UI"), | ||
| 844 | ); | ||
| 845 | const orphan = `knife-code-${"d".repeat(64)}.tar.gz`; | ||
| 846 | stored.set(orphan, Buffer.from("An incomplete publication")); | ||
| 847 | stored.set("notes.txt", Buffer.from("Keep this unrelated file")); | ||
| 848 | stored.set("0.0.1999999996.json.PARTIAL", Buffer.from("Unfinished manifest")); | ||
| 849 | yield* publish(); | ||
| 850 | for (const entry of [oldest, middle]) { | ||
| 851 | expect(stored.has(`${entry.version}.json`)).toBe(false); | ||
| 852 | expect(stored.has(entry.ui.file)).toBe(false); | ||
| 853 | } | ||
| 854 | expect(stored.has(`${previous.version}.json`)).toBe(true); | ||
| 855 | expect(stored.has(previous.ui.file)).toBe(true); | ||
| 856 | expect(stored.has(release.assets.file)).toBe(true); | ||
| 857 | expect(stored.has(release.server.file)).toBe(true); | ||
| 858 | expect(stored.has(orphan)).toBe(true); | ||
| 859 | expect(stored.has("notes.txt")).toBe(true); | ||
| 860 | expect(stored.has("0.0.1999999996.json.PARTIAL")).toBe(true); | ||
| 861 | expect(requests.indexOf(`DELETE ${oldest.ui.file}`)).toBeLessThan( | ||
| 862 | requests.indexOf(`DELETE ${oldest.version}.json`), | ||
| 863 | ); | ||
| 864 | }), | ||
| 865 | ); | ||
| 866 | |||
| 867 | it.effect("treats HTTP 404 for an already deleted obsolete artifact as successful cleanup", () => | ||
| 868 | Effect.gen(function* () { | ||
| 869 | missingDeleteStatus = 404; | ||
| 870 | const oldest = yield* Effect.promise(() => storeOldRelease("0.0.1999999997", "obsolete UI")); | ||
| 871 | yield* Effect.promise(() => storeOldRelease("0.0.1999999998", "other obsolete UI")); | ||
| 872 | yield* Effect.promise(() => storeOldRelease("0.0.1999999999", "previous UI")); | ||
| 873 | beforeDelete = () => { | ||
| 874 | beforeDelete = undefined; | ||
| 875 | stored.delete(oldest.ui.file); | ||
| 876 | }; | ||
| 877 | yield* publish(); | ||
| 878 | expect(requests).toContain(`DELETE ${oldest.ui.file}`); | ||
| 879 | expect([...stored.keys()].filter((file) => /^0\.0\.\d+\.json$/.test(file))).toEqual([ | ||
| 880 | "0.0.1999999999.json", | ||
| 881 | `${release.version}.json`, | ||
| 882 | ]); | ||
| 883 | }), | ||
| 884 | ); | ||
| 885 | |||
| 886 | it.effect.each(["0.0.2000000001", "0.0.1999999996"])( | ||
| 887 | "stops pruning when publication %s completes during cleanup", | ||
| 888 | (version) => | ||
| 889 | Effect.gen(function* () { | ||
| 890 | yield* Effect.promise(() => storeOldRelease("0.0.1999999997", "obsolete UI")); | ||
| 891 | yield* Effect.promise(() => storeOldRelease("0.0.1999999998", "other obsolete UI")); | ||
| 892 | yield* Effect.promise(() => storeOldRelease("0.0.1999999999", "previous UI")); | ||
| 893 | beforeDelete = () => { | ||
| 894 | beforeDelete = undefined; | ||
| 895 | stored.set(`${version}.json`, Buffer.from(JSON.stringify({ ...release, version }))); | ||
| 896 | }; | ||
| 897 | yield* publish(); | ||
| 898 | const firstDelete = requests.findIndex((entry) => entry.startsWith("DELETE ")); | ||
| 899 | const observed = requests.indexOf(`GET ${version}.json`); | ||
| 900 | expect(observed).toBeGreaterThan(firstDelete); | ||
| 901 | expect( | ||
| 902 | requests.slice(firstDelete + 1, observed).some((entry) => entry.startsWith("DELETE ")), | ||
| 903 | ).toBe(false); | ||
| 904 | expect(stored.has(`${version}.json`)).toBe(version === "0.0.2000000001"); | ||
| 905 | }), | ||
| 906 | ); | ||
| 907 | |||
| 908 | it.effect("reconciles a late older aggregate before its native target can be pruned", () => | ||
| 909 | Effect.gen(function* () { | ||
| 910 | const desktop = yield* Effect.promise(() => makeDesktop()); | ||
| 911 | release = { ...release, desktop: { "darwin-arm64": desktop } }; | ||
| 912 | yield* Effect.promise(writeRelease); | ||
| 913 | yield* Effect.promise(() => storeOldRelease("0.0.1999999999", "previous UI")); | ||
| 914 | const linux = { ...desktop, version: "0.0.1999999998" }; | ||
| 915 | let listings = 0; | ||
| 916 | beforeListing = () => { | ||
| 917 | listings++; | ||
| 918 | if (listings === 4) | ||
| 919 | stored.set( | ||
| 920 | "0.0.1999999998.json", | ||
| 921 | Buffer.from( | ||
| 922 | JSON.stringify({ | ||
| 923 | ...release, | ||
| 924 | version: "0.0.1999999998", | ||
| 925 | desktop: { "linux-x64": linux }, | ||
| 926 | }), | ||
| 927 | ), | ||
| 928 | ); | ||
| 929 | }; | ||
| 930 | yield* publish(); | ||
| 931 | const newest = latestForkRelease({ files: [...stored.keys()].map((href) => ({ href })) })!; | ||
| 932 | const completed = yield* decodeRelease(stored.get(`${newest}.json`)!.toString()); | ||
| 933 | expect(completed.desktop["linux-x64"]).toEqual(linux); | ||
| 934 | expect(completed.desktop["darwin-arm64"]).toEqual(desktop); | ||
| 935 | expect(requests.indexOf(`PUT ${newest}.json`)).toBeLessThan( | ||
| 936 | requests.indexOf("DELETE 0.0.1999999998.json"), | ||
| 937 | ); | ||
| 938 | }), | ||
| 939 | ); | ||
| 940 | |||
| 941 | it.effect("links the server bundle only after it is publicly available", () => | ||
| 942 | Effect.gen(function* () { | ||
| 943 | yield* publish(); | ||
| 944 | expect(stored.get("preadme.md")!.toString()).not.toContain("Docker bundle"); | ||
| 945 | stored.set("knife-server-linux-amd64.tar.gz", Buffer.from("Docker image")); | ||
| 946 | stored.set("knife-server-linux-amd64.tar.gz.sha256", Buffer.from("checksum")); | ||
| 947 | yield* publish(); | ||
| 948 | const page = stored.get("preadme.md")!.toString(); | ||
| 949 | expect(page).toContain("[Docker bundle](knife-server-linux-amd64.tar.gz)"); | ||
| 950 | expect(page).toContain("[SHA-256](knife-server-linux-amd64.tar.gz.sha256)"); | ||
| 951 | }), | ||
| 952 | ); | ||
| 953 | |||
| 954 | it.effect("preserves an older platform's UI and assets while pruning their original manifest", () => | ||
| 955 | Effect.gen(function* () { | ||
| 956 | const oldest = yield* Effect.promise(() => storeOldRelease("0.0.1999999997", "old Linux UI")); | ||
| 957 | yield* Effect.promise(() => storeOldRelease("0.0.1999999998", "obsolete UI")); | ||
| 958 | const previous = yield* Effect.promise(() => | ||
| 959 | storeOldRelease("0.0.1999999999", "previous root UI"), | ||
| 960 | ); | ||
| 961 | const code = yield* Effect.promise(() => makeArtifact("code", "Linux application code")); | ||
| 962 | const assets = yield* Effect.promise(() => makeArtifact("assets", "old Linux assets")); | ||
| 963 | const app = yield* Effect.promise(() => | ||
| 964 | makeArtifact("app", "Linux installer", "The-Knife-0.0.1999999997-x64.AppImage"), | ||
| 965 | ); | ||
| 966 | const target = { | ||
| 967 | version: oldest.version, | ||
| 968 | ui: oldest.ui, | ||
| 969 | assets, | ||
| 970 | code, | ||
| 971 | app: { ...app, version: oldest.version }, | ||
| 972 | shell: "b".repeat(64), | ||
| 973 | runtime: "c".repeat(64), | ||
| 974 | }; | ||
| 975 | stored.set( | ||
| 976 | `${previous.version}.json`, | ||
| 977 | Buffer.from(JSON.stringify({ ...previous, desktop: { "linux-x64": target } })), | ||
| 978 | ); | ||
| 979 | for (const artifact of [assets, code, app]) | ||
| 980 | stored.set( | ||
| 981 | artifact.file, | ||
| 982 | yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))), | ||
| 983 | ); | ||
| 984 | yield* publish(); | ||
| 985 | expect(stored.has(`${oldest.version}.json`)).toBe(false); | ||
| 986 | for (const artifact of [oldest.ui, assets, code, app]) | ||
| 987 | expect(stored.has(artifact.file)).toBe(true); | ||
| 988 | const published = JSON.parse(stored.get(`${release.version}.json`)!.toString()); | ||
| 989 | expect(published.desktop["linux-x64"]).toEqual(target); | ||
| 990 | }), | ||
| 991 | ); | ||
| 992 | |||
| 993 | it.effect("refuses to replace a platform with an older native build", () => | ||
| 994 | Effect.gen(function* () { | ||
| 995 | const desktop = yield* Effect.promise(() => makeDesktop()); | ||
| 996 | release = { ...release, desktop: { "darwin-arm64": desktop } }; | ||
| 997 | yield* Effect.promise(writeRelease); | ||
| 998 | stored.set( | ||
| 999 | "0.0.1999999999.json", | ||
| 1000 | Buffer.from( | ||
| 1001 | JSON.stringify({ | ||
| 1002 | ...release, | ||
| 1003 | version: "0.0.1999999999", | ||
| 1004 | desktop: { "darwin-arm64": { ...desktop, version: "0.0.2000000001" } }, | ||
| 1005 | }), | ||
| 1006 | ), | ||
| 1007 | ); | ||
| 1008 | const error = yield* publish().pipe(Effect.flip); | ||
| 1009 | expect(error.message).toContain("newer native build"); | ||
| 1010 | expect(requests.some((entry) => entry.startsWith("PUT "))).toBe(false); | ||
| 1011 | }), | ||
| 1012 | ); | ||
| 1013 | |||
| 1014 | it.effect("an older native build keeps the newest published container code and matching UI", () => | ||
| 1015 | Effect.gen(function* () { | ||
| 1016 | const desktop = yield* Effect.promise(() => makeDesktop()); | ||
| 1017 | release = { ...release, version: "0.0.2000000006", desktop: { "darwin-arm64": desktop } }; | ||
| 1018 | yield* Effect.promise(writeRelease); | ||
| 1019 | const ui = yield* Effect.promise(() => makeArtifact("ui", "newer container UI")); | ||
| 1020 | const assets = yield* Effect.promise(() => makeArtifact("assets", "newer container assets")); | ||
| 1021 | const server = { | ||
| 1022 | ...(yield* Effect.promise(() => makeArtifact("server", "newer container code"))), | ||
| 1023 | runtime: "e".repeat(64), | ||
| 1024 | version: "0.0.2000000001", | ||
| 1025 | }; | ||
| 1026 | const previous = { ...release, version: "0.0.2000000005", ui, assets, server, desktop: {} }; | ||
| 1027 | stored.set(`${previous.version}.json`, Buffer.from(JSON.stringify(previous))); | ||
| 1028 | for (const artifact of [ui, assets, server]) | ||
| 1029 | stored.set( | ||
| 1030 | artifact.file, | ||
| 1031 | yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, artifact.file))), | ||
| 1032 | ); | ||
| 1033 | yield* publish(); | ||
| 1034 | const published = JSON.parse(stored.get(`${release.version}.json`)!.toString()); | ||
| 1035 | expect(published.ui).toEqual(ui); | ||
| 1036 | expect(published.assets).toEqual(assets); | ||
| 1037 | expect(published.server).toEqual(server); | ||
| 1038 | expect(published.desktop["darwin-arm64"]).toEqual(desktop); | ||
| 1039 | expect(requests).not.toContain(`PUT ${release.server.file}`); | ||
| 1040 | expect(requests).toContain(`PUT ${desktop.ui.file}`); | ||
| 1041 | }), | ||
| 1042 | ); | ||
| 1043 | |||
| 1044 | it.effect("retains another platform's complete target when publishing a single native build", () => | ||
| 1045 | Effect.gen(function* () { | ||
| 1046 | const desktop = yield* Effect.promise(() => makeDesktop()); | ||
| 1047 | const prior = { ...desktop, version: "0.0.1999999999" }; | ||
| 1048 | const previous = { ...release, version: "0.0.1999999999", desktop: { "linux-x64": prior } }; | ||
| 1049 | stored.set(`${previous.version}.json`, Buffer.from(JSON.stringify(previous))); | ||
| 1050 | for (const entry of [prior.ui, prior.assets, prior.code, prior.app]) | ||
| 1051 | stored.set( | ||
| 1052 | entry.file, | ||
| 1053 | yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(directory, entry.file))), | ||
| 1054 | ); | ||
| 1055 | release = { ...release, desktop: { "darwin-arm64": desktop } }; | ||
| 1056 | yield* Effect.promise(writeRelease); | ||
| 1057 | yield* publish(); | ||
| 1058 | const published = JSON.parse(stored.get(`${release.version}.json`)!.toString()); | ||
| 1059 | expect(published.desktop["linux-x64"]).toEqual(prior); | ||
| 1060 | expect(published.desktop["darwin-arm64"]).toEqual(desktop); | ||
| 1061 | expect(stored.get("preadme.md")!.toString()).toContain("Linux x64"); | ||
| 1062 | }), | ||
| 1063 | ); | ||
| 1064 | |||
| 1065 | it.effect.each(["linux", "win32"] as const)( | ||
| 1066 | "ingests native %s resources without mixing desktop and server dependencies", | ||
| 1067 | (platform) => | ||
| 1068 | Effect.gen(function* () { | ||
| 1069 | const source = NodePath.join(directory, "native"); | ||
| 1070 | const resources = NodePath.join(directory, "resources"); | ||
| 1071 | const app = NodePath.join(directory, "app-source"); | ||
| 1072 | const backend = platform === "win32" ? NodePath.join(directory, "server-source") : app; | ||
| 1073 | const output = NodePath.join(directory, "ingested"); | ||
| 1074 | const extension = platform === "linux" ? ".AppImage" : ".exe"; | ||
| 1075 | const yaml = platform === "linux" ? "latest-linux.yml" : "latest.yml"; | ||
| 1076 | const installer = `The-Knife-${release.version}-${platform === "linux" ? "x86_64" : "x64"}${extension}`; | ||
| 1077 | yield* Effect.promise(async () => { | ||
| 1078 | for (const path of [ | ||
| 1079 | source, | ||
| 1080 | resources, | ||
| 1081 | NodePath.join(app, "apps/desktop/dist-electron"), | ||
| 1082 | NodePath.join(app, "node_modules/same"), | ||
| 1083 | NodePath.join(backend, "node_modules/same"), | ||
| 1084 | NodePath.join(backend, "apps/server/dist/client/.vite"), | ||
| 1085 | ]) | ||
| 1086 | await NodeFSP.mkdir(path, { recursive: true }); | ||
| 1087 | await NodeFSP.writeFile( | ||
| 1088 | NodePath.join(app, "knife-runtime.json"), | ||
| 1089 | JSON.stringify({ | ||
| 1090 | version: release.version, | ||
| 1091 | sha256: "c".repeat(64), | ||
| 1092 | shell: "b".repeat(64), | ||
| 1093 | }), | ||
| 1094 | ); | ||
| 1095 | await NodeFSP.writeFile( | ||
| 1096 | NodePath.join(app, "apps/desktop/dist-electron/main.cjs"), | ||
| 1097 | "desktop code", | ||
| 1098 | ); | ||
| 1099 | await NodeFSP.writeFile( | ||
| 1100 | NodePath.join(app, "node_modules/same/index.js"), | ||
| 1101 | "desktop dependency", | ||
| 1102 | ); | ||
| 1103 | await NodeFSP.writeFile( | ||
| 1104 | NodePath.join(backend, "node_modules/same/index.js"), | ||
| 1105 | "server dependency", | ||
| 1106 | ); | ||
| 1107 | await NodeFSP.writeFile(NodePath.join(backend, "apps/server/dist/bin.mjs"), "server code"); | ||
| 1108 | await NodeFSP.writeFile( | ||
| 1109 | NodePath.join(backend, "apps/server/dist/client/main.js"), | ||
| 1110 | "UI code", | ||
| 1111 | ); | ||
| 1112 | await NodeFSP.writeFile( | ||
| 1113 | NodePath.join(backend, "apps/server/dist/client/image.png"), | ||
| 1114 | "static asset", | ||
| 1115 | ); | ||
| 1116 | await NodeFSP.writeFile( | ||
| 1117 | NodePath.join(backend, "apps/server/dist/client/.vite/manifest.json"), | ||
| 1118 | "{}", | ||
| 1119 | ); | ||
| 1120 | await createPackage(app, NodePath.join(resources, "app.asar")); | ||
| 1121 | if (platform === "win32") | ||
| 1122 | await createPackage(backend, NodePath.join(resources, "server.asar")); | ||
| 1123 | await NodeFSP.writeFile(NodePath.join(resources, "knife-server-runtime"), "e".repeat(64)); | ||
| 1124 | NodeChildProcess.execFileSync("tar", [ | ||
| 1125 | "-czf", | ||
| 1126 | NodePath.join(source, `knife-resources-${platform}-x64.tar.gz`), | ||
| 1127 | "-C", | ||
| 1128 | resources, | ||
| 1129 | "app.asar", | ||
| 1130 | ...(platform === "win32" ? ["server.asar"] : []), | ||
| 1131 | "knife-server-runtime", | ||
| 1132 | ]); | ||
| 1133 | const bytes = Buffer.from("native installer fixture"); | ||
| 1134 | await NodeFSP.writeFile(NodePath.join(source, installer), bytes); | ||
| 1135 | const sha512 = NodeCrypto.createHash("sha512").update(bytes).digest("base64"); | ||
| 1136 | const deb = `The-Knife-${release.version}-amd64.deb`; | ||
| 1137 | if (platform === "linux") await NodeFSP.writeFile(NodePath.join(source, deb), bytes); | ||
| 1138 | await NodeFSP.writeFile( | ||
| 1139 | NodePath.join(source, yaml), | ||
| 1140 | `version: ${release.version}\nfiles:\n - url: ${installer}\n sha512: ${sha512}\n size: ${bytes.length}\n${platform === "linux" ? ` blockMapSize: 178033\n - url: ${deb}\n sha512: ${sha512}\n size: ${bytes.length}\n` : ""}releaseDate: '2026-10-16T00:00:00.000Z'\n`, | ||
| 1141 | ); | ||
| 1142 | }); | ||
| 1143 | yield* ingestForkRelease(`${platform}-x64`, source, output, "0.0.2000000001").pipe( | ||
| 1144 | Effect.provide(NodeServices.layer), | ||
| 1145 | ); | ||
| 1146 | const ingested = yield* Effect.promise(() => | ||
| 1147 | NodeFSP.readFile(NodePath.join(output, "release.json"), "utf8"), | ||
| 1148 | ).pipe(Effect.flatMap(decodeRelease)); | ||
| 1149 | const target = ingested.desktop[`${platform}-x64`]!; | ||
| 1150 | expect(ingested.version).toBe("0.0.2000000001"); | ||
| 1151 | expect(target.version).toBe(release.version); | ||
| 1152 | expect(target.app.version).toBe(release.version); | ||
| 1153 | expect(ingested.server.runtime).toBe("e".repeat(64)); | ||
| 1154 | expect(ingested.server.version).toBe(release.version); | ||
| 1155 | yield* Effect.promise(async () => { | ||
| 1156 | const extracted = NodePath.join(directory, "extracted"); | ||
| 1157 | await NodeFSP.mkdir(extracted); | ||
| 1158 | NodeChildProcess.execFileSync("tar", [ | ||
| 1159 | "-xzf", | ||
| 1160 | NodePath.join(output, target.code.file), | ||
| 1161 | "-C", | ||
| 1162 | extracted, | ||
| 1163 | ]); | ||
| 1164 | expect( | ||
| 1165 | await NodeFSP.readFile( | ||
| 1166 | NodePath.join( | ||
| 1167 | extracted, | ||
| 1168 | platform === "win32" ? "server/apps/server/dist/bin.mjs" : "apps/server/dist/bin.mjs", | ||
| 1169 | ), | ||
| 1170 | "utf8", | ||
| 1171 | ), | ||
| 1172 | ).toBe("server code"); | ||
| 1173 | if (platform === "win32") { | ||
| 1174 | expect( | ||
| 1175 | await NodeFSP.readFile(NodePath.join(extracted, "node_modules/same/index.js"), "utf8"), | ||
| 1176 | ).toBe("desktop dependency"); | ||
| 1177 | expect( | ||
| 1178 | await NodeFSP.readFile( | ||
| 1179 | NodePath.join(extracted, "server/node_modules/same/index.js"), | ||
| 1180 | "utf8", | ||
| 1181 | ), | ||
| 1182 | ).toBe("server dependency"); | ||
| 1183 | } | ||
| 1184 | }); | ||
| 1185 | yield* publishForkRelease(output, { | ||
| 1186 | feedUrl: `${origin}/read/`, | ||
| 1187 | uploadUrl: `${origin}/write/`, | ||
| 1188 | uploadApiKey: "fixture-key", | ||
| 1189 | }).pipe( | ||
| 1190 | Effect.scoped, | ||
| 1191 | Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), | ||
| 1192 | ); | ||
| 1193 | expect(stored.get("preadme.md")!.toString()).toContain( | ||
| 1194 | `href="The-Knife-${platform}-x64${extension}"`, | ||
| 1195 | ); | ||
| 1196 | expect(stored.has(`${target.app.version}${platform === "linux" ? "-linux" : ""}.yml`)).toBe( | ||
| 1197 | true, | ||
| 1198 | ); | ||
| 1199 | const publicManifest = parseUpdateManifest( | ||
| 1200 | stored.get(`${target.app.version}${platform === "linux" ? "-linux" : ""}.yml`)!.toString(), | ||
| 1201 | yaml, | ||
| 1202 | platform, | ||
| 1203 | ); | ||
| 1204 | expect(publicManifest.files.map((file) => file.url)).toEqual([ | ||
| 1205 | `The-Knife-${platform}-x64${extension}`, | ||
| 1206 | ]); | ||
| 1207 | expect(stored.has(installer)).toBe(false); | ||
| 1208 | expect(publicManifest.files.every((file) => stored.has(file.url))).toBe(true); | ||
| 1209 | }), | ||
| 1210 | ); | ||
scripts/release-fork.ts created+1215| ... | @@ -0,0 +1,1215 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Release validation reads local archives at the CLI boundary. | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | import * as NodeFS from "node:fs"; | ||
| 4 | import * as NodeFSP from "node:fs/promises"; | ||
| 5 | import * as NodeOS from "node:os"; | ||
| 6 | import * as NodePath from "node:path"; | ||
| 7 | import * as NodeURL from "node:url"; | ||
| 8 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | ||
| 9 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 10 | import * as Clock from "effect/Clock"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Data from "effect/Data"; | ||
| 13 | import * as Layer from "effect/Layer"; | ||
| 14 | import * as Schema from "effect/Schema"; | ||
| 15 | import * as Stream from "effect/Stream"; | ||
| 16 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; | ||
| 17 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ||
| 18 | import type { HttpMethod } from "effect/http/HttpMethod"; | ||
| 19 | import { extractAll } from "@electron/asar"; | ||
| 20 | |||
| 21 | import { | ||
| 22 | ForkReleaseManifest, | ||
| 23 | ForkReleaseListing, | ||
| 24 | ForkRuntimeManifest, | ||
| 25 | latestForkRelease, | ||
| 26 | } from "@t3tools/shared/forkUpdateDirectory"; | ||
| 27 | |||
| 28 | import { parseUpdateManifest, serializeUpdateManifest } from "./lib/update-manifest.ts"; | ||
| 29 | import { fingerprintForkServerRuntime } from "./lib/fork-runtime.ts"; | ||
| 30 | import { loadRepoEnv } from "./lib/public-config.ts"; | ||
| 31 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 32 | |||
| 33 | const ReleaseConfig = Schema.Struct({ | ||
| 34 | feedUrl: Schema.String.check(Schema.isPattern(/^https:\/\//)), | ||
| 35 | uploadUrl: Schema.String.check(Schema.isPattern(/^https:\/\//)), | ||
| 36 | signingIdentity: Schema.String.check(Schema.isPattern(/^[A-Fa-f0-9]{40}$/)), | ||
| 37 | signingKeychain: Schema.NonEmptyString, | ||
| 38 | notarizationKeychainProfile: Schema.optionalKey(Schema.NonEmptyString), | ||
| 39 | uploadApiKey: Schema.optionalKey(Schema.NonEmptyString), | ||
| 40 | }); | ||
| 41 | type ReleaseConfig = typeof ReleaseConfig.Type; | ||
| 42 | |||
| 43 | class ForkReleaseError extends Data.TaggedError("ForkReleaseError")<{ | ||
| 44 | readonly message: string; | ||
| 45 | }> {} | ||
| 46 | |||
| 47 | class ForkReleaseCollision extends Data.TaggedError("ForkReleaseCollision")<{ | ||
| 48 | readonly message: string; | ||
| 49 | }> {} | ||
| 50 | |||
| 51 | const repoRoot = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))); | ||
| 52 | |||
| 53 | const run = Effect.fn("fork.release.run")(function* ( | ||
| 54 | command: string, | ||
| 55 | args: string[], | ||
| 56 | options: { env?: NodeJS.ProcessEnv; capture?: boolean } = {}, | ||
| 57 | ) { | ||
| 58 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 59 | const child = yield* spawner.spawn( | ||
| 60 | ChildProcess.make(command, args, { | ||
| 61 | cwd: repoRoot, | ||
| 62 | env: options.env, | ||
| 63 | stdout: options.capture ? "pipe" : "inherit", | ||
| 64 | stderr: "inherit", | ||
| 65 | stdin: "ignore", | ||
| 66 | }), | ||
| 67 | ); | ||
| 68 | const [chunks, code] = yield* Effect.all([Stream.runCollect(child.stdout), child.exitCode], { | ||
| 69 | concurrency: "unbounded", | ||
| 70 | }); | ||
| 71 | if (Number(code) !== 0) | ||
| 72 | return yield* new ForkReleaseError({ message: `${command} exited with code ${code}.` }); | ||
| 73 | const decoder = new TextDecoder(); | ||
| 74 | return chunks.map((chunk) => decoder.decode(chunk, { stream: true })).join("") + decoder.decode(); | ||
| 75 | }); | ||
| 76 | |||
| 77 | const request = Effect.fn("fork.release.request")(function* ( | ||
| 78 | input: HttpClientRequest.HttpClientRequest, | ||
| 79 | ) { | ||
| 80 | const http = HttpClient.withScope(yield* HttpClient.HttpClient); | ||
| 81 | const response = yield* http.execute(input).pipe( | ||
| 82 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), | ||
| 83 | Effect.mapError(() => new ForkReleaseError({ message: "Could not reach the release share." })), | ||
| 84 | ); | ||
| 85 | if ( | ||
| 86 | (response.status < 200 || response.status >= 300) && | ||
| 87 | !(input.method === "DELETE" && response.status === 404) | ||
| 88 | ) { | ||
| 89 | return yield* new ForkReleaseError({ | ||
| 90 | message: `Release share returned HTTP ${response.status}.`, | ||
| 91 | }); | ||
| 92 | } | ||
| 93 | return response; | ||
| 94 | }); | ||
| 95 | |||
| 96 | const desktopTargets = { | ||
| 97 | darwin: { manifest: "latest-mac.yml", channel: "-mac", label: "macOS", extension: ".zip" }, | ||
| 98 | linux: { | ||
| 99 | manifest: "latest-linux.yml", | ||
| 100 | channel: "-linux", | ||
| 101 | label: "Linux", | ||
| 102 | extension: ".AppImage", | ||
| 103 | }, | ||
| 104 | win32: { manifest: "latest.yml", channel: "", label: "Windows", extension: ".exe" }, | ||
| 105 | }; | ||
| 106 | |||
| 107 | const installerSuffix = (platform: keyof typeof desktopTargets, arch: string) => | ||
| 108 | `-${platform === "linux" && arch === "x64" ? "x86_64" : arch}${desktopTargets[platform].extension}`; | ||
| 109 | |||
| 110 | const verifyDesktopRelease = ( | ||
| 111 | directory: string, | ||
| 112 | platform: keyof typeof desktopTargets = "darwin", | ||
| 113 | ) => | ||
| 114 | Effect.tryPromise({ | ||
| 115 | try: async () => { | ||
| 116 | const target = desktopTargets[platform]; | ||
| 117 | const raw = await NodeFSP.readFile(NodePath.join(directory, target.manifest), "utf8"); | ||
| 118 | const manifest = parseUpdateManifest(raw, target.manifest, target.label); | ||
| 119 | if (!/^0\.0\.\d+$/.test(manifest.version)) | ||
| 120 | throw new ForkReleaseError({ message: "Not a private fork release version." }); | ||
| 121 | if (!manifest.files.some((file) => file.url.endsWith(target.extension))) { | ||
| 122 | throw new ForkReleaseError({ message: `The release has no ${target.label} installer.` }); | ||
| 123 | } | ||
| 124 | const files: string[] = []; | ||
| 125 | for (const file of manifest.files) { | ||
| 126 | if ( | ||
| 127 | NodePath.basename(file.url) !== file.url || | ||
| 128 | (!file.url.startsWith(`The-Knife-${manifest.version}-`) && | ||
| 129 | !["arm64", "x64"].some( | ||
| 130 | (arch) => file.url === `The-Knife-${platform}-${arch}${target.extension}`, | ||
| 131 | )) | ||
| 132 | ) { | ||
| 133 | throw new ForkReleaseError({ | ||
| 134 | message: "The release manifest contains an unexpected artifact path.", | ||
| 135 | }); | ||
| 136 | } | ||
| 137 | const artifact = NodePath.join(directory, file.url); | ||
| 138 | const stat = await NodeFSP.stat(artifact); | ||
| 139 | const hash = NodeCrypto.createHash("sha512"); | ||
| 140 | for await (const chunk of NodeFS.createReadStream(artifact)) hash.update(chunk); | ||
| 141 | if (stat.size !== file.size || hash.digest("base64") !== file.sha512) { | ||
| 142 | throw new ForkReleaseError({ message: `Artifact verification failed: ${file.url}.` }); | ||
| 143 | } | ||
| 144 | files.push(file.url); | ||
| 145 | if ( | ||
| 146 | await NodeFSP.stat(`${artifact}.blockmap`).then( | ||
| 147 | () => true, | ||
| 148 | () => false, | ||
| 149 | ) | ||
| 150 | ) | ||
| 151 | files.push(`${file.url}.blockmap`); | ||
| 152 | } | ||
| 153 | return { manifest, raw, files }; | ||
| 154 | }, | ||
| 155 | catch: (cause) => | ||
| 156 | cause instanceof ForkReleaseError | ||
| 157 | ? cause | ||
| 158 | : new ForkReleaseError({ message: "Could not verify the release artifacts." }), | ||
| 159 | }); | ||
| 160 | |||
| 161 | const decodeRelease = Schema.decodeUnknownEffect(Schema.fromJsonString(ForkReleaseManifest)); | ||
| 162 | const encodeRelease = Schema.encodeEffect(Schema.fromJsonString(ForkReleaseManifest)); | ||
| 163 | const PublicationIntentJson = Schema.fromJsonString( | ||
| 164 | Schema.Struct({ | ||
| 165 | release: ForkReleaseManifest, | ||
| 166 | retiredFiles: Schema.Array(Schema.String), | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | const decodePublicationIntent = Schema.decodeUnknownEffect(PublicationIntentJson); | ||
| 170 | const encodePublicationIntent = Schema.encodeEffect(PublicationIntentJson); | ||
| 171 | const decodeListing = Schema.decodeUnknownEffect(ForkReleaseListing); | ||
| 172 | |||
| 173 | const completedVersions = (listing: typeof ForkReleaseListing.Type) => | ||
| 174 | listing.files | ||
| 175 | .flatMap((file) => { | ||
| 176 | const version = latestForkRelease({ files: [file] }); | ||
| 177 | return version ? [version] : []; | ||
| 178 | }) | ||
| 179 | .sort((a, b) => (BigInt(a.slice(4)) > BigInt(b.slice(4)) ? -1 : 1)); | ||
| 180 | |||
| 181 | const readReleaseListing = (feed: URL) => | ||
| 182 | Effect.gen(function* () { | ||
| 183 | const response = yield* request(HttpClientRequest.get(new URL("?ls", feed).href)); | ||
| 184 | return yield* decodeListing(yield* response.json); | ||
| 185 | }); | ||
| 186 | |||
| 187 | const readPublishedRelease = (feed: URL, version: string) => | ||
| 188 | Effect.gen(function* () { | ||
| 189 | const manifest = yield* request(HttpClientRequest.get(new URL(`${version}.json`, feed).href)); | ||
| 190 | const release = yield* decodeRelease(yield* manifest.text); | ||
| 191 | if (release.version !== version) | ||
| 192 | return yield* new ForkReleaseError({ | ||
| 193 | message: "The published manifest has the wrong version.", | ||
| 194 | }); | ||
| 195 | return release; | ||
| 196 | }); | ||
| 197 | |||
| 198 | const previousRelease = (feed: URL) => | ||
| 199 | Effect.scoped( | ||
| 200 | Effect.gen(function* () { | ||
| 201 | const version = latestForkRelease(yield* readReleaseListing(feed)); | ||
| 202 | return version ? yield* readPublishedRelease(feed, version) : undefined; | ||
| 203 | }), | ||
| 204 | ); | ||
| 205 | |||
| 206 | const artifact = (directory: string, file: string) => | ||
| 207 | Effect.tryPromise(async () => { | ||
| 208 | const source = NodePath.join(directory, file); | ||
| 209 | const hash = NodeCrypto.createHash("sha256"); | ||
| 210 | for await (const chunk of NodeFS.createReadStream(source)) hash.update(chunk); | ||
| 211 | return { file, sha256: hash.digest("hex"), size: (await NodeFSP.stat(source)).size }; | ||
| 212 | }); | ||
| 213 | |||
| 214 | const publicReadme = (release: typeof ForkReleaseManifest.Type, serverAvailable: boolean) => { | ||
| 215 | const instructions = new Set<string>(); | ||
| 216 | const downloads = Object.keys(release.desktop).flatMap((key) => { | ||
| 217 | const [platform, arch] = key.split("-"); | ||
| 218 | if (platform !== "darwin" && platform !== "linux" && platform !== "win32") return []; | ||
| 219 | const architecture = | ||
| 220 | arch === "arm64" | ||
| 221 | ? platform === "darwin" | ||
| 222 | ? "Apple silicon" | ||
| 223 | : "ARM64" | ||
| 224 | : platform === "darwin" | ||
| 225 | ? "Intel" | ||
| 226 | : "x64"; | ||
| 227 | const install = | ||
| 228 | platform === "darwin" | ||
| 229 | ? "Unzip, then move The Knife to Applications. If macOS blocks the first launch, see [Apple’s opening instructions](https://support.apple.com/102445)." | ||
| 230 | : platform === "linux" | ||
| 231 | ? "Make the AppImage executable, then open it." | ||
| 232 | : "Run the installer."; | ||
| 233 | instructions.add(`**${desktopTargets[platform].label}:** ${install}`); | ||
| 234 | return [ | ||
| 235 | `<a href="The-Knife-${key}${desktopTargets[platform].extension}">${desktopTargets[platform].label} ${architecture}</a>`, | ||
| 236 | ]; | ||
| 237 | }); | ||
| 238 | return [ | ||
| 239 | "## The Knife", | ||
| 240 | "", | ||
| 241 | "A focused workspace for coding agents, based on [T3 Code](https://t3.codes). Bring your own agent subscriptions and keep your work on your machines.", | ||
| 242 | "", | ||
| 243 | ...(downloads.length > 0 | ||
| 244 | ? [ | ||
| 245 | `<p align="center">${downloads.join(" &nbsp; · &nbsp; ")}</p>`, | ||
| 246 | "", | ||
| 247 | [...instructions].join(" "), | ||
| 248 | "", | ||
| 249 | ] | ||
| 250 | : []), | ||
| 251 | "Updates download automatically. The app asks before restarting; UI updates apply when your work can be preserved.", | ||
| 252 | "", | ||
| 253 | ...(serverAvailable | ||
| 254 | ? [ | ||
| 255 | "**Server:** [Docker bundle](knife-server-linux-amd64.tar.gz) · [SHA-256](knife-server-linux-amd64.tar.gz.sha256). Includes the image and configuration for a Linux x64 host.", | ||
| 256 | "", | ||
| 257 | ] | ||
| 258 | : []), | ||
| 259 | ].join("\n"); | ||
| 260 | }; | ||
| 261 | |||
| 262 | const releaseArtifacts = (release: typeof ForkReleaseManifest.Type) => [ | ||
| 263 | release.ui, | ||
| 264 | release.assets, | ||
| 265 | release.server, | ||
| 266 | ...Object.values(release.desktop).flatMap((desktop) => [ | ||
| 267 | desktop.ui, | ||
| 268 | desktop.assets, | ||
| 269 | desktop.code, | ||
| 270 | desktop.app, | ||
| 271 | ]), | ||
| 272 | ]; | ||
| 273 | |||
| 274 | const releaseFiles = (release: typeof ForkReleaseManifest.Type) => { | ||
| 275 | const files = [ | ||
| 276 | ...releaseArtifacts(release).map((artifact) => artifact.file), | ||
| 277 | `${release.version}.json`, | ||
| 278 | ]; | ||
| 279 | for (const [key, desktop] of Object.entries(release.desktop)) { | ||
| 280 | files.push(`${desktop.app.file}.blockmap`); | ||
| 281 | const platform = key.slice(0, key.lastIndexOf("-")); | ||
| 282 | if (platform === "darwin" || platform === "linux" || platform === "win32") | ||
| 283 | files.push(`${desktop.app.version}${desktopTargets[platform].channel}.yml`); | ||
| 284 | } | ||
| 285 | return files; | ||
| 286 | }; | ||
| 287 | |||
| 288 | const maintainPublicFolder = Effect.fn("fork.release.maintainPublicFolder")(function* ( | ||
| 289 | release: typeof ForkReleaseManifest.Type, | ||
| 290 | feedBase: URL, | ||
| 291 | uploadBase: URL, | ||
| 292 | apiKey: string | undefined, | ||
| 293 | completed: ReadonlyArray<string>, | ||
| 294 | retiredFiles: ReadonlySet<string>, | ||
| 295 | ) { | ||
| 296 | const authenticated = (input: HttpClientRequest.HttpClientRequest) => | ||
| 297 | input.pipe(HttpClientRequest.setHeaders(apiKey ? { PW: apiKey } : {})); | ||
| 298 | const http = HttpClient.withScope(yield* HttpClient.HttpClient); | ||
| 299 | const initialVersions = completedVersions(yield* readReleaseListing(feedBase)); | ||
| 300 | if (initialVersions.join("\n") !== completed.join("\n")) return false; | ||
| 301 | for (const key of Object.keys(release.desktop)) { | ||
| 302 | const oldPage = `The-Knife-${key}.html`; | ||
| 303 | const page = yield* http.head(new URL(oldPage, feedBase).href); | ||
| 304 | if (page.status !== 200 && page.status !== 404) | ||
| 305 | return yield* new ForkReleaseError({ | ||
| 306 | message: `Release share returned HTTP ${page.status}.`, | ||
| 307 | }); | ||
| 308 | if (page.status === 200) { | ||
| 309 | const removed = yield* request( | ||
| 310 | authenticated(HttpClientRequest.make("DELETE")(new URL(oldPage, uploadBase).href)), | ||
| 311 | ); | ||
| 312 | yield* removed.arrayBuffer; | ||
| 313 | } | ||
| 314 | } | ||
| 315 | |||
| 316 | const server = yield* http.head(new URL("knife-server-linux-amd64.tar.gz", feedBase).href); | ||
| 317 | if (server.status !== 200 && server.status !== 404) | ||
| 318 | return yield* new ForkReleaseError({ | ||
| 319 | message: `Release share returned HTTP ${server.status}.`, | ||
| 320 | }); | ||
| 321 | const readme = publicReadme(release, server.status === 200); | ||
| 322 | const existing = yield* http.get(new URL("preadme.md", feedBase).href); | ||
| 323 | if (existing.status !== 200 || (yield* existing.text) !== readme) { | ||
| 324 | if (existing.status !== 200 && existing.status !== 404) | ||
| 325 | return yield* new ForkReleaseError({ | ||
| 326 | message: `Release share returned HTTP ${existing.status}.`, | ||
| 327 | }); | ||
| 328 | const uploaded = yield* request( | ||
| 329 | authenticated( | ||
| 330 | HttpClientRequest.put(new URL("preadme.md", uploadBase).href).pipe( | ||
| 331 | HttpClientRequest.setHeader("Replace", "1"), | ||
| 332 | HttpClientRequest.bodyText(readme, "text/markdown; charset=utf-8"), | ||
| 333 | ), | ||
| 334 | ), | ||
| 335 | ); | ||
| 336 | yield* uploaded.arrayBuffer; | ||
| 337 | const readable = yield* request(HttpClientRequest.get(new URL("preadme.md", feedBase).href)); | ||
| 338 | if ((yield* readable.text) !== readme) | ||
| 339 | return yield* new ForkReleaseError({ | ||
| 340 | message: "The download page could not be updated. Retry publication.", | ||
| 341 | }); | ||
| 342 | } | ||
| 343 | const listing = yield* readReleaseListing(feedBase); | ||
| 344 | let versions = completedVersions(listing); | ||
| 345 | if (versions.join("\n") !== completed.join("\n")) return false; | ||
| 346 | |||
| 347 | const retained = new Set(releaseFiles(release)); | ||
| 348 | for (const version of versions.slice(0, 2)) { | ||
| 349 | for (const file of releaseFiles(yield* readPublishedRelease(feedBase, version))) | ||
| 350 | retained.add(file); | ||
| 351 | } | ||
| 352 | const obsolete = new Set([...retiredFiles].filter((file) => !retained.has(file))); | ||
| 353 | for (const version of versions.slice(2)) { | ||
| 354 | for (const file of releaseFiles(yield* readPublishedRelease(feedBase, version))) { | ||
| 355 | if ( | ||
| 356 | !retained.has(file) && | ||
| 357 | (/^knife-(?:ui|assets|server|code)-[a-f0-9]{64}\.tar\.gz$/.test(file) || | ||
| 358 | /^The-Knife-0\.0\.\d+-[\w.-]+\.(?:zip|exe|AppImage)(?:\.blockmap)?$/.test(file) || | ||
| 359 | /^0\.0\.\d+(?:-mac|-linux)?\.(?:json|yml)$/.test(file)) | ||
| 360 | ) | ||
| 361 | obsolete.add(file); | ||
| 362 | } | ||
| 363 | } | ||
| 364 | const visible = new Set(listing.files.map((file) => file.href)); | ||
| 365 | const deletions = [...obsolete].sort( | ||
| 366 | (a, b) => Number(a.endsWith(".json")) - Number(b.endsWith(".json")), | ||
| 367 | ); | ||
| 368 | for (const file of deletions) { | ||
| 369 | if (!visible.has(file)) continue; | ||
| 370 | const currentVersions = completedVersions(yield* readReleaseListing(feedBase)); | ||
| 371 | if (currentVersions.join("\n") !== versions.join("\n")) return false; | ||
| 372 | const deleted = yield* request( | ||
| 373 | authenticated( | ||
| 374 | HttpClientRequest.make("DELETE")(new URL(encodeURIComponent(file), uploadBase).href), | ||
| 375 | ), | ||
| 376 | ); | ||
| 377 | yield* deleted.arrayBuffer; | ||
| 378 | if (file.endsWith(".json")) versions = versions.filter((version) => `${version}.json` !== file); | ||
| 379 | } | ||
| 380 | return true; | ||
| 381 | }); | ||
| 382 | |||
| 383 | export const publishForkRelease = Effect.fn("fork.release.publish")(function* ( | ||
| 384 | directory: string, | ||
| 385 | config: Pick<ReleaseConfig, "feedUrl" | "uploadUrl" | "uploadApiKey">, | ||
| 386 | ) { | ||
| 387 | const raw = yield* Effect.tryPromise(() => | ||
| 388 | NodeFSP.readFile(NodePath.join(directory, "release.json"), "utf8"), | ||
| 389 | ); | ||
| 390 | const built = yield* decodeRelease(raw); | ||
| 391 | for (const expected of releaseArtifacts(built)) { | ||
| 392 | const actual = yield* artifact(directory, expected.file).pipe( | ||
| 393 | Effect.mapError( | ||
| 394 | () => | ||
| 395 | new ForkReleaseError({ | ||
| 396 | message: `Could not verify the local artifact: ${expected.file}.`, | ||
| 397 | }), | ||
| 398 | ), | ||
| 399 | ); | ||
| 400 | if (actual.sha256 !== expected.sha256 || actual.size !== expected.size) | ||
| 401 | return yield* new ForkReleaseError({ | ||
| 402 | message: `Artifact verification failed: ${expected.file}.`, | ||
| 403 | }); | ||
| 404 | } | ||
| 405 | const desktopReleases = new Map< | ||
| 406 | keyof typeof desktopTargets, | ||
| 407 | { | ||
| 408 | manifest: ReturnType<typeof parseUpdateManifest>; | ||
| 409 | raw: string; | ||
| 410 | files: string[]; | ||
| 411 | } | ||
| 412 | >(); | ||
| 413 | for (const [key, desktop] of Object.entries(built.desktop)) { | ||
| 414 | const platform = key.slice(0, key.lastIndexOf("-")); | ||
| 415 | if (platform !== "darwin" && platform !== "linux" && platform !== "win32") | ||
| 416 | return yield* new ForkReleaseError({ message: `Unsupported desktop platform: ${key}.` }); | ||
| 417 | let desktopRelease = desktopReleases.get(platform); | ||
| 418 | if (!desktopRelease) { | ||
| 419 | desktopRelease = yield* verifyDesktopRelease(directory, platform); | ||
| 420 | desktopReleases.set(platform, desktopRelease); | ||
| 421 | } | ||
| 422 | if ( | ||
| 423 | desktopRelease.manifest.version !== desktop.version || | ||
| 424 | desktop.app.version !== desktop.version || | ||
| 425 | !desktopRelease.files.includes(desktop.app.file) | ||
| 426 | ) | ||
| 427 | return yield* new ForkReleaseError({ | ||
| 428 | message: "The local app does not match its updater manifest.", | ||
| 429 | }); | ||
| 430 | } | ||
| 431 | |||
| 432 | const intentPath = NodePath.join(directory, "publication.json"); | ||
| 433 | const intent = NodeFS.existsSync(intentPath) | ||
| 434 | ? yield* Effect.tryPromise(() => NodeFSP.readFile(intentPath, "utf8")).pipe( | ||
| 435 | Effect.flatMap(decodePublicationIntent), | ||
| 436 | ) | ||
| 437 | : undefined; | ||
| 438 | if (intent !== undefined && intent.release.version !== built.version) | ||
| 439 | return yield* new ForkReleaseError({ | ||
| 440 | message: "The publication intent belongs to a different release.", | ||
| 441 | }); | ||
| 442 | const uploadBase = new URL( | ||
| 443 | config.uploadUrl.endsWith("/") ? config.uploadUrl : `${config.uploadUrl}/`, | ||
| 444 | ); | ||
| 445 | const feedBase = new URL(config.feedUrl.endsWith("/") ? config.feedUrl : `${config.feedUrl}/`); | ||
| 446 | const previous = yield* previousRelease(feedBase); | ||
| 447 | if (previous && BigInt(previous.version.slice(4)) > BigInt(built.version.slice(4))) | ||
| 448 | return yield* new ForkReleaseError({ | ||
| 449 | message: "This release or a newer version is already published. Build a new version.", | ||
| 450 | }); | ||
| 451 | for (const [platform, desktop] of Object.entries(built.desktop)) { | ||
| 452 | const prior = previous?.desktop[platform]; | ||
| 453 | if (prior && BigInt(prior.version.slice(4)) > BigInt(desktop.version.slice(4))) | ||
| 454 | return yield* new ForkReleaseError({ | ||
| 455 | message: `A newer native build is already published for ${platform}.`, | ||
| 456 | }); | ||
| 457 | } | ||
| 458 | |||
| 459 | const serverRelease = | ||
| 460 | previous && BigInt(previous.server.version.slice(4)) > BigInt(built.server.version.slice(4)) | ||
| 461 | ? previous | ||
| 462 | : built; | ||
| 463 | const release = intent?.release ?? { | ||
| 464 | ...built, | ||
| 465 | ui: serverRelease.ui, | ||
| 466 | assets: serverRelease.assets, | ||
| 467 | server: serverRelease.server, | ||
| 468 | desktop: { | ||
| 469 | ...previous?.desktop, | ||
| 470 | ...Object.fromEntries( | ||
| 471 | Object.entries(built.desktop).map(([platform, desktop]) => { | ||
| 472 | const prior = previous?.desktop[platform]; | ||
| 473 | const nativePlatform = platform.slice(0, platform.lastIndexOf("-")); | ||
| 474 | if ( | ||
| 475 | nativePlatform !== "darwin" && | ||
| 476 | nativePlatform !== "linux" && | ||
| 477 | nativePlatform !== "win32" | ||
| 478 | ) | ||
| 479 | throw new ForkReleaseError({ message: `Unsupported desktop platform: ${platform}.` }); | ||
| 480 | const file = `The-Knife-${platform}${desktopTargets[nativePlatform].extension}`; | ||
| 481 | return [ | ||
| 482 | platform, | ||
| 483 | { | ||
| 484 | ...desktop, | ||
| 485 | code: prior?.runtime === desktop.runtime ? prior.code : desktop.code, | ||
| 486 | app: { | ||
| 487 | ...(prior?.shell === desktop.shell && prior.app.file === file | ||
| 488 | ? prior.app | ||
| 489 | : desktop.app), | ||
| 490 | file, | ||
| 491 | }, | ||
| 492 | }, | ||
| 493 | ]; | ||
| 494 | }), | ||
| 495 | ), | ||
| 496 | }, | ||
| 497 | }; | ||
| 498 | const encoded = yield* encodeRelease(release); | ||
| 499 | if (previous?.version === release.version) { | ||
| 500 | if ((yield* encodeRelease(previous)) !== encoded) | ||
| 501 | return yield* new ForkReleaseError({ | ||
| 502 | message: `Published manifest has different contents: ${release.version}.json.`, | ||
| 503 | }); | ||
| 504 | } | ||
| 505 | const sidecars = []; | ||
| 506 | const sources = new Map<string, string>(); | ||
| 507 | for (const [key, desktop] of Object.entries(built.desktop)) { | ||
| 508 | const target = release.desktop[key]!; | ||
| 509 | if (target.app.sha256 !== desktop.app.sha256) continue; | ||
| 510 | sources.set(target.app.file, desktop.app.file); | ||
| 511 | const file = `${desktop.app.file}.blockmap`; | ||
| 512 | if ( | ||
| 513 | yield* Effect.tryPromise(() => NodeFSP.stat(NodePath.join(directory, file))).pipe( | ||
| 514 | Effect.map((stat) => stat.isFile()), | ||
| 515 | Effect.orElseSucceed(() => false), | ||
| 516 | ) | ||
| 517 | ) { | ||
| 518 | const sidecar = yield* artifact(directory, file); | ||
| 519 | const name = `${target.app.file}.blockmap`; | ||
| 520 | sidecars.push({ ...sidecar, file: name }); | ||
| 521 | sources.set(name, file); | ||
| 522 | } | ||
| 523 | } | ||
| 524 | const files = new Map( | ||
| 525 | [...releaseArtifacts(release), ...sidecars].map((entry) => [entry.file, entry]), | ||
| 526 | ); | ||
| 527 | const mutableFiles = new Set([ | ||
| 528 | ...Object.values(release.desktop).map((desktop) => desktop.app.file), | ||
| 529 | ...sidecars.map((sidecar) => sidecar.file), | ||
| 530 | ]); | ||
| 531 | const retiredFiles = new Set(intent?.retiredFiles); | ||
| 532 | |||
| 533 | const upload = (name: string, body: HttpClientRequest.HttpClientRequest) => | ||
| 534 | request( | ||
| 535 | body.pipe( | ||
| 536 | HttpClientRequest.setUrl(new URL(encodeURIComponent(name), uploadBase).href), | ||
| 537 | HttpClientRequest.setHeaders(config.uploadApiKey ? { PW: config.uploadApiKey } : {}), | ||
| 538 | ), | ||
| 539 | ).pipe( | ||
| 540 | Effect.flatMap((response) => response.arrayBuffer), | ||
| 541 | Effect.asVoid, | ||
| 542 | ); | ||
| 543 | |||
| 544 | const http = HttpClient.withScope(yield* HttpClient.HttpClient); | ||
| 545 | const publishManifest = (name: string, contents: string, contentType: string) => | ||
| 546 | Effect.gen(function* () { | ||
| 547 | const existing = yield* http.get(new URL(name, feedBase).href); | ||
| 548 | if (existing.status === 200) { | ||
| 549 | if ((yield* existing.text) === contents) return; | ||
| 550 | return yield* new ForkReleaseCollision({ | ||
| 551 | message: `Published manifest has different contents: ${name}.`, | ||
| 552 | }); | ||
| 553 | } | ||
| 554 | if (existing.status !== 404) | ||
| 555 | return yield* new ForkReleaseError({ | ||
| 556 | message: `Release share returned HTTP ${existing.status}.`, | ||
| 557 | }); | ||
| 558 | yield* upload( | ||
| 559 | name, | ||
| 560 | HttpClientRequest.put("").pipe(HttpClientRequest.bodyText(contents, contentType)), | ||
| 561 | ); | ||
| 562 | const readable = yield* request(HttpClientRequest.get(new URL(name, feedBase).href)); | ||
| 563 | if ((yield* readable.text) !== contents) | ||
| 564 | return yield* new ForkReleaseCollision({ | ||
| 565 | message: `Published manifest has different contents: ${name}.`, | ||
| 566 | }); | ||
| 567 | }); | ||
| 568 | const matchesDownload = Effect.fn("fork.release.matchesDownload")(function* ( | ||
| 569 | name: string, | ||
| 570 | expected: { readonly size: number; readonly sha256: string }, | ||
| 571 | ) { | ||
| 572 | const response = yield* http.get(new URL(name, feedBase).href); | ||
| 573 | if (response.status === 404) return false; | ||
| 574 | if (response.status !== 200) | ||
| 575 | return yield* new ForkReleaseError({ | ||
| 576 | message: `Release share returned HTTP ${response.status}.`, | ||
| 577 | }); | ||
| 578 | const hash = NodeCrypto.createHash("sha256"); | ||
| 579 | let size = 0; | ||
| 580 | yield* Stream.runForEach(response.stream, (bytes) => | ||
| 581 | Effect.sync(() => { | ||
| 582 | hash.update(bytes); | ||
| 583 | size += bytes.length; | ||
| 584 | }), | ||
| 585 | ); | ||
| 586 | return size === expected.size && hash.digest("hex") === expected.sha256; | ||
| 587 | }); | ||
| 588 | const remove = (name: string) => upload(name, HttpClientRequest.make("DELETE")("")); | ||
| 589 | const retireConflictingReleases = Effect.fn("fork.release.retireConflictingReleases")( | ||
| 590 | function* () { | ||
| 591 | const listing = yield* readReleaseListing(feedBase); | ||
| 592 | const visible = new Set(listing.files.map((file) => file.href)); | ||
| 593 | const completed = []; | ||
| 594 | for (const version of completedVersions(listing)) | ||
| 595 | completed.push(yield* readPublishedRelease(feedBase, version)); | ||
| 596 | if ( | ||
| 597 | completed.some( | ||
| 598 | (completed) => BigInt(completed.version.slice(4)) > BigInt(release.version.slice(4)), | ||
| 599 | ) | ||
| 600 | ) | ||
| 601 | return yield* new ForkReleaseError({ | ||
| 602 | message: | ||
| 603 | "A newer release completed during installer publication. Retry with a new version.", | ||
| 604 | }); | ||
| 605 | const retained = completed.filter((completed) => | ||
| 606 | Object.entries(completed.desktop).every(([key, target]) => { | ||
| 607 | const current = release.desktop[key]; | ||
| 608 | return ( | ||
| 609 | current === undefined || | ||
| 610 | (current.app.file === target.app.file && | ||
| 611 | current.app.sha256 === target.app.sha256 && | ||
| 612 | current.app.size === target.app.size) | ||
| 613 | ); | ||
| 614 | }), | ||
| 615 | ); | ||
| 616 | const protectedFiles = new Set([...releaseFiles(release), ...retained.flatMap(releaseFiles)]); | ||
| 617 | const conflicting = completed.filter((completed) => !retained.includes(completed)); | ||
| 618 | if (conflicting.length > 0) { | ||
| 619 | const retired = new Set([...retiredFiles, ...conflicting.flatMap(releaseFiles)]); | ||
| 620 | const encodedIntent = yield* encodePublicationIntent({ | ||
| 621 | release, | ||
| 622 | retiredFiles: [...retired], | ||
| 623 | }); | ||
| 624 | yield* Effect.tryPromise(async () => { | ||
| 625 | await NodeFSP.writeFile(`${intentPath}.tmp`, encodedIntent + "\n"); | ||
| 626 | await NodeFSP.rename(`${intentPath}.tmp`, intentPath); | ||
| 627 | }); | ||
| 628 | for (const file of retired) retiredFiles.add(file); | ||
| 629 | } | ||
| 630 | for (const completedRelease of conflicting) yield* remove(`${completedRelease.version}.json`); | ||
| 631 | for (const file of retiredFiles) { | ||
| 632 | if (file.endsWith(".yml") && !protectedFiles.has(file) && visible.has(file)) | ||
| 633 | yield* remove(file); | ||
| 634 | } | ||
| 635 | }, | ||
| 636 | ); | ||
| 637 | const publicationOrder = [...files.values()].toSorted( | ||
| 638 | (left, right) => Number(mutableFiles.has(left.file)) - Number(mutableFiles.has(right.file)), | ||
| 639 | ); | ||
| 640 | for (const expected of publicationOrder) { | ||
| 641 | const mutable = mutableFiles.has(expected.file); | ||
| 642 | if (mutable) { | ||
| 643 | if (yield* matchesDownload(expected.file, expected)) continue; | ||
| 644 | const source = sources.get(expected.file); | ||
| 645 | if (source === undefined) | ||
| 646 | return yield* new ForkReleaseError({ | ||
| 647 | message: `The installer is unavailable: ${expected.file}.`, | ||
| 648 | }); | ||
| 649 | const actual = yield* artifact(directory, source); | ||
| 650 | if (actual.sha256 !== expected.sha256 || actual.size !== expected.size) | ||
| 651 | return yield* new ForkReleaseError({ message: `Artifact verification failed: ${source}.` }); | ||
| 652 | const temporary = `${expected.file}.${NodeCrypto.randomUUID()}.PARTIAL`; | ||
| 653 | yield* Effect.gen(function* () { | ||
| 654 | const put = yield* HttpClientRequest.put("").pipe( | ||
| 655 | HttpClientRequest.bodyFile(NodePath.join(directory, source)), | ||
| 656 | ); | ||
| 657 | yield* upload(temporary, put); | ||
| 658 | if (!(yield* matchesDownload(temporary, expected))) | ||
| 659 | return yield* new ForkReleaseError({ | ||
| 660 | message: `The uploaded installer did not match its checksum: ${expected.file}.`, | ||
| 661 | }); | ||
| 662 | yield* retireConflictingReleases(); | ||
| 663 | // Effect's method union omits the WebDAV MOVE supported by the share. | ||
| 664 | yield* upload( | ||
| 665 | temporary, | ||
| 666 | HttpClientRequest.make("MOVE" as HttpMethod)("").pipe( | ||
| 667 | HttpClientRequest.setHeaders({ | ||
| 668 | Destination: new URL(expected.file, uploadBase).href, | ||
| 669 | Overwrite: "T", | ||
| 670 | }), | ||
| 671 | ), | ||
| 672 | ); | ||
| 673 | if (!(yield* matchesDownload(expected.file, expected))) | ||
| 674 | return yield* new ForkReleaseError({ | ||
| 675 | message: `The promoted installer did not match its checksum: ${expected.file}.`, | ||
| 676 | }); | ||
| 677 | }).pipe(Effect.ensuring(remove(temporary).pipe(Effect.ignore))); | ||
| 678 | continue; | ||
| 679 | } | ||
| 680 | const visible = yield* http.head(new URL(expected.file, feedBase).href); | ||
| 681 | if (visible.status === 200 && Number(visible.headers["content-length"]) === expected.size) | ||
| 682 | continue; | ||
| 683 | if (visible.status !== 404) | ||
| 684 | return yield* new ForkReleaseError({ | ||
| 685 | message: `Published artifact does not match: ${expected.file}.`, | ||
| 686 | }); | ||
| 687 | const put = yield* HttpClientRequest.put("").pipe( | ||
| 688 | HttpClientRequest.bodyFile(NodePath.join(directory, expected.file)), | ||
| 689 | ); | ||
| 690 | yield* upload(expected.file, put); | ||
| 691 | const readable = yield* request(HttpClientRequest.head(new URL(expected.file, feedBase).href)); | ||
| 692 | if (Number(readable.headers["content-length"]) !== expected.size) | ||
| 693 | return yield* new ForkReleaseError({ | ||
| 694 | message: `Published artifact is not readable: ${expected.file}.`, | ||
| 695 | }); | ||
| 696 | yield* Effect.log(`Published ${expected.file} (${expected.size} bytes)`); | ||
| 697 | } | ||
| 698 | if (previous?.version !== release.version) { | ||
| 699 | yield* retireConflictingReleases(); | ||
| 700 | for (const [platform, desktopRelease] of desktopReleases) { | ||
| 701 | const [key, desktop] = Object.entries(built.desktop).find(([key]) => | ||
| 702 | key.startsWith(`${platform}-`), | ||
| 703 | )!; | ||
| 704 | const target = release.desktop[key]!; | ||
| 705 | if (target.app.version !== desktopRelease.manifest.version) continue; | ||
| 706 | const name = `${desktopRelease.manifest.version}${desktopTargets[platform].channel}.yml`; | ||
| 707 | const manifest = { | ||
| 708 | ...desktopRelease.manifest, | ||
| 709 | files: desktopRelease.manifest.files | ||
| 710 | .filter((file) => file.url === desktop.app.file) | ||
| 711 | .map((file) => ({ ...file, url: target.app.file })), | ||
| 712 | }; | ||
| 713 | yield* publishManifest( | ||
| 714 | name, | ||
| 715 | serializeUpdateManifest(manifest, { | ||
| 716 | platformLabel: desktopTargets[platform].label, | ||
| 717 | }), | ||
| 718 | "text/yaml", | ||
| 719 | ); | ||
| 720 | } | ||
| 721 | yield* publishManifest(`${release.version}.json`, encoded + "\n", "application/json"); | ||
| 722 | } | ||
| 723 | |||
| 724 | for (let attempt = 0; attempt < 8; attempt++) { | ||
| 725 | const versions = completedVersions(yield* readReleaseListing(feedBase)); | ||
| 726 | const newest = versions[0]; | ||
| 727 | if (!newest || BigInt(newest.slice(4)) < BigInt(release.version.slice(4))) | ||
| 728 | return yield* new ForkReleaseError({ | ||
| 729 | message: "The release is missing from the feed. Retry publication.", | ||
| 730 | }); | ||
| 731 | const latest = yield* readPublishedRelease(feedBase, newest); | ||
| 732 | let serverRelease = latest; | ||
| 733 | const desktop = { ...latest.desktop }; | ||
| 734 | for (const version of versions.slice(1)) { | ||
| 735 | const completed = yield* readPublishedRelease(feedBase, version); | ||
| 736 | if (BigInt(completed.server.version.slice(4)) > BigInt(serverRelease.server.version.slice(4))) | ||
| 737 | serverRelease = completed; | ||
| 738 | for (const [platform, target] of Object.entries(completed.desktop)) { | ||
| 739 | const current = desktop[platform]; | ||
| 740 | if (!current || BigInt(target.version.slice(4)) > BigInt(current.version.slice(4))) | ||
| 741 | desktop[platform] = target; | ||
| 742 | } | ||
| 743 | } | ||
| 744 | const merged = { | ||
| 745 | ...latest, | ||
| 746 | ui: serverRelease.ui, | ||
| 747 | assets: serverRelease.assets, | ||
| 748 | server: serverRelease.server, | ||
| 749 | desktop, | ||
| 750 | }; | ||
| 751 | if ((yield* encodeRelease(merged)) === (yield* encodeRelease(latest))) { | ||
| 752 | const currentVersions = completedVersions(yield* readReleaseListing(feedBase)); | ||
| 753 | if (currentVersions.join("\n") !== versions.join("\n")) continue; | ||
| 754 | const maintained = yield* maintainPublicFolder( | ||
| 755 | latest, | ||
| 756 | feedBase, | ||
| 757 | uploadBase, | ||
| 758 | config.uploadApiKey, | ||
| 759 | versions, | ||
| 760 | retiredFiles, | ||
| 761 | ); | ||
| 762 | if (!maintained) continue; | ||
| 763 | if (NodeFS.existsSync(intentPath)) { | ||
| 764 | const completed = yield* readPublishedRelease(feedBase, release.version); | ||
| 765 | if ((yield* encodeRelease(completed)) !== encoded) | ||
| 766 | return yield* new ForkReleaseError({ | ||
| 767 | message: "The completed release differs from the publication intent.", | ||
| 768 | }); | ||
| 769 | yield* Effect.tryPromise(() => NodeFSP.rm(intentPath)); | ||
| 770 | } | ||
| 771 | yield* Effect.log(`Fork release ${latest.version} is available to all machines.`); | ||
| 772 | return; | ||
| 773 | } | ||
| 774 | const timestamp = BigInt(Math.floor((yield* Clock.currentTimeMillis) / 1000)); | ||
| 775 | const next = BigInt(newest.slice(4)) + 1n; | ||
| 776 | merged.version = `0.0.${timestamp > next ? timestamp : next}`; | ||
| 777 | yield* publishManifest( | ||
| 778 | `${merged.version}.json`, | ||
| 779 | (yield* encodeRelease(merged)) + "\n", | ||
| 780 | "application/json", | ||
| 781 | ).pipe(Effect.catchTags({ ForkReleaseCollision: () => Effect.void })); | ||
| 782 | } | ||
| 783 | return yield* new ForkReleaseError({ | ||
| 784 | message: "Concurrent releases are still completing. Retry publication.", | ||
| 785 | }); | ||
| 786 | }); | ||
| 787 | |||
| 788 | const archiveComponent = Effect.fn("fork.release.archive")(function* ( | ||
| 789 | directory: string, | ||
| 790 | source: string, | ||
| 791 | name: string, | ||
| 792 | ) { | ||
| 793 | const entries = yield* Effect.tryPromise(async () => { | ||
| 794 | const files: string[] = []; | ||
| 795 | const visit = async (relative: string): Promise<void> => { | ||
| 796 | const file = NodePath.join(source, relative); | ||
| 797 | const stat = await NodeFSP.lstat(file); | ||
| 798 | if (stat.isDirectory()) { | ||
| 799 | for (const child of (await NodeFSP.readdir(file)).sort()) | ||
| 800 | await visit(NodePath.join(relative, child)); | ||
| 801 | } | ||
| 802 | if (!stat.isSymbolicLink()) await NodeFSP.utimes(file, 0, 0); | ||
| 803 | files.push(relative); | ||
| 804 | }; | ||
| 805 | await visit("."); | ||
| 806 | return files; | ||
| 807 | }); | ||
| 808 | const list = NodePath.join(directory, `${name}.files`); | ||
| 809 | yield* Effect.tryPromise(() => NodeFSP.writeFile(list, entries.join("\n") + "\n")); | ||
| 810 | const temporary = `${name}.tar.gz`; | ||
| 811 | const platform = yield* HostProcess.Platform; | ||
| 812 | const owner = | ||
| 813 | platform === "darwin" | ||
| 814 | ? [ | ||
| 815 | "--uid", | ||
| 816 | "0", | ||
| 817 | "--gid", | ||
| 818 | "0", | ||
| 819 | "--uname", | ||
| 820 | "root", | ||
| 821 | "--gname", | ||
| 822 | "root", | ||
| 823 | "--options", | ||
| 824 | "gzip:!timestamp", | ||
| 825 | ] | ||
| 826 | : ["--owner=0", "--group=0", "--numeric-owner"]; | ||
| 827 | yield* run("tar", [ | ||
| 828 | "--format=gnutar", | ||
| 829 | ...owner, | ||
| 830 | "--no-recursion", | ||
| 831 | "-czf", | ||
| 832 | NodePath.join(directory, temporary), | ||
| 833 | "-C", | ||
| 834 | source, | ||
| 835 | "-T", | ||
| 836 | list, | ||
| 837 | ]); | ||
| 838 | yield* Effect.tryPromise(() => NodeFSP.rm(list)); | ||
| 839 | const result = yield* artifact(directory, temporary); | ||
| 840 | const file = `knife-${name}-${result.sha256}.tar.gz`; | ||
| 841 | yield* Effect.tryPromise(() => | ||
| 842 | NodeFSP.rename(NodePath.join(directory, temporary), NodePath.join(directory, file)), | ||
| 843 | ); | ||
| 844 | return { ...result, file }; | ||
| 845 | }); | ||
| 846 | |||
| 847 | const prepareForkComponents = Effect.fn("fork.release.prepareComponents")(function* ( | ||
| 848 | directory: string, | ||
| 849 | resources: string, | ||
| 850 | platform: keyof typeof desktopTargets, | ||
| 851 | arch: string, | ||
| 852 | version: string, | ||
| 853 | serverRuntime: string, | ||
| 854 | ) { | ||
| 855 | const desktopRelease = yield* verifyDesktopRelease(directory, platform); | ||
| 856 | const components = NodePath.join(directory, "components"); | ||
| 857 | const codeRoot = NodePath.join(components, "code"); | ||
| 858 | yield* Effect.tryPromise(async () => { | ||
| 859 | await NodeFSP.mkdir(components); | ||
| 860 | extractAll(NodePath.join(resources, "app.asar"), codeRoot); | ||
| 861 | if (platform === "win32") | ||
| 862 | extractAll(NodePath.join(resources, "server.asar"), NodePath.join(codeRoot, "server")); | ||
| 863 | }); | ||
| 864 | const runtime = yield* Effect.tryPromise(() => | ||
| 865 | NodeFSP.readFile(NodePath.join(codeRoot, "knife-runtime.json"), "utf8"), | ||
| 866 | ).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ForkRuntimeManifest)))); | ||
| 867 | if (runtime.version !== desktopRelease.manifest.version) | ||
| 868 | return yield* new ForkReleaseError({ | ||
| 869 | message: "The packaged app version does not match its installer.", | ||
| 870 | }); | ||
| 871 | const serverCodeRoot = platform === "win32" ? NodePath.join(codeRoot, "server") : codeRoot; | ||
| 872 | const archive = desktopRelease.files.find((name) => | ||
| 873 | name.endsWith(installerSuffix(platform, arch)), | ||
| 874 | ); | ||
| 875 | if (!archive) | ||
| 876 | return yield* new ForkReleaseError({ | ||
| 877 | message: "The release has no installer for this architecture.", | ||
| 878 | }); | ||
| 879 | const uiRoot = NodePath.join(components, "ui"); | ||
| 880 | const assetsRoot = NodePath.join(components, "assets"); | ||
| 881 | const serverRoot = NodePath.join(components, "server"); | ||
| 882 | yield* Effect.tryPromise(async () => { | ||
| 883 | await NodeFSP.rename(NodePath.join(serverCodeRoot, "apps/server/dist/client"), uiRoot); | ||
| 884 | const splitAssets = async (relative: string): Promise<void> => { | ||
| 885 | const file = NodePath.join(uiRoot, relative); | ||
| 886 | if ((await NodeFSP.stat(file)).isDirectory()) { | ||
| 887 | for (const child of await NodeFSP.readdir(file)) | ||
| 888 | await splitAssets(NodePath.join(relative, child)); | ||
| 889 | } else if (relative !== ".vite/manifest.json" && !/\.(?:html|m?js|css)$/.test(relative)) { | ||
| 890 | const target = NodePath.join(assetsRoot, relative); | ||
| 891 | await NodeFSP.mkdir(NodePath.dirname(target), { recursive: true }); | ||
| 892 | await NodeFSP.rename(file, target); | ||
| 893 | } | ||
| 894 | }; | ||
| 895 | await splitAssets(""); | ||
| 896 | await NodeFSP.mkdir(NodePath.join(serverRoot, "apps/server"), { recursive: true }); | ||
| 897 | await NodeFSP.cp( | ||
| 898 | NodePath.join(serverCodeRoot, "apps/server/dist"), | ||
| 899 | NodePath.join(serverRoot, "apps/server/dist"), | ||
| 900 | { recursive: true }, | ||
| 901 | ); | ||
| 902 | for (const dist of [ | ||
| 903 | NodePath.join(serverCodeRoot, "apps/server/dist"), | ||
| 904 | NodePath.join(codeRoot, "apps/desktop/dist-electron"), | ||
| 905 | NodePath.join(serverRoot, "apps/server/dist"), | ||
| 906 | ]) { | ||
| 907 | for (const name of await NodeFSP.readdir(dist)) { | ||
| 908 | if (name.endsWith(".map")) await NodeFSP.rm(NodePath.join(dist, name)); | ||
| 909 | } | ||
| 910 | } | ||
| 911 | }); | ||
| 912 | const code = yield* archiveComponent(directory, codeRoot, "code"); | ||
| 913 | const app = { ...(yield* artifact(directory, archive)), version: runtime.version }; | ||
| 914 | const ui = yield* archiveComponent(directory, uiRoot, "ui"); | ||
| 915 | const assets = yield* archiveComponent(directory, assetsRoot, "assets"); | ||
| 916 | const manifest = { | ||
| 917 | version, | ||
| 918 | ui, | ||
| 919 | assets, | ||
| 920 | server: { | ||
| 921 | ...(yield* archiveComponent(directory, serverRoot, "server")), | ||
| 922 | version: runtime.version, | ||
| 923 | runtime: serverRuntime, | ||
| 924 | }, | ||
| 925 | desktop: { | ||
| 926 | [`${platform}-${arch}`]: { | ||
| 927 | version: runtime.version, | ||
| 928 | ui, | ||
| 929 | assets, | ||
| 930 | shell: runtime.shell, | ||
| 931 | runtime: runtime.sha256, | ||
| 932 | code, | ||
| 933 | app, | ||
| 934 | }, | ||
| 935 | }, | ||
| 936 | }; | ||
| 937 | const encoded = yield* encodeRelease(manifest); | ||
| 938 | yield* Effect.tryPromise(() => | ||
| 939 | NodeFSP.writeFile(NodePath.join(directory, "release.json"), encoded + "\n"), | ||
| 940 | ); | ||
| 941 | yield* Effect.tryPromise(() => NodeFSP.rm(components, { recursive: true })); | ||
| 942 | yield* Effect.log( | ||
| 943 | `Built ${version}: UI ${manifest.ui.size} bytes, assets ${manifest.assets.size} bytes, server ${manifest.server.size} bytes.`, | ||
| 944 | ); | ||
| 945 | return directory; | ||
| 946 | }); | ||
| 947 | |||
| 948 | export function resolveForkMacSigningType( | ||
| 949 | identity: string, | ||
| 950 | identities: string, | ||
| 951 | notarizationKeychainProfile: string | undefined, | ||
| 952 | ): "development" | "distribution" { | ||
| 953 | const certificate = identities | ||
| 954 | .split("\n") | ||
| 955 | .map((line) => /^\s*\d+\) ([A-Fa-f0-9]{40}) "([^"]+)"/.exec(line)) | ||
| 956 | .find((match) => match?.[1]?.toUpperCase() === identity.toUpperCase())?.[2]; | ||
| 957 | if (!certificate) | ||
| 958 | throw new ForkReleaseError({ | ||
| 959 | message: | ||
| 960 | "The release signing identity is unavailable. Import its certificate and private key into the signing keychain.", | ||
| 961 | }); | ||
| 962 | if (certificate.startsWith("Developer ID Application:")) { | ||
| 963 | if (!notarizationKeychainProfile) | ||
| 964 | throw new ForkReleaseError({ | ||
| 965 | message: | ||
| 966 | "Developer ID releases need notarization. Set notarizationKeychainProfile in the private release config.", | ||
| 967 | }); | ||
| 968 | return "distribution"; | ||
| 969 | } | ||
| 970 | if (certificate.startsWith("Apple Development:") && !notarizationKeychainProfile) | ||
| 971 | return "development"; | ||
| 972 | throw new ForkReleaseError({ | ||
| 973 | message: | ||
| 974 | "Notarization requires a Developer ID Application certificate. Update signingIdentity in the private release config.", | ||
| 975 | }); | ||
| 976 | } | ||
| 977 | |||
| 978 | const buildForkRelease = Effect.fn("fork.release.build")(function* (config: ReleaseConfig) { | ||
| 979 | const keychainPassword = yield* Effect.tryPromise(() => | ||
| 980 | NodeFSP.readFile(`${config.signingKeychain}.password`, "utf8"), | ||
| 981 | ); | ||
| 982 | yield* run("security", [ | ||
| 983 | "unlock-keychain", | ||
| 984 | "-p", | ||
| 985 | keychainPassword.replace(/[\r\n]+$/, ""), | ||
| 986 | config.signingKeychain, | ||
| 987 | ]); | ||
| 988 | const identities = yield* run( | ||
| 989 | "security", | ||
| 990 | ["find-identity", "-v", "-p", "codesigning", config.signingKeychain], | ||
| 991 | { capture: true }, | ||
| 992 | ); | ||
| 993 | const signingType = yield* Effect.try({ | ||
| 994 | try: () => | ||
| 995 | resolveForkMacSigningType( | ||
| 996 | config.signingIdentity, | ||
| 997 | identities, | ||
| 998 | config.notarizationKeychainProfile, | ||
| 999 | ), | ||
| 1000 | catch: (cause) => | ||
| 1001 | cause instanceof ForkReleaseError | ||
| 1002 | ? cause | ||
| 1003 | : new ForkReleaseError({ message: "Could not verify the release signing identity." }), | ||
| 1004 | }); | ||
| 1005 | if (config.notarizationKeychainProfile) { | ||
| 1006 | yield* run( | ||
| 1007 | "xcrun", | ||
| 1008 | [ | ||
| 1009 | "notarytool", | ||
| 1010 | "history", | ||
| 1011 | "--keychain-profile", | ||
| 1012 | config.notarizationKeychainProfile, | ||
| 1013 | "--keychain", | ||
| 1014 | config.signingKeychain, | ||
| 1015 | ], | ||
| 1016 | { capture: true }, | ||
| 1017 | ); | ||
| 1018 | } else { | ||
| 1019 | yield* Effect.logWarning( | ||
| 1020 | "Apple Development signing does not clear Gatekeeper. Configure Developer ID Application signing and notarization for direct downloads.", | ||
| 1021 | ); | ||
| 1022 | } | ||
| 1023 | const version = `0.0.${Math.floor((yield* Clock.currentTimeMillis) / 1000)}`; | ||
| 1024 | const directory = NodePath.join(repoRoot, "release", "fork", version); | ||
| 1025 | yield* Effect.tryPromise(() => NodeFSP.mkdir(NodePath.dirname(directory), { recursive: true })); | ||
| 1026 | yield* Effect.tryPromise(() => NodeFSP.mkdir(directory)); | ||
| 1027 | const commit = (yield* run("jj", ["log", "-r", "@", "--no-graph", "-T", "commit_id.short(12)"], { | ||
| 1028 | capture: true, | ||
| 1029 | })).trim(); | ||
| 1030 | if (!/^[a-f0-9]{12}$/.test(commit)) | ||
| 1031 | return yield* new ForkReleaseError({ message: "Could not read the current Jujutsu revision." }); | ||
| 1032 | const env = { ...loadRepoEnv(), ...process.env }; | ||
| 1033 | env.T3CODE_COMMIT_HASH = commit; | ||
| 1034 | env.APP_VERSION = version; | ||
| 1035 | env.T3CODE_DESKTOP_UPDATE_URL = config.feedUrl; | ||
| 1036 | env.T3CODE_DESKTOP_MAC_SIGN_IDENTITY = config.signingIdentity; | ||
| 1037 | env.T3CODE_DESKTOP_MAC_SIGN_TYPE = signingType; | ||
| 1038 | env.CSC_KEYCHAIN = config.signingKeychain; | ||
| 1039 | if (config.notarizationKeychainProfile) { | ||
| 1040 | env.APPLE_KEYCHAIN_PROFILE = config.notarizationKeychainProfile; | ||
| 1041 | env.APPLE_KEYCHAIN = config.signingKeychain; | ||
| 1042 | delete env.APPLE_ID; | ||
| 1043 | delete env.APPLE_APP_SPECIFIC_PASSWORD; | ||
| 1044 | delete env.APPLE_TEAM_ID; | ||
| 1045 | } | ||
| 1046 | env.T3CODE_DESKTOP_SIGNED = "false"; | ||
| 1047 | env.T3CODE_DESKTOP_SKIP_BUILD = "false"; | ||
| 1048 | delete env.T3CODE_DISABLE_AUTO_UPDATE; | ||
| 1049 | delete env.T3CODE_DESKTOP_MOCK_UPDATES; | ||
| 1050 | yield* run( | ||
| 1051 | process.execPath, | ||
| 1052 | [ | ||
| 1053 | "scripts/build-desktop-artifact.ts", | ||
| 1054 | "--platform", | ||
| 1055 | "mac", | ||
| 1056 | "--target", | ||
| 1057 | "zip", | ||
| 1058 | "--arch", | ||
| 1059 | "arm64", | ||
| 1060 | "--build-version", | ||
| 1061 | version, | ||
| 1062 | "--output-dir", | ||
| 1063 | directory, | ||
| 1064 | "--verbose", | ||
| 1065 | ], | ||
| 1066 | { env }, | ||
| 1067 | ); | ||
| 1068 | const release = yield* verifyDesktopRelease(directory); | ||
| 1069 | const archive = release.files.find((name) => name.endsWith(".zip"))!; | ||
| 1070 | yield* run("ditto", ["-x", "-k", NodePath.join(directory, archive), directory]); | ||
| 1071 | const entries = yield* Effect.tryPromise(() => NodeFSP.readdir(directory)); | ||
| 1072 | const appName = entries.find((name) => name.endsWith(".app")); | ||
| 1073 | if (!appName) | ||
| 1074 | return yield* new ForkReleaseError({ message: "The update ZIP did not contain an app." }); | ||
| 1075 | yield* run("codesign", ["--verify", "--deep", "--strict", NodePath.join(directory, appName)]); | ||
| 1076 | if (signingType === "distribution") { | ||
| 1077 | yield* run("xcrun", ["stapler", "validate", NodePath.join(directory, appName)]); | ||
| 1078 | yield* run("spctl", ["--assess", "--type", "execute", NodePath.join(directory, appName)]); | ||
| 1079 | } | ||
| 1080 | yield* run( | ||
| 1081 | NodePath.join(directory, appName, "Contents", "MacOS", appName.slice(0, -4)), | ||
| 1082 | ["-p", "process.versions.node"], | ||
| 1083 | { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } }, | ||
| 1084 | ); | ||
| 1085 | return yield* prepareForkComponents( | ||
| 1086 | directory, | ||
| 1087 | NodePath.join(directory, appName, "Contents/Resources"), | ||
| 1088 | "darwin", | ||
| 1089 | "arm64", | ||
| 1090 | version, | ||
| 1091 | yield* Effect.tryPromise(() => fingerprintForkServerRuntime(repoRoot)), | ||
| 1092 | ); | ||
| 1093 | }); | ||
| 1094 | |||
| 1095 | export const ingestForkRelease = Effect.fn("fork.release.ingest")(function* ( | ||
| 1096 | platformKey: string, | ||
| 1097 | source: string, | ||
| 1098 | directory: string, | ||
| 1099 | version: string, | ||
| 1100 | ) { | ||
| 1101 | const match = /^(linux|win32)-(x64|arm64)$/.exec(platformKey); | ||
| 1102 | if (!match) | ||
| 1103 | return yield* new ForkReleaseError({ | ||
| 1104 | message: "Choose linux-x64, linux-arm64, win32-x64, or win32-arm64.", | ||
| 1105 | }); | ||
| 1106 | const platform = match[1] === "linux" ? "linux" : "win32"; | ||
| 1107 | const arch = match[2]!; | ||
| 1108 | const verified = yield* verifyDesktopRelease(source, platform); | ||
| 1109 | const installer = verified.manifest.files.find((file) => | ||
| 1110 | file.url.endsWith(installerSuffix(platform, arch)), | ||
| 1111 | ); | ||
| 1112 | if (!installer) | ||
| 1113 | return yield* new ForkReleaseError({ | ||
| 1114 | message: "The release has no installer for this architecture.", | ||
| 1115 | }); | ||
| 1116 | const archive = NodePath.join(source, `knife-resources-${platformKey}.tar.gz`); | ||
| 1117 | const entries = (yield* run("tar", ["-tzf", archive], { capture: true })).trim().split(/\r?\n/); | ||
| 1118 | if ( | ||
| 1119 | entries.some((entry) => { | ||
| 1120 | const path = entry.replace(/^\.\//, ""); | ||
| 1121 | return ( | ||
| 1122 | path.startsWith("/") || | ||
| 1123 | path.includes("\\") || | ||
| 1124 | path.split("/").includes("..") || | ||
| 1125 | !/^(?:app\.asar(?:\.unpacked(?:\/.*)?)?|server\.asar(?:\.unpacked(?:\/.*)?)?|knife-server-runtime)\/?$/.test( | ||
| 1126 | path, | ||
| 1127 | ) | ||
| 1128 | ); | ||
| 1129 | }) | ||
| 1130 | ) | ||
| 1131 | return yield* new ForkReleaseError({ | ||
| 1132 | message: "The native resource archive contains an unexpected path.", | ||
| 1133 | }); | ||
| 1134 | yield* Effect.tryPromise(() => NodeFSP.mkdir(directory, { recursive: true })); | ||
| 1135 | const resources = yield* Effect.tryPromise(() => | ||
| 1136 | NodeFSP.mkdtemp(NodePath.join(directory, "native-resources-")), | ||
| 1137 | ); | ||
| 1138 | yield* run("tar", ["-xzf", archive, "-C", resources]); | ||
| 1139 | const serverRuntime = (yield* Effect.tryPromise(() => | ||
| 1140 | NodeFSP.readFile(NodePath.join(resources, "knife-server-runtime"), "utf8"), | ||
| 1141 | )).trim(); | ||
| 1142 | yield* Effect.tryPromise(async () => { | ||
| 1143 | for (const name of verified.files.filter( | ||
| 1144 | (name) => name === installer.url || name === `${installer.url}.blockmap`, | ||
| 1145 | )) | ||
| 1146 | await NodeFSP.copyFile(NodePath.join(source, name), NodePath.join(directory, name)); | ||
| 1147 | await NodeFSP.writeFile( | ||
| 1148 | NodePath.join(directory, desktopTargets[platform].manifest), | ||
| 1149 | serializeUpdateManifest( | ||
| 1150 | { ...verified.manifest, files: [installer] }, | ||
| 1151 | { platformLabel: desktopTargets[platform].label }, | ||
| 1152 | ), | ||
| 1153 | ); | ||
| 1154 | }); | ||
| 1155 | yield* prepareForkComponents(directory, resources, platform, arch, version, serverRuntime); | ||
| 1156 | yield* Effect.tryPromise(() => NodeFSP.rm(resources, { recursive: true })); | ||
| 1157 | return directory; | ||
| 1158 | }); | ||
| 1159 | |||
| 1160 | if (import.meta.main) { | ||
| 1161 | Effect.gen(function* () { | ||
| 1162 | const mode = process.argv[2] ?? "release"; | ||
| 1163 | if (!["build", "publish", "release", "ingest"].includes(mode)) | ||
| 1164 | return yield* new ForkReleaseError({ | ||
| 1165 | message: | ||
| 1166 | "Use build, publish <release-directory>, release, or ingest <platform-arch> <native-build-directory>.", | ||
| 1167 | }); | ||
| 1168 | const config = yield* Effect.tryPromise(() => | ||
| 1169 | NodeFSP.readFile(NodePath.join(NodeOS.homedir(), ".config/t3-fork-release.json"), "utf8"), | ||
| 1170 | ).pipe( | ||
| 1171 | Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ReleaseConfig))), | ||
| 1172 | Effect.mapError( | ||
| 1173 | () => | ||
| 1174 | new ForkReleaseError({ | ||
| 1175 | message: | ||
| 1176 | "Configure ~/.config/t3-fork-release.json with feedUrl, uploadUrl, signingIdentity, and signingKeychain.", | ||
| 1177 | }), | ||
| 1178 | ), | ||
| 1179 | ); | ||
| 1180 | let directory: string | undefined; | ||
| 1181 | if (mode === "ingest") { | ||
| 1182 | const platform = process.argv[3]; | ||
| 1183 | const source = process.argv[4]; | ||
| 1184 | if (!platform || !source) | ||
| 1185 | return yield* new ForkReleaseError({ | ||
| 1186 | message: "Pass the platform and native build directory to ingest.", | ||
| 1187 | }); | ||
| 1188 | const version = `0.0.${Math.floor((yield* Clock.currentTimeMillis) / 1000)}`; | ||
| 1189 | directory = yield* ingestForkRelease( | ||
| 1190 | platform, | ||
| 1191 | NodePath.resolve(source), | ||
| 1192 | NodePath.join(repoRoot, "release/fork", version), | ||
| 1193 | version, | ||
| 1194 | ); | ||
| 1195 | } else { | ||
| 1196 | directory = mode === "publish" ? process.argv[3] : yield* buildForkRelease(config); | ||
| 1197 | } | ||
| 1198 | if (!directory) | ||
| 1199 | return yield* new ForkReleaseError({ message: "Pass the release directory to publish." }); | ||
| 1200 | if (mode !== "build") yield* publishForkRelease(NodePath.resolve(directory), config); | ||
| 1201 | }).pipe( | ||
| 1202 | Effect.scoped, | ||
| 1203 | Effect.catch((error) => | ||
| 1204 | Effect.logError(error instanceof Error ? error.message : "Fork release failed.").pipe( | ||
| 1205 | Effect.andThen( | ||
| 1206 | Effect.sync(() => { | ||
| 1207 | process.exitCode = 1; | ||
| 1208 | }), | ||
| 1209 | ), | ||
| 1210 | ), | ||
| 1211 | ), | ||
| 1212 | Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), | ||
| 1213 | NodeRuntime.runMain, | ||
| 1214 | ); | ||
| 1215 | } | ||