diff --git a/FORK.md b/FORK.md index a125bc556..beae9b777 100644 --- a/FORK.md +++ b/FORK.md @@ -40,6 +40,7 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork - Stable-version pushes wait for the matching release to finish so tag-based version resolution advances past the published version. - Release build jobs skip relay client tracing config because the relay config job is disabled. - Release builds publish updater metadata against the fork repository. +- Unsigned macOS updates replace the installed app bundle with a detached helper instead of using Squirrel.Mac installation. - Fork stable release versions use date-based `YYYY.M.DDSS` numbers without build metadata. Release PRs commit them before release artifacts are built and tagged. - macOS release signing is separate from Apple notarization. - Self-signed macOS signing certificates are trusted during release builds. diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index c2acc9ce1..14e6e60d0 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -1,9 +1,13 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { beforeEach, vi } from "vite-plus/test"; -const { autoUpdaterMock } = vi.hoisted(() => ({ - autoUpdaterMock: { +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +const { autoUpdaterMock, updaterConstructorMock } = vi.hoisted(() => { + const autoUpdaterMock = { allowDowngrade: false, allowPrerelease: false, autoDownload: true, @@ -17,15 +21,25 @@ const { autoUpdaterMock } = vi.hoisted(() => ({ quitAndInstall: vi.fn(), removeListener: vi.fn(), setFeedURL: vi.fn(), - }, -})); + }; + const updaterConstructorMock = vi.fn(function () { + return autoUpdaterMock; + }); + return { autoUpdaterMock, updaterConstructorMock }; +}); vi.mock("electron-updater", () => ({ - autoUpdater: autoUpdaterMock, + AppImageUpdater: updaterConstructorMock, + MacUpdater: updaterConstructorMock, + NsisUpdater: updaterConstructorMock, })); import * as ElectronUpdater from "./ElectronUpdater.ts"; +const updaterLayer = ElectronUpdater.layer.pipe( + Layer.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcessPlatform, "linux"))), +); + describe("ElectronUpdater", () => { beforeEach(() => { autoUpdaterMock.allowDowngrade = false; @@ -56,9 +70,9 @@ describe("ElectronUpdater", () => { }), ); - assert.deepEqual(autoUpdaterMock.on.mock.calls, [["update-available", listener]]); + assert.deepEqual(autoUpdaterMock.on.mock.calls.at(-1), ["update-available", listener]); assert.deepEqual(autoUpdaterMock.removeListener.mock.calls, [["update-available", listener]]); - }).pipe(Effect.provide(ElectronUpdater.layer)), + }).pipe(Effect.provide(updaterLayer)), ); it.effect("wraps rejected update checks in the method-specific typed error", () => @@ -76,7 +90,7 @@ describe("ElectronUpdater", () => { assert.strictEqual(error.cause, cause); assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronUpdater.layer)), + }).pipe(Effect.provide(updaterLayer)), ); it.effect("preserves the execution-time channel on download failures", () => @@ -97,7 +111,7 @@ describe("ElectronUpdater", () => { "Electron updater failed to download the update on channel nightly.", ); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronUpdater.layer)), + }).pipe(Effect.provide(updaterLayer)), ); it.effect("sets full changelog mode", () => @@ -109,7 +123,7 @@ describe("ElectronUpdater", () => { yield* updater.setFullChangelog(false); assert.equal(autoUpdaterMock.fullChangelog, false); - }).pipe(Effect.provide(ElectronUpdater.layer)), + }).pipe(Effect.provide(updaterLayer)), ); it.effect("preserves quit-and-install flags and the execution-time channel", () => @@ -137,6 +151,6 @@ describe("ElectronUpdater", () => { ); assert.notInclude(error.message, cause.message); assert.deepEqual(autoUpdaterMock.quitAndInstall.mock.calls, [[true, false]]); - }).pipe(Effect.provide(ElectronUpdater.layer)), + }).pipe(Effect.provide(updaterLayer)), ); }); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 4157d29a9..e09fb9f52 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -4,11 +4,31 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import { autoUpdater } from "electron-updater"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + AppImageUpdater, + MacUpdater, + NsisUpdater, + type AppUpdater, + type UpdateDownloadedEvent, +} from "electron-updater"; -type AutoUpdater = typeof autoUpdater; +import { makeInstallUnsignedMacUpdate } from "./installUnsignedMacUpdate.ts"; -export type ElectronUpdaterFeedUrl = Parameters[0]; +export type ElectronUpdaterFeedUrl = Parameters[0]; + +function createUpdater(platform: NodeJS.Platform): AppUpdater { + switch (platform) { + case "linux": + return new AppImageUpdater(); + case "darwin": + return new MacUpdater(); + case "win32": + return new NsisUpdater(); + default: + throw new Error(`Unsupported desktop update platform: ${platform}`); + } +} export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedErrorClass()( "ElectronUpdaterCheckForUpdatesError", @@ -81,92 +101,125 @@ export class ElectronUpdater extends Context.Service< } >()("@t3tools/desktop/electron/ElectronUpdater") {} -export const make = ElectronUpdater.of({ - setFeedURL: (options) => - Effect.suspend(() => { - autoUpdater.setFeedURL(options); - return Effect.void; - }), - setAutoDownload: (value) => - Effect.suspend(() => { - autoUpdater.autoDownload = value; - return Effect.void; - }), - setAutoInstallOnAppQuit: (value) => - Effect.suspend(() => { - autoUpdater.autoInstallOnAppQuit = value; - return Effect.void; - }), - setChannel: (channel) => - Effect.suspend(() => { - autoUpdater.channel = channel; - return Effect.void; - }), - setAllowPrerelease: (value) => - Effect.suspend(() => { - autoUpdater.allowPrerelease = value; - return Effect.void; - }), - allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), - setAllowDowngrade: (value) => - Effect.suspend(() => { - autoUpdater.allowDowngrade = value; - return Effect.void; - }), - setFullChangelog: (value) => - Effect.suspend(() => { - autoUpdater.fullChangelog = value; - return Effect.void; - }), - setDisableDifferentialDownload: (value) => - Effect.suspend(() => { - autoUpdater.disableDifferentialDownload = value; - return Effect.void; +export const make = Effect.gen(function* () { + const installUnsignedMacUpdate = yield* makeInstallUnsignedMacUpdate(); + const platform = yield* HostProcessPlatform; + const updater = createUpdater(platform); + let downloadedUpdatePath: string | undefined; + updater.on("update-downloaded", (event: UpdateDownloadedEvent) => { + downloadedUpdatePath = event.downloadedFile; + }); + + return ElectronUpdater.of({ + setFeedURL: (options) => + Effect.suspend(() => { + updater.setFeedURL(options); + return Effect.void; + }), + setAutoDownload: (value) => + Effect.suspend(() => { + updater.autoDownload = value; + return Effect.void; + }), + setAutoInstallOnAppQuit: (value) => + Effect.suspend(() => { + updater.autoInstallOnAppQuit = value; + return Effect.void; + }), + setChannel: (channel) => + Effect.suspend(() => { + updater.channel = channel; + return Effect.void; + }), + setAllowPrerelease: (value) => + Effect.suspend(() => { + updater.allowPrerelease = value; + return Effect.void; + }), + allowDowngrade: Effect.sync(() => updater.allowDowngrade), + setAllowDowngrade: (value) => + Effect.suspend(() => { + updater.allowDowngrade = value; + return Effect.void; + }), + setFullChangelog: (value) => + Effect.suspend(() => { + updater.fullChangelog = value; + return Effect.void; + }), + setDisableDifferentialDownload: (value) => + Effect.suspend(() => { + updater.disableDifferentialDownload = value; + return Effect.void; + }), + checkForUpdates: Effect.suspend(() => { + const channel = updater.channel; + return Effect.tryPromise({ + try: () => updater.checkForUpdates(), + catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ channel, cause }), + }).pipe(Effect.asVoid); }), - checkForUpdates: Effect.suspend(() => { - const channel = autoUpdater.channel; - return Effect.tryPromise({ - try: () => autoUpdater.checkForUpdates(), - catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ channel, cause }), - }).pipe(Effect.asVoid); - }), - downloadUpdate: Effect.suspend(() => { - const channel = autoUpdater.channel; - return Effect.tryPromise({ - try: () => autoUpdater.downloadUpdate(), - catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), - }).pipe(Effect.asVoid); - }), - quitAndInstall: ({ isSilent, isForceRunAfter }) => - Effect.suspend(() => { - const channel = autoUpdater.channel; - return Effect.try({ - try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), - catch: (cause) => - new ElectronUpdaterQuitAndInstallError({ - channel, - isSilent, - isForceRunAfter, - cause, - }), - }); + downloadUpdate: Effect.suspend(() => { + const channel = updater.channel; + return Effect.tryPromise({ + try: () => updater.downloadUpdate(), + catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), + }).pipe(Effect.asVoid); }), - on: (eventName, listener) => { - const eventTarget = autoUpdater as unknown as { - on: (eventName: string, listener: (...args: Array) => void) => void; - removeListener: (eventName: string, listener: (...args: Array) => void) => void; - }; - const untypedListener = listener as unknown as (...args: Array) => void; - return Effect.acquireRelease( - Effect.sync(() => { - eventTarget.on(eventName, untypedListener); + quitAndInstall: ({ isSilent, isForceRunAfter }) => + Effect.suspend(() => { + const channel = updater.channel; + if (platform === "darwin") { + if (downloadedUpdatePath === undefined) { + return Effect.fail( + new ElectronUpdaterQuitAndInstallError({ + channel, + isSilent, + isForceRunAfter, + cause: new Error("Downloaded macOS update path is unavailable."), + }), + ); + } + return installUnsignedMacUpdate(downloadedUpdatePath).pipe( + Effect.mapError( + (cause) => + new ElectronUpdaterQuitAndInstallError({ + channel, + isSilent, + isForceRunAfter, + cause, + }), + ), + ); + } + return Effect.try({ + try: () => updater.quitAndInstall(isSilent, isForceRunAfter), + catch: (cause) => + new ElectronUpdaterQuitAndInstallError({ + channel, + isSilent, + isForceRunAfter, + cause, + }), + }); }), - () => + on: (eventName, listener) => { + const eventTarget = updater as unknown as { + on: (eventName: string, listener: (...args: Array) => void) => void; + removeListener: (eventName: string, listener: (...args: Array) => void) => void; + }; + const untypedListener = listener as unknown as (...args: Array) => void; + return Effect.acquireRelease( Effect.sync(() => { - eventTarget.removeListener(eventName, untypedListener); + eventTarget.on(eventName, untypedListener); }), - ).pipe(Effect.asVoid); - }, + () => + Effect.sync(() => { + eventTarget.removeListener(eventName, untypedListener); + }), + ).pipe(Effect.asVoid); + }, + }); }); -export const layer = Layer.succeed(ElectronUpdater, make); +export const layer = Layer.effect(ElectronUpdater, make); diff --git a/apps/desktop/src/electron/installUnsignedMacUpdate.ts b/apps/desktop/src/electron/installUnsignedMacUpdate.ts new file mode 100644 index 000000000..2bb591c43 --- /dev/null +++ b/apps/desktop/src/electron/installUnsignedMacUpdate.ts @@ -0,0 +1,139 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Electron from "electron"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +const INSTALL_HELPER = `#!/bin/sh +set -eu + +reopen_bundle() { + reopen_target="$1" + reopen_elevated="$2" + + if [ "$reopen_elevated" = "true" ]; then + console_uid="$(/usr/bin/stat -f '%u' /dev/console)" + console_user="$(/usr/bin/id -nu "$console_uid")" + /usr/bin/sudo -u "$console_user" /bin/launchctl asuser "$console_uid" /usr/bin/open "$reopen_target" + return + fi + + /usr/bin/open "$reopen_target" +} + +replace_bundle() { + replace_candidate="$1" + replace_target="$2" + replace_elevated="$3" + replace_previous="\${replace_target}.previous" + + /bin/rm -rf "$replace_previous" + if ! /bin/mv "$replace_target" "$replace_previous"; then + reopen_bundle "$replace_target" "$replace_elevated" || true + return 1 + fi + + if ! /bin/mv "$replace_candidate" "$replace_target"; then + /bin/mv "$replace_previous" "$replace_target" + reopen_bundle "$replace_target" "$replace_elevated" || true + return 1 + fi + + if reopen_bundle "$replace_target" "$replace_elevated"; then + /bin/rm -rf "$replace_previous" + return 0 + fi + + /bin/rm -rf "$replace_target" + /bin/mv "$replace_previous" "$replace_target" + reopen_bundle "$replace_target" "$replace_elevated" || true + return 1 +} + +if [ "\${1:-}" = "--replace-elevated" ]; then + shift + replace_bundle "$1" "$2" true + exit $? +fi + +running_pid="$1" +archive="$2" +target="$3" +helper_dir="$(/usr/bin/dirname "$0")" +stage="$(/usr/bin/mktemp -d "\${TMPDIR:-/tmp}/t3code-update.XXXXXX")" + +cleanup() { + /bin/rm -rf "$stage" + /bin/rm -f "$0" + /bin/rmdir "$helper_dir" 2>/dev/null || true +} +trap cleanup EXIT + +while /bin/kill -0 "$running_pid" 2>/dev/null; do + /bin/sleep 1 +done + +if ! /usr/bin/ditto -x -k "$archive" "$stage"; then + /usr/bin/open "$target" || true + exit 1 +fi + +candidate="$(/usr/bin/find "$stage" -type d -name '*.app' -prune -print | /usr/bin/head -n 1)" +if [ -z "$candidate" ]; then + /usr/bin/open "$target" || true + exit 1 +fi + +/usr/bin/xattr -rd com.apple.quarantine "$candidate" 2>/dev/null || true + +target_parent="$(/usr/bin/dirname "$target")" +if [ -w "$target_parent" ]; then + replace_bundle "$candidate" "$target" false + exit $? +fi + +/usr/bin/osascript - "$0" "$candidate" "$target" <<'APPLESCRIPT' +on run argv + set helperPath to item 1 of argv + set candidatePath to item 2 of argv + set targetPath to item 3 of argv + do shell script "/bin/sh " & quoted form of helperPath & " --replace-elevated " & quoted form of candidatePath & " " & quoted form of targetPath with administrator privileges +end run +APPLESCRIPT +`; + +export const makeInstallUnsignedMacUpdate = Effect.fn("makeInstallUnsignedMacUpdate")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + return Effect.fn("installUnsignedMacUpdate")(function* (archivePath: string) { + const executablePath = Electron.app.getPath("exe"); + const targetBundlePath = path.dirname(path.dirname(path.dirname(executablePath))); + const helperDirectory = yield* fileSystem.makeTempDirectory({ + directory: Electron.app.getPath("temp"), + prefix: "t3code-mac-update-", + }); + const helperPath = path.join(helperDirectory, "install-update.sh"); + yield* fileSystem.writeFileString(helperPath, INSTALL_HELPER, { mode: 0o700 }); + + yield* Effect.scoped( + Effect.gen(function* () { + const helper = yield* childProcessSpawner.spawn( + ChildProcess.make( + "/bin/sh", + [helperPath, String(process.pid), archivePath, targetBundlePath], + { + detached: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ), + ); + yield* helper.unref.pipe(Effect.asVoid); + }), + ); + yield* Effect.sync(() => Electron.app.quit()); + }); +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ad370e36f..b3773de98 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -120,7 +120,7 @@ const electronLayer = Layer.mergeAll( ElectronSafeStorage.layer, ElectronShell.layer, ElectronTheme.layer, - ElectronUpdater.layer, + ElectronUpdater.layer.pipe(Layer.provide(NodeServices.layer)), ElectronWindow.layer, DesktopIpc.layer(Electron.ipcMain), ); diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 38fa3be8b..0cf433432 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -14,6 +14,7 @@ import { ExecutionEnvironmentDescriptor, PortSchema, } from "@t3tools/contracts"; +import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import { buildTailscaleHttpsBaseUrl, @@ -337,6 +338,7 @@ const makePairServerConfig = Effect.fn(function* (input: { host: state.host, cwd: process.cwd(), baseDir, + basePath: ROOT_BASE_PATH, ...derivedPaths, staticDir: undefined, devUrl, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1b2a7ca7c..a07795e49 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -298,7 +298,7 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200; // past this gap a single O(active-threads) snapshot is cheaper and bounded. // Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT). const SHELL_RESUME_MAX_GAP = 1_000; -const THREAD_DELTA_SUBSCRIPTION_MAX_GAP = 1_000; +const THREAD_RESUME_MAX_GAP = 1_000; function toAuthAccessStreamEvent( change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, @@ -987,7 +987,7 @@ const makeWsRpcLayer = ( const makeThreadSubscription = Effect.fn("Ws.makeThreadSubscription")(function* (input: { readonly request: OrchestrationSubscribeThreadInput; - readonly maxReplayGap: number | null; + readonly missingThread: "error" | "not-found"; }) { const isThisThreadDetailEvent = (event: OrchestrationEvent) => event.aggregateKind === "thread" && @@ -1021,56 +1021,42 @@ const makeWsRpcLayer = ( if (input.request.afterSequence !== undefined) { const afterSequence = input.request.afterSequence; - if (input.maxReplayGap !== null) { - const headSequence = yield* orchestrationEngine.latestSequence; - const replayGap = headSequence - afterSequence; - if (replayGap < 0 || replayGap > input.maxReplayGap) { - const snapshot = yield* loadThreadSnapshot(input.request.threadId); - if (Option.isNone(snapshot)) { + const headSequence = yield* orchestrationEngine.latestSequence; + const replayGap = headSequence - afterSequence; + if (replayGap < 0 || replayGap > THREAD_RESUME_MAX_GAP) { + const snapshot = yield* loadThreadSnapshot(input.request.threadId); + if (Option.isNone(snapshot)) { + if (input.missingThread === "not-found") { return Stream.concat( Stream.make({ kind: "not-found" as const }), synchronizedThenLive, ); } - return Stream.concat( - Stream.make({ kind: "snapshot" as const, snapshot: snapshot.value }), - synchronizedThenLive, - ); + return yield* new OrchestrationGetSnapshotError({ + message: `Thread ${input.request.threadId} was not found`, + cause: input.request.threadId, + }); } - - const catchUpStream = orchestrationEngine.readEvents(afterSequence, replayGap).pipe( - Stream.filter(isThisThreadDetailEvent), - Stream.map((event) => ({ - kind: "event" as const, - event: projectActivityEvent(event), - })), - Stream.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: `Failed to replay thread ${input.request.threadId} events`, - cause, - }), - ), + return Stream.concat( + Stream.make({ kind: "snapshot" as const, snapshot: snapshot.value }), + synchronizedThenLive, ); - return Stream.concat(catchUpStream, synchronizedThenLive); } - const catchUpStream = orchestrationEngine - .readEvents(afterSequence, Number.MAX_SAFE_INTEGER) - .pipe( - Stream.filter(isThisThreadDetailEvent), - Stream.map((event) => ({ - kind: "event" as const, - event: projectActivityEvent(event), - })), - Stream.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: `Failed to replay thread ${input.request.threadId} events`, - cause, - }), - ), - ); + const catchUpStream = orchestrationEngine.readEvents(afterSequence, replayGap).pipe( + Stream.filter(isThisThreadDetailEvent), + Stream.map((event) => ({ + kind: "event" as const, + event: projectActivityEvent(event), + })), + Stream.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: `Failed to replay thread ${input.request.threadId} events`, + cause, + }), + ), + ); return Stream.concat(catchUpStream, synchronizedThenLive); } @@ -1364,7 +1350,7 @@ const makeWsRpcLayer = ( [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeThread, - makeThreadSubscription({ request: input, maxReplayGap: null }).pipe( + makeThreadSubscription({ request: input, missingThread: "error" }).pipe( Effect.map((stream) => Stream.filter( stream, @@ -1379,7 +1365,7 @@ const makeWsRpcLayer = ( ORCHESTRATION_WS_METHODS.subscribeThreadWithDelta, makeThreadSubscription({ request: input, - maxReplayGap: THREAD_DELTA_SUBSCRIPTION_MAX_GAP, + missingThread: "not-found", }), { "rpc.aggregate": "orchestration" }, ), diff --git a/docs/operations/release.md b/docs/operations/release.md index 9b33e94cc..72cd09d05 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -200,6 +200,9 @@ guidance when those environments are available. - No automatic download or install. - The desktop UI shows a rocket update button when an update is available; click once to download, click again after download to restart/install. - Provider: GitHub Releases (`provider: github`) configured at build time. +- Installation: + - Linux AppImage and Windows NSIS builds use `electron-updater`'s standard installer. + - macOS uses `MacUpdater` for checks, architecture selection, download, and SHA-512 verification. A detached helper extracts the ZIP, clears quarantine, replaces the installed app bundle, and restores the previous bundle if replacement or relaunch fails. Protected install locations request administrator privileges. - Repository slug source: - `T3CODE_DESKTOP_UPDATE_REPOSITORY` (format `owner/repo`), if set. - otherwise `GITHUB_REPOSITORY` from GitHub Actions.