From ba32b3671e448af2e4a4e05d9943daf0dc5f9d2f Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Sat, 14 Mar 2026 22:18:43 +0000 Subject: [PATCH] Fix macOS auto-update for unsigned builds with manual install fallback --- apps/desktop/src/macCodeSigning.test.ts | 23 +++ apps/desktop/src/macCodeSigning.ts | 5 + apps/desktop/src/macUpdateInstaller.test.ts | 62 ++++++++ apps/desktop/src/macUpdateInstaller.ts | 89 ++++++++++++ apps/desktop/src/main.ts | 149 +++++++++++++++++--- 5 files changed, 308 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/macCodeSigning.test.ts create mode 100644 apps/desktop/src/macCodeSigning.ts create mode 100644 apps/desktop/src/macUpdateInstaller.test.ts create mode 100644 apps/desktop/src/macUpdateInstaller.ts diff --git a/apps/desktop/src/macCodeSigning.test.ts b/apps/desktop/src/macCodeSigning.test.ts new file mode 100644 index 00000000..8b78de10 --- /dev/null +++ b/apps/desktop/src/macCodeSigning.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { hasDeveloperIdApplicationAuthority } from "./macCodeSigning"; + +describe("hasDeveloperIdApplicationAuthority", () => { + it("matches a Developer ID Application authority line", () => { + expect( + hasDeveloperIdApplicationAuthority(`Executable=/Applications/T3 Code.app/Contents/MacOS/T3 Code +Identifier=com.t3tools.t3code +Authority=Developer ID Application: T3 Tools, Inc. (ABCDE12345) +Authority=Developer ID Certification Authority +Authority=Apple Root CA`), + ).toBe(true); + }); + + it("ignores ad hoc signatures and unsigned output", () => { + expect( + hasDeveloperIdApplicationAuthority(`Executable=/Applications/T3 Code.app/Contents/MacOS/T3 Code +Signature=adhoc`), + ).toBe(false); + expect(hasDeveloperIdApplicationAuthority("code object is not signed at all")).toBe(false); + }); +}); diff --git a/apps/desktop/src/macCodeSigning.ts b/apps/desktop/src/macCodeSigning.ts new file mode 100644 index 00000000..ce6ccccb --- /dev/null +++ b/apps/desktop/src/macCodeSigning.ts @@ -0,0 +1,5 @@ +export function hasDeveloperIdApplicationAuthority(value: string): boolean { + return value + .split(/\r?\n/) + .some((line) => line.trim().startsWith("Authority=Developer ID Application:")); +} diff --git a/apps/desktop/src/macUpdateInstaller.test.ts b/apps/desktop/src/macUpdateInstaller.test.ts new file mode 100644 index 00000000..df96520a --- /dev/null +++ b/apps/desktop/src/macUpdateInstaller.test.ts @@ -0,0 +1,62 @@ +import * as FS from "node:fs"; +import * as OS from "node:os"; +import * as Path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + buildMacManualUpdateInstallScript, + findFirstAppBundlePath, + resolveDownloadedMacUpdateZipPath, +} from "./macUpdateInstaller"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + FS.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("resolveDownloadedMacUpdateZipPath", () => { + it("returns the downloaded zip path", () => { + expect(resolveDownloadedMacUpdateZipPath(["/tmp/update.zip", "/tmp/update.blockmap"])).toBe( + "/tmp/update.zip", + ); + }); + + it("returns null when no zip exists", () => { + expect(resolveDownloadedMacUpdateZipPath(["/tmp/update.blockmap"])).toBeNull(); + }); +}); + +describe("findFirstAppBundlePath", () => { + it("finds an extracted app bundle recursively", () => { + const rootDir = FS.mkdtempSync(Path.join(OS.tmpdir(), "t3-mac-update-")); + tempDirs.push(rootDir); + + const appPath = Path.join(rootDir, "nested", "T3 Code.app"); + FS.mkdirSync(appPath, { recursive: true }); + + expect(findFirstAppBundlePath(rootDir)).toBe(appPath); + }); +}); + +describe("buildMacManualUpdateInstallScript", () => { + it("builds a detached installer script with admin fallback", () => { + const script = buildMacManualUpdateInstallScript({ + appPid: 123, + sourceAppPath: "/tmp/T3 Code's Update.app", + targetAppPath: "/Applications/T3 Code.app", + stagingDir: "/tmp/t3-stage", + }); + + expect(script).toContain("APP_PID=123"); + expect(script).toContain("wait_for_app_exit"); + expect(script).toContain("/usr/bin/ditto"); + expect(script).toContain("/usr/bin/osascript"); + expect(script).toContain(`SOURCE_APP='/tmp/T3 Code'\\''s Update.app'`); + expect(script).toContain(`TARGET_APP='/Applications/T3 Code.app'`); + expect(script).toContain('/usr/bin/open -n "$TARGET_APP"'); + }); +}); diff --git a/apps/desktop/src/macUpdateInstaller.ts b/apps/desktop/src/macUpdateInstaller.ts new file mode 100644 index 00000000..feddca29 --- /dev/null +++ b/apps/desktop/src/macUpdateInstaller.ts @@ -0,0 +1,89 @@ +import * as FS from "node:fs"; +import * as Path from "node:path"; + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +export function resolveDownloadedMacUpdateZipPath( + downloadedFiles: ReadonlyArray, +): string | null { + for (const file of downloadedFiles) { + if (file.toLowerCase().endsWith(".zip")) { + return file; + } + } + return null; +} + +export function findFirstAppBundlePath(rootDir: string): string | null { + const queue = [rootDir]; + + while (queue.length > 0) { + const currentDir = queue.shift(); + if (!currentDir) { + continue; + } + + for (const entry of FS.readdirSync(currentDir, { withFileTypes: true })) { + const entryPath = Path.join(currentDir, entry.name); + if (entry.isDirectory() && entry.name.endsWith(".app")) { + return entryPath; + } + if (entry.isDirectory()) { + queue.push(entryPath); + } + } + } + + return null; +} + +export function buildMacManualUpdateInstallScript(args: { + appPid: number; + sourceAppPath: string; + targetAppPath: string; + stagingDir: string; +}): string { + const sourceAppPath = shellQuote(args.sourceAppPath); + const targetAppPath = shellQuote(args.targetAppPath); + const stagingDir = shellQuote(args.stagingDir); + + return `#!/bin/sh +set -eu +APP_PID=${args.appPid} +SOURCE_APP=${sourceAppPath} +TARGET_APP=${targetAppPath} +STAGING_DIR=${stagingDir} + +cleanup() { + /bin/rm -rf "$STAGING_DIR" +} + +trap cleanup EXIT + +wait_for_app_exit() { + while /bin/kill -0 "$APP_PID" 2>/dev/null; do + /bin/sleep 0.2 + done +} + +install_update() { + /bin/rm -rf "$TARGET_APP" + /usr/bin/ditto "$SOURCE_APP" "$TARGET_APP" +} + +wait_for_app_exit + +if ! install_update >/dev/null 2>&1; then + export SOURCE_APP TARGET_APP + /usr/bin/osascript <<'APPLESCRIPT' +set sourceApp to system attribute "SOURCE_APP" +set targetApp to system attribute "TARGET_APP" +do shell script "/bin/rm -rf " & quoted form of targetApp & " && /usr/bin/ditto " & quoted form of sourceApp & " " & quoted form of targetApp with administrator privileges +APPLESCRIPT +fi + +/usr/bin/open -n "$TARGET_APP" +`; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 46068492..edfacbc5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -29,6 +29,12 @@ import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { fixPath } from "./fixPath"; +import { hasDeveloperIdApplicationAuthority } from "./macCodeSigning"; +import { + buildMacManualUpdateInstallScript, + findFirstAppBundlePath, + resolveDownloadedMacUpdateZipPath, +} from "./macUpdateInstaller"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; import { createInitialDesktopUpdateState, @@ -93,6 +99,8 @@ let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; +let macDeveloperIdSigned: boolean | null = null; +let downloadedUpdateFiles: string[] = []; const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ platform: process.platform, processArch: process.arch, @@ -113,6 +121,48 @@ function sanitizeLogValue(value: string): string { return value.replace(/\s+/g, " ").trim(); } +function resolveMacAppBundlePath(): string | null { + if (process.platform !== "darwin" || !app.isPackaged) { + return null; + } + + return Path.resolve(process.execPath, "..", "..", ".."); +} + +function isMacDeveloperIdSignedBuild(): boolean { + if (process.platform !== "darwin" || !app.isPackaged) { + return false; + } + if (macDeveloperIdSigned !== null) { + return macDeveloperIdSigned; + } + + const appBundlePath = resolveMacAppBundlePath(); + if (!appBundlePath) { + macDeveloperIdSigned = false; + return macDeveloperIdSigned; + } + + const result = ChildProcess.spawnSync("codesign", ["--display", "--verbose=4", appBundlePath], { + encoding: "utf8", + }); + const details = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + macDeveloperIdSigned = result.status === 0 && hasDeveloperIdApplicationAuthority(details); + + if (!macDeveloperIdSigned) { + const diagnostic = + result.error?.message || + (result.status === 0 + ? "missing Developer ID Application authority in code signature" + : details || `codesign exited with status ${result.status ?? "unknown"}`); + console.info( + `[desktop-updater] macOS code-signing check indicates manual install fallback will be used: ${sanitizeLogValue(diagnostic)}`, + ); + } + + return macDeveloperIdSigned; +} + function writeDesktopLogHeader(message: string): void { if (!desktopLogSink) return; desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); @@ -133,6 +183,14 @@ function formatErrorMessage(error: unknown): string { return String(error); } +function setDownloadedUpdateFiles(files: ReadonlyArray): void { + downloadedUpdateFiles = [...files]; +} + +function clearDownloadedUpdateFiles(): void { + downloadedUpdateFiles = []; +} + function getSafeExternalUrl(rawUrl: unknown): string | null { if (typeof rawUrl !== "string" || rawUrl.length === 0) { return null; @@ -513,13 +571,7 @@ function dispatchMenuAction(action: string): void { } function handleCheckForUpdatesMenuClick(): void { - const disabledReason = getAutoUpdateDisabledReason({ - isDevelopment, - isPackaged: app.isPackaged, - platform: process.platform, - appImage: process.env.APPIMAGE, - disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", - }); + const disabledReason = resolveAutoUpdateDisabledReason(); if (disabledReason) { console.info("[desktop-updater] Manual update check requested, but updates are disabled."); void dialog.showMessageBox({ @@ -730,16 +782,14 @@ function setUpdateState(patch: Partial): void { emitUpdateState(); } -function shouldEnableAutoUpdates(): boolean { - return ( - getAutoUpdateDisabledReason({ - isDevelopment, - isPackaged: app.isPackaged, - platform: process.platform, - appImage: process.env.APPIMAGE, - disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", - }) === null - ); +function resolveAutoUpdateDisabledReason(): string | null { + return getAutoUpdateDisabledReason({ + isDevelopment, + isPackaged: app.isPackaged, + platform: process.platform, + appImage: process.env.APPIMAGE, + disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", + }); } async function checkForUpdates(reason: string): Promise { @@ -777,10 +827,12 @@ async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed console.info("[desktop-updater] Downloading update..."); try { - await autoUpdater.downloadUpdate(); + const downloadedFiles = await autoUpdater.downloadUpdate(); + setDownloadedUpdateFiles(downloadedFiles); return { accepted: true, completed: true }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); + clearDownloadedUpdateFiles(); setUpdateState(reduceDesktopUpdateStateOnDownloadFailure(updateState, message)); console.error(`[desktop-updater] Failed to download update: ${message}`); return { accepted: true, completed: false }; @@ -789,6 +841,54 @@ async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed } } +function installDownloadedUnsignedMacUpdate(): void { + const currentAppPath = resolveMacAppBundlePath(); + if (!currentAppPath) { + throw new Error("Could not resolve the current app bundle path."); + } + + const downloadedZipPath = resolveDownloadedMacUpdateZipPath(downloadedUpdateFiles); + if (!downloadedZipPath) { + throw new Error("Could not locate the downloaded macOS update archive."); + } + + const stagingDir = FS.mkdtempSync(Path.join(OS.tmpdir(), "t3-mac-update-")); + const extractedDir = Path.join(stagingDir, "extracted"); + FS.mkdirSync(extractedDir, { recursive: true }); + + const unzipResult = ChildProcess.spawnSync( + "ditto", + ["-x", "-k", downloadedZipPath, extractedDir], + { + encoding: "utf8", + }, + ); + if (unzipResult.status !== 0) { + const details = `${unzipResult.stdout ?? ""}\n${unzipResult.stderr ?? ""}`.trim(); + throw new Error(details || `ditto exited with status ${unzipResult.status ?? "unknown"}`); + } + + const extractedAppPath = findFirstAppBundlePath(extractedDir); + if (!extractedAppPath) { + throw new Error("Could not find the extracted app bundle inside the downloaded update."); + } + + const installerScriptPath = Path.join(stagingDir, "install-update.sh"); + const installerScript = buildMacManualUpdateInstallScript({ + appPid: process.pid, + sourceAppPath: extractedAppPath, + targetAppPath: currentAppPath, + stagingDir, + }); + FS.writeFileSync(installerScriptPath, installerScript, { mode: 0o700 }); + + const installerProcess = ChildProcess.spawn("/bin/sh", [installerScriptPath], { + detached: true, + stdio: "ignore", + }); + installerProcess.unref(); +} + async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed: boolean }> { if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") { return { accepted: false, completed: false }; @@ -798,7 +898,12 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed clearUpdatePollTimer(); try { await stopBackendAndWaitForExit(); - autoUpdater.quitAndInstall(); + if (process.platform === "darwin" && !isMacDeveloperIdSignedBuild()) { + installDownloadedUnsignedMacUpdate(); + app.quit(); + } else { + autoUpdater.quitAndInstall(); + } return { accepted: true, completed: true }; } catch (error: unknown) { const message = formatErrorMessage(error); @@ -810,13 +915,15 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed } function configureAutoUpdater(): void { - const enabled = shouldEnableAutoUpdates(); + const disabledReason = resolveAutoUpdateDisabledReason(); + const enabled = disabledReason === null; setUpdateState({ ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo), enabled, status: enabled ? "idle" : "disabled", }); if (!enabled) { + console.info(`[desktop-updater] Automatic updates disabled: ${disabledReason}`); return; } updaterConfigured = true; @@ -857,6 +964,7 @@ function configureAutoUpdater(): void { console.info("[desktop-updater] Looking for updates..."); }); autoUpdater.on("update-available", (info) => { + clearDownloadedUpdateFiles(); setUpdateState( reduceDesktopUpdateStateOnUpdateAvailable( updateState, @@ -868,6 +976,7 @@ function configureAutoUpdater(): void { console.info(`[desktop-updater] Update available: ${info.version}`); }); autoUpdater.on("update-not-available", () => { + clearDownloadedUpdateFiles(); setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); lastLoggedDownloadMilestone = -1; console.info("[desktop-updater] No updates available.");