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
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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({
|
||||
</View>
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
<WorktreeSetupCalloutSource />
|
||||
<CommandCenter />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -71,6 +78,25 @@ export async function getLocalDaemonVersion(): Promise<LocalDaemonVersionResult>
|
||||
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<DesktopRuntimeInfo> {
|
||||
const result = await invokeDesktopCommand<unknown>("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)";
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<SidebarCalloutDescriptionText>
|
||||
You're running the Intel build of Paseo under Rosetta on Apple Silicon.
|
||||
</SidebarCalloutDescriptionText>
|
||||
<SidebarCalloutDescriptionText>
|
||||
This causes high CPU usage. Download the Apple Silicon build to fix it.
|
||||
</SidebarCalloutDescriptionText>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RosettaCalloutSource() {
|
||||
const callouts = useSidebarCallouts();
|
||||
const [runtimeInfo, setRuntimeInfo] = useState<DesktopRuntimeInfo | null>(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: <RosettaCalloutDescription />,
|
||||
variant: "error",
|
||||
dismissible: false,
|
||||
actions: [
|
||||
{
|
||||
label: "Download",
|
||||
onPress: openDownload,
|
||||
variant: "primary",
|
||||
},
|
||||
],
|
||||
testID: "rosetta-callout",
|
||||
});
|
||||
}, [callouts, isElectronMac, openDownload, runtimeInfo]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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<string, DesktopCommandHandler> {
|
||||
return {
|
||||
...createDesktopSettingsCommandHandlers({ settingsStore: getDesktopSettingsStore() }),
|
||||
desktop_get_runtime_info: () => ({
|
||||
appVersion: resolveDesktopAppVersion(),
|
||||
runningUnderARM64Translation: isRunningUnderARM64Translation(),
|
||||
}),
|
||||
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
|
||||
start_desktop_daemon: () => startDaemon(),
|
||||
stop_desktop_daemon: () => stopDesktopDaemon(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user