Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
reduceDesktopUpdateStateOnNoUpdate,
reduceDesktopUpdateStateOnUpdateAvailable,
} from "./updateMachine";
import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";

fixPath();

Expand Down Expand Up @@ -80,7 +81,13 @@ let backendLogSink: RotatingFileSink | null = null;
let restoreStdIoCapture: (() => void) | null = null;

let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
const initialUpdateState = (): DesktopUpdateState => createInitialDesktopUpdateState(app.getVersion());
const desktopRuntimeInfo = resolveDesktopRuntimeInfo({
platform: process.platform,
processArch: process.arch,
runningUnderArm64Translation: app.runningUnderARM64Translation === true,
});
const initialUpdateState = (): DesktopUpdateState =>
createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo);

function logTimestamp(): string {
return new Date().toISOString();
Expand Down Expand Up @@ -708,6 +715,7 @@ async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed
}
updateDownloadInFlight = true;
setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState));
autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);
console.info("[desktop-updater] Downloading update...");

try {
Expand Down Expand Up @@ -746,7 +754,7 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed
function configureAutoUpdater(): void {
const enabled = shouldEnableAutoUpdates();
setUpdateState({
...createInitialDesktopUpdateState(app.getVersion()),
...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo),
enabled,
status: enabled ? "idle" : "disabled",
});
Expand Down Expand Up @@ -780,8 +788,15 @@ function configureAutoUpdater(): void {
autoUpdater.channel = DESKTOP_UPDATE_CHANNEL;
autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE;
autoUpdater.allowDowngrade = false;
autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);
let lastLoggedDownloadMilestone = -1;

if (isArm64HostRunningIntelBuild(desktopRuntimeInfo)) {
console.info(
"[desktop-updater] Apple Silicon host detected while running Intel build; updates will switch to arm64 packages.",
);
}

autoUpdater.on("checking-for-update", () => {
console.info("[desktop-updater] Looking for updates...");
});
Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/src/runtimeArch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";

import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";

describe("resolveDesktopRuntimeInfo", () => {
it("detects Rosetta-translated Intel builds on Apple Silicon", () => {
const runtimeInfo = resolveDesktopRuntimeInfo({
platform: "darwin",
processArch: "x64",
runningUnderArm64Translation: true,
});

expect(runtimeInfo).toEqual({
hostArch: "arm64",
appArch: "x64",
runningUnderArm64Translation: true,
});
expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(true);
});

it("detects native Apple Silicon builds", () => {
const runtimeInfo = resolveDesktopRuntimeInfo({
platform: "darwin",
processArch: "arm64",
runningUnderArm64Translation: false,
});

expect(runtimeInfo).toEqual({
hostArch: "arm64",
appArch: "arm64",
runningUnderArm64Translation: false,
});
expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(false);
});

it("passes through non-mac builds without translation", () => {
const runtimeInfo = resolveDesktopRuntimeInfo({
platform: "linux",
processArch: "x64",
runningUnderArm64Translation: true,
});

expect(runtimeInfo).toEqual({
hostArch: "x64",
appArch: "x64",
runningUnderArm64Translation: false,
});
});
});
40 changes: 40 additions & 0 deletions apps/desktop/src/runtimeArch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts";

interface ResolveDesktopRuntimeInfoInput {
readonly platform: NodeJS.Platform;
readonly processArch: string;
readonly runningUnderArm64Translation: boolean;
}

function normalizeDesktopArch(arch: string): DesktopRuntimeArch {
if (arch === "arm64") return "arm64";
if (arch === "x64") return "x64";
return "other";
}

export function resolveDesktopRuntimeInfo(
input: ResolveDesktopRuntimeInfoInput,
): DesktopRuntimeInfo {
const appArch = normalizeDesktopArch(input.processArch);

if (input.platform !== "darwin") {
return {
hostArch: appArch,
appArch,
runningUnderArm64Translation: false,
};
}

const hostArch =
appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch;

return {
hostArch,
appArch,
runningUnderArm64Translation: input.runningUnderArm64Translation,
};
}

export function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean {
return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64";
}
18 changes: 12 additions & 6 deletions apps/desktop/src/updateMachine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@ import {
reduceDesktopUpdateStateOnUpdateAvailable,
} from "./updateMachine";

const runtimeInfo = {
hostArch: "x64",
appArch: "x64",
runningUnderArm64Translation: false,
} as const;

describe("updateMachine", () => {
it("clears transient errors when a check starts", () => {
const state = reduceDesktopUpdateStateOnCheckStart(
{
...createInitialDesktopUpdateState("1.0.0"),
...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
enabled: true,
status: "error",
message: "network",
Expand All @@ -36,7 +42,7 @@ describe("updateMachine", () => {
it("records a check failure without exposing an action", () => {
const state = reduceDesktopUpdateStateOnCheckFailure(
{
...createInitialDesktopUpdateState("1.0.0"),
...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
enabled: true,
status: "checking",
},
Expand All @@ -52,7 +58,7 @@ describe("updateMachine", () => {
it("preserves available version on download failure for retry", () => {
const state = reduceDesktopUpdateStateOnDownloadFailure(
{
...createInitialDesktopUpdateState("1.0.0"),
...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
enabled: true,
status: "downloading",
availableVersion: "1.1.0",
Expand All @@ -70,7 +76,7 @@ describe("updateMachine", () => {
it("transitions to downloaded and then preserves install retry state", () => {
const downloaded = reduceDesktopUpdateStateOnDownloadComplete(
{
...createInitialDesktopUpdateState("1.0.0"),
...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
enabled: true,
status: "downloading",
availableVersion: "1.1.0",
Expand All @@ -89,7 +95,7 @@ describe("updateMachine", () => {
it("clears stale download state when no update is available", () => {
const state = reduceDesktopUpdateStateOnNoUpdate(
{
...createInitialDesktopUpdateState("1.0.0"),
...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
enabled: true,
status: "error",
availableVersion: "1.1.0",
Expand All @@ -111,7 +117,7 @@ describe("updateMachine", () => {
it("tracks available, download start, and progress cleanly", () => {
const available = reduceDesktopUpdateStateOnUpdateAvailable(
{
...createInitialDesktopUpdateState("1.0.0"),
...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
enabled: true,
status: "checking",
},
Expand Down
11 changes: 8 additions & 3 deletions apps/desktop/src/updateMachine.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import type { DesktopUpdateState } from "@t3tools/contracts";
import type { DesktopRuntimeInfo, DesktopUpdateState } from "@t3tools/contracts";

import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState";

export function createInitialDesktopUpdateState(currentVersion: string): DesktopUpdateState {
export function createInitialDesktopUpdateState(
currentVersion: string,
runtimeInfo: DesktopRuntimeInfo,
): DesktopUpdateState {
return {
enabled: false,
status: "disabled",
currentVersion,
hostArch: runtimeInfo.hostArch,
appArch: runtimeInfo.appArch,
runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation,
availableVersion: null,
downloadedVersion: null,
downloadPercent: null,
Expand Down Expand Up @@ -152,4 +158,3 @@ export function reduceDesktopUpdateStateOnInstallFailure(
canRetry: true,
};
}

3 changes: 3 additions & 0 deletions apps/desktop/src/updateState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const baseState: DesktopUpdateState = {
enabled: true,
status: "idle",
currentVersion: "1.0.0",
hostArch: "x64",
appArch: "x64",
runningUnderArm64Translation: false,
availableVersion: null,
downloadedVersion: null,
downloadPercent: null,
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
SettingsIcon,
SquarePenIcon,
TerminalIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Expand Down Expand Up @@ -36,14 +37,18 @@ import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraft
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
import { toastManager } from "./ui/toast";
import {
getArm64IntelBuildWarningDescription,
getDesktopUpdateActionError,
getDesktopUpdateButtonTooltip,
isDesktopUpdateButtonDisabled,
resolveDesktopUpdateButtonAction,
shouldShowArm64IntelBuildWarning,
shouldHighlightDesktopUpdateError,
shouldShowDesktopUpdateButton,
shouldToastDesktopUpdateActionResult,
} from "./desktopUpdate.logic";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import {
Expand Down Expand Up @@ -887,6 +892,12 @@ export default function Sidebar() {
const desktopUpdateButtonAction = desktopUpdateState
? resolveDesktopUpdateButtonAction(desktopUpdateState)
: "none";
const showArm64IntelBuildWarning =
isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState);
const arm64IntelBuildWarningDescription =
desktopUpdateState && showArm64IntelBuildWarning
? getArm64IntelBuildWarningDescription(desktopUpdateState)
: null;
const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled
? "cursor-not-allowed opacity-60"
: "hover:bg-accent hover:text-foreground";
Expand Down Expand Up @@ -1030,6 +1041,29 @@ export default function Sidebar() {
)}

<SidebarContent className="gap-0">
{showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
<SidebarGroup className="px-2 pt-2 pb-0">
<Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
<TriangleAlertIcon />
<AlertTitle>Intel build on Apple Silicon</AlertTitle>
<AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription>
{desktopUpdateButtonAction !== "none" ? (
<AlertAction>
<Button
size="xs"
variant="outline"
disabled={desktopUpdateButtonDisabled}
onClick={handleDesktopUpdateButtonClick}
>
{desktopUpdateButtonAction === "download"
? "Download ARM build"
: "Install ARM build"}
</Button>
</AlertAction>
) : null}
</Alert>
</SidebarGroup>
) : null}
<SidebarGroup className="px-2 py-2">
<div className="mb-1 flex items-center justify-between px-2">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/desktopUpdate.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { describe, expect, it } from "vitest";
import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";

import {
getArm64IntelBuildWarningDescription,
getDesktopUpdateActionError,
getDesktopUpdateButtonTooltip,
isDesktopUpdateButtonDisabled,
resolveDesktopUpdateButtonAction,
shouldHighlightDesktopUpdateError,
shouldShowArm64IntelBuildWarning,
shouldShowDesktopUpdateButton,
shouldToastDesktopUpdateActionResult,
} from "./desktopUpdate.logic";
Expand All @@ -15,6 +17,9 @@ const baseState: DesktopUpdateState = {
enabled: true,
status: "idle",
currentVersion: "1.0.0",
hostArch: "x64",
appArch: "x64",
runningUnderArm64Translation: false,
availableVersion: null,
downloadedVersion: null,
downloadPercent: null,
Expand Down Expand Up @@ -171,4 +176,30 @@ describe("desktop update UI helpers", () => {
}),
).toBe(false);
});

it("shows an Apple Silicon warning for Intel builds under Rosetta", () => {
const state: DesktopUpdateState = {
...baseState,
hostArch: "arm64",
appArch: "x64",
runningUnderArm64Translation: true,
};

expect(shouldShowArm64IntelBuildWarning(state)).toBe(true);
expect(getArm64IntelBuildWarningDescription(state)).toContain("Apple Silicon");
expect(getArm64IntelBuildWarningDescription(state)).toContain("Intel build");
});

it("changes the warning copy when a native build update is ready to download", () => {
const state: DesktopUpdateState = {
...baseState,
hostArch: "arm64",
appArch: "x64",
runningUnderArm64Translation: true,
status: "available",
availableVersion: "1.1.0",
};

expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update");
});
});
Loading