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.
|
- 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.
|
- For full suite verification, push to CI and check GitHub Actions instead.
|
||||||
- **Always run typecheck and lint after every change.**
|
- **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.
|
- **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:
|
- **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`
|
- `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 { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
|
||||||
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
|
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
|
||||||
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||||
@@ -464,6 +465,7 @@ function AppContainer({
|
|||||||
</View>
|
</View>
|
||||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
|
<RosettaCalloutSource />
|
||||||
<UpdateCalloutSource />
|
<UpdateCalloutSource />
|
||||||
<WorktreeSetupCalloutSource />
|
<WorktreeSetupCalloutSource />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
|
|||||||
@@ -92,4 +92,31 @@ describe("desktop-updates helpers", () => {
|
|||||||
expect(diagnostics).toContain("STDOUT:\nstdout text");
|
expect(diagnostics).toContain("STDOUT:\nstdout text");
|
||||||
expect(diagnostics).toContain("STDERR:\nstderr 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;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopRuntimeInfo {
|
||||||
|
appVersion: string | null;
|
||||||
|
runningUnderARM64Translation: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type DesktopReleaseChannel = "stable" | "beta";
|
export type DesktopReleaseChannel = "stable" | "beta";
|
||||||
|
|
||||||
export interface LocalDaemonUpdateResult {
|
export interface LocalDaemonUpdateResult {
|
||||||
@@ -30,6 +35,8 @@ export interface LocalDaemonVersionResult {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RELEASE_DOWNLOAD_BASE_URL = "https://github.com/getpaseo/paseo/releases/download";
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null;
|
return typeof value === "object" && value !== null;
|
||||||
}
|
}
|
||||||
@@ -71,6 +78,25 @@ export async function getLocalDaemonVersion(): Promise<LocalDaemonVersionResult>
|
|||||||
return parseLocalDaemonVersionResult(result);
|
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({
|
export async function checkDesktopAppUpdate({
|
||||||
releaseChannel,
|
releaseChannel,
|
||||||
}: {
|
}: {
|
||||||
@@ -153,6 +179,15 @@ export function formatVersionWithPrefix(version: string | null | undefined): str
|
|||||||
return value.startsWith("v") ? value : `v${value}`;
|
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 {
|
export function buildDaemonUpdateDiagnostics(result: LocalDaemonUpdateResult): string {
|
||||||
const stdout = result.stdout.length > 0 ? result.stdout : "(empty)";
|
const stdout = result.stdout.length > 0 ? result.stdout : "(empty)";
|
||||||
const stderr = result.stderr.length > 0 ? result.stderr : "(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,
|
type DesktopCommandHandler,
|
||||||
} from "../settings/desktop-settings-commands.js";
|
} from "../settings/desktop-settings-commands.js";
|
||||||
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
|
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
|
||||||
|
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
|
||||||
|
|
||||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||||
const PID_POLL_INTERVAL_MS = 100;
|
const PID_POLL_INTERVAL_MS = 100;
|
||||||
@@ -525,6 +526,10 @@ async function resolveRequestedReleaseChannel(
|
|||||||
export function createDaemonCommandHandlers(): Record<string, DesktopCommandHandler> {
|
export function createDaemonCommandHandlers(): Record<string, DesktopCommandHandler> {
|
||||||
return {
|
return {
|
||||||
...createDesktopSettingsCommandHandlers({ settingsStore: getDesktopSettingsStore() }),
|
...createDesktopSettingsCommandHandlers({ settingsStore: getDesktopSettingsStore() }),
|
||||||
|
desktop_get_runtime_info: () => ({
|
||||||
|
appVersion: resolveDesktopAppVersion(),
|
||||||
|
runningUnderARM64Translation: isRunningUnderARM64Translation(),
|
||||||
|
}),
|
||||||
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
|
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
|
||||||
start_desktop_daemon: () => startDaemon(),
|
start_desktop_daemon: () => startDaemon(),
|
||||||
stop_desktop_daemon: () => stopDesktopDaemon(),
|
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