From 6a61066e2cb4967701b92e120908cf356dda4e74 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 30 Apr 2026 13:13:30 +0800 Subject: [PATCH] fix(desktop): warn about Rosetta installs on Apple Silicon (#621) * fix(desktop): warn about Rosetta installs on Apple Silicon * docs: note workspace build sync before type fixes --- CLAUDE.md | 3 + packages/app/src/app/_layout.tsx | 2 + .../desktop/updates/desktop-updates.test.ts | 27 ++++++ .../src/desktop/updates/desktop-updates.ts | 35 ++++++++ .../updates/rosetta-callout-source.tsx | 86 +++++++++++++++++++ packages/desktop/src/daemon/daemon-manager.ts | 5 ++ .../src/system/arm64-translation.test.ts | 59 +++++++++++++ .../desktop/src/system/arm64-translation.ts | 44 ++++++++++ 8 files changed, 261 insertions(+) create mode 100644 packages/app/src/desktop/updates/rosetta-callout-source.tsx create mode 100644 packages/desktop/src/system/arm64-translation.test.ts create mode 100644 packages/desktop/src/system/arm64-translation.ts diff --git a/CLAUDE.md b/CLAUDE.md index e636e47da..cbdfafda5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,9 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir - Never re-run a test suite that another agent already ran and reported green — trust the result. - For full suite verification, push to CI and check GitHub Actions instead. - **Always run typecheck and lint after every change.** +- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace (especially CLI depending on server/daemon types), rebuild the owning package first so `dist` declarations are current: + - `npm run build:daemon` — rebuild highlight, relay, server, and CLI when daemon/server/CLI types may be stale. + - Do not patch inferred callback parameters or add local duplicate types just to silence stale declaration errors. - **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it. - **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script: - `npm run lint -- packages/app/src/components/message.tsx` diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index db3d96602..c51fb8a67 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -55,6 +55,7 @@ import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { listenToDesktopEvent } from "@/desktop/electron/events"; import { updateDesktopWindowControls } from "@/desktop/electron/window"; import { getDesktopHost } from "@/desktop/host"; +import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source"; import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source"; import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action"; import { useColorScheme } from "@/hooks/use-color-scheme"; @@ -464,6 +465,7 @@ function AppContainer({ {isCompactLayout && chromeEnabled && } + diff --git a/packages/app/src/desktop/updates/desktop-updates.test.ts b/packages/app/src/desktop/updates/desktop-updates.test.ts index dc7eb3783..c8db260c6 100644 --- a/packages/app/src/desktop/updates/desktop-updates.test.ts +++ b/packages/app/src/desktop/updates/desktop-updates.test.ts @@ -92,4 +92,31 @@ describe("desktop-updates helpers", () => { expect(diagnostics).toContain("STDOUT:\nstdout text"); expect(diagnostics).toContain("STDERR:\nstderr text"); }); + + it("parses runtime info defensively", async () => { + const { parseDesktopRuntimeInfo } = await loadModuleForPlatform("web"); + + expect( + parseDesktopRuntimeInfo({ + appVersion: " 0.1.64 ", + runningUnderARM64Translation: true, + }), + ).toEqual({ + appVersion: "0.1.64", + runningUnderARM64Translation: true, + }); + expect(parseDesktopRuntimeInfo(null)).toEqual({ + appVersion: null, + runningUnderARM64Translation: false, + }); + }); + + it("builds the direct Apple Silicon DMG URL from a version", async () => { + const { buildMacAppleSiliconDownloadUrl } = await loadModuleForPlatform("web"); + + expect(buildMacAppleSiliconDownloadUrl("v0.1.64")).toBe( + "https://github.com/getpaseo/paseo/releases/download/v0.1.64/Paseo-0.1.64-arm64.dmg", + ); + expect(buildMacAppleSiliconDownloadUrl(null)).toBeNull(); + }); }); diff --git a/packages/app/src/desktop/updates/desktop-updates.ts b/packages/app/src/desktop/updates/desktop-updates.ts index 4216b5b48..966e5c72d 100644 --- a/packages/app/src/desktop/updates/desktop-updates.ts +++ b/packages/app/src/desktop/updates/desktop-updates.ts @@ -17,6 +17,11 @@ export interface DesktopAppUpdateInstallResult { message: string; } +export interface DesktopRuntimeInfo { + appVersion: string | null; + runningUnderARM64Translation: boolean; +} + export type DesktopReleaseChannel = "stable" | "beta"; export interface LocalDaemonUpdateResult { @@ -30,6 +35,8 @@ export interface LocalDaemonVersionResult { error: string | null; } +const RELEASE_DOWNLOAD_BASE_URL = "https://github.com/getpaseo/paseo/releases/download"; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -71,6 +78,25 @@ export async function getLocalDaemonVersion(): Promise return parseLocalDaemonVersionResult(result); } +export function parseDesktopRuntimeInfo(raw: unknown): DesktopRuntimeInfo { + if (!isRecord(raw)) { + return { + appVersion: null, + runningUnderARM64Translation: false, + }; + } + + return { + appVersion: toStringOrNull(raw.appVersion), + runningUnderARM64Translation: raw.runningUnderARM64Translation === true, + }; +} + +export async function getDesktopRuntimeInfo(): Promise { + const result = await invokeDesktopCommand("desktop_get_runtime_info"); + return parseDesktopRuntimeInfo(result); +} + export async function checkDesktopAppUpdate({ releaseChannel, }: { @@ -153,6 +179,15 @@ export function formatVersionWithPrefix(version: string | null | undefined): str return value.startsWith("v") ? value : `v${value}`; } +export function buildMacAppleSiliconDownloadUrl(version: string | null | undefined): string | null { + const normalizedVersion = normalizeVersionForComparison(version); + if (!normalizedVersion) { + return null; + } + + return `${RELEASE_DOWNLOAD_BASE_URL}/v${normalizedVersion}/Paseo-${normalizedVersion}-arm64.dmg`; +} + export function buildDaemonUpdateDiagnostics(result: LocalDaemonUpdateResult): string { const stdout = result.stdout.length > 0 ? result.stdout : "(empty)"; const stderr = result.stderr.length > 0 ? result.stderr : "(empty)"; diff --git a/packages/app/src/desktop/updates/rosetta-callout-source.tsx b/packages/app/src/desktop/updates/rosetta-callout-source.tsx new file mode 100644 index 000000000..b059f1605 --- /dev/null +++ b/packages/app/src/desktop/updates/rosetta-callout-source.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; +import { SidebarCalloutDescriptionText } from "@/components/sidebar-callout"; +import { getIsElectronMac } from "@/constants/platform"; +import { useSidebarCallouts } from "@/contexts/sidebar-callout-context"; +import { + buildMacAppleSiliconDownloadUrl, + getDesktopRuntimeInfo, + type DesktopRuntimeInfo, +} from "@/desktop/updates/desktop-updates"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { openExternalUrl } from "@/utils/open-external-url"; + +const FALLBACK_DOWNLOAD_URL = "https://paseo.sh/download"; + +function RosettaCalloutDescription() { + return ( + <> + + You're running the Intel build of Paseo under Rosetta on Apple Silicon. + + + This causes high CPU usage. Download the Apple Silicon build to fix it. + + + ); +} + +export function RosettaCalloutSource() { + const callouts = useSidebarCallouts(); + const [runtimeInfo, setRuntimeInfo] = useState(null); + const isElectronMac = getIsElectronMac(); + + const openDownload = useStableEvent(() => { + const downloadUrl = + buildMacAppleSiliconDownloadUrl(runtimeInfo?.appVersion) ?? FALLBACK_DOWNLOAD_URL; + void openExternalUrl(downloadUrl); + }); + + useEffect(() => { + if (!isElectronMac) { + return; + } + + let cancelled = false; + + void getDesktopRuntimeInfo() + .then((nextRuntimeInfo) => { + if (!cancelled) { + setRuntimeInfo(nextRuntimeInfo); + } + return nextRuntimeInfo; + }) + .catch((error) => { + console.warn("[RosettaCallout] Failed to load desktop runtime info", error); + }); + + return () => { + cancelled = true; + }; + }, [isElectronMac]); + + useEffect(() => { + if (!isElectronMac || runtimeInfo?.runningUnderARM64Translation !== true) { + return; + } + + return callouts.show({ + id: "desktop-rosetta-warning", + priority: 300, + title: "Download the Apple Silicon build", + description: , + variant: "error", + dismissible: false, + actions: [ + { + label: "Download", + onPress: openDownload, + variant: "primary", + }, + ], + testID: "rosetta-callout", + }); + }, [callouts, isElectronMac, openDownload, runtimeInfo]); + + return null; +} diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index a8bbe98cb..93008ed27 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -38,6 +38,7 @@ import { type DesktopCommandHandler, } from "../settings/desktop-settings-commands.js"; import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js"; +import { isRunningUnderARM64Translation } from "../system/arm64-translation.js"; const DAEMON_LOG_FILENAME = "daemon.log"; const PID_POLL_INTERVAL_MS = 100; @@ -525,6 +526,10 @@ async function resolveRequestedReleaseChannel( export function createDaemonCommandHandlers(): Record { return { ...createDesktopSettingsCommandHandlers({ settingsStore: getDesktopSettingsStore() }), + desktop_get_runtime_info: () => ({ + appVersion: resolveDesktopAppVersion(), + runningUnderARM64Translation: isRunningUnderARM64Translation(), + }), desktop_daemon_status: () => resolveDesktopDaemonStatus(), start_desktop_daemon: () => startDaemon(), stop_desktop_daemon: () => stopDesktopDaemon(), diff --git a/packages/desktop/src/system/arm64-translation.test.ts b/packages/desktop/src/system/arm64-translation.test.ts new file mode 100644 index 000000000..d48c0ec7b --- /dev/null +++ b/packages/desktop/src/system/arm64-translation.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; +import { detectRunningUnderARM64Translation } from "./arm64-translation"; + +describe("detectRunningUnderARM64Translation", () => { + it("returns false outside macOS without probing sysctl", () => { + const execFileSyncImpl = vi.fn(); + + expect( + detectRunningUnderARM64Translation({ + platform: "linux", + electronReportedTranslation: true, + execFileSyncImpl, + }), + ).toBe(false); + expect(execFileSyncImpl).not.toHaveBeenCalled(); + }); + + it("trusts Electron when it reports ARM64 translation", () => { + const execFileSyncImpl = vi.fn(); + + expect( + detectRunningUnderARM64Translation({ + platform: "darwin", + electronReportedTranslation: true, + execFileSyncImpl, + }), + ).toBe(true); + expect(execFileSyncImpl).not.toHaveBeenCalled(); + }); + + it("falls back to sysctl when Electron does not report translation", () => { + const execFileSyncImpl = vi.fn(() => "1\n"); + + expect( + detectRunningUnderARM64Translation({ + platform: "darwin", + electronReportedTranslation: false, + execFileSyncImpl, + }), + ).toBe(true); + expect(execFileSyncImpl).toHaveBeenCalledWith("sysctl", ["-in", "sysctl.proc_translated"], { + encoding: "utf-8", + timeout: 1000, + }); + }); + + it("treats missing sysctl support as not translated", () => { + const execFileSyncImpl = vi.fn(() => { + throw new Error("unknown oid"); + }); + + expect( + detectRunningUnderARM64Translation({ + platform: "darwin", + execFileSyncImpl, + }), + ).toBe(false); + }); +}); diff --git a/packages/desktop/src/system/arm64-translation.ts b/packages/desktop/src/system/arm64-translation.ts new file mode 100644 index 000000000..1451627af --- /dev/null +++ b/packages/desktop/src/system/arm64-translation.ts @@ -0,0 +1,44 @@ +import { execFileSync } from "node:child_process"; +import { app } from "electron"; + +const SYSCTL_TRANSLATED_KEY = "sysctl.proc_translated"; + +let cachedRunningUnderARM64Translation: boolean | null = null; + +export function detectRunningUnderARM64Translation(input: { + platform: NodeJS.Platform; + electronReportedTranslation?: boolean; + execFileSyncImpl?: typeof execFileSync; +}): boolean { + if (input.platform !== "darwin") { + return false; + } + + if (input.electronReportedTranslation === true) { + return true; + } + + const execImpl = input.execFileSyncImpl ?? execFileSync; + + try { + const output = execImpl("sysctl", ["-in", SYSCTL_TRANSLATED_KEY], { + encoding: "utf-8", + timeout: 1000, + }); + return output.trim() === "1"; + } catch { + return false; + } +} + +export function isRunningUnderARM64Translation(): boolean { + if (cachedRunningUnderARM64Translation !== null) { + return cachedRunningUnderARM64Translation; + } + + cachedRunningUnderARM64Translation = detectRunningUnderARM64Translation({ + platform: process.platform, + electronReportedTranslation: app.runningUnderARM64Translation, + }); + return cachedRunningUnderARM64Translation; +}