diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index f7a5dfb1e..cdaf1b137 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -128,6 +128,7 @@ export type CommandMappings = { openAboutDialog: CommandData; hideFloatingButtons: {}; hideLeftPane: CommandData; + showRosettaWarning: CommandData; showLeftPane: CommandData; hoistNote: CommandData & { noteId: string }; leaveProtectedSession: CommandData; diff --git a/apps/client/src/desktop.ts b/apps/client/src/desktop.ts index 1a0f7e8a9..e762aa65b 100644 --- a/apps/client/src/desktop.ts +++ b/apps/client/src/desktop.ts @@ -8,6 +8,7 @@ import electronContextMenu from "./menus/electron_context_menu.js"; import glob from "./services/glob.js"; import { t } from "./services/i18n.js"; import options from "./services/options.js"; +import server from "./services/server.js"; import type ElectronRemote from "@electron/remote"; import type Electron from "electron"; import "./stylesheets/bootstrap.scss"; @@ -22,7 +23,10 @@ bundleService.getWidgetBundlesByParent().then(async (widgetBundles) => { const DesktopLayout = (await import("./layouts/desktop_layout.js")).default; appContext.setLayout(new DesktopLayout(widgetBundles)); - appContext.start().catch((e) => { + appContext.start().then(() => { + // Check for Rosetta 2 after the app has fully started + checkRosetta2Warning(); + }).catch((e) => { toastService.showPersistent({ title: t("toast.critical-error.title"), icon: "alert", @@ -114,3 +118,18 @@ function initDarkOrLightMode(style: CSSStyleDeclaration) { const { nativeTheme } = utils.dynamicRequire("@electron/remote") as typeof ElectronRemote; nativeTheme.themeSource = themeSource; } + +async function checkRosetta2Warning() { + if (!utils.isElectron()) return; + + try { + // Check if running under Rosetta 2 by calling the server + const response = await server.get("api/system-info/rosetta-check") as { isRunningUnderRosetta2: boolean }; + if (response.isRunningUnderRosetta2) { + // Trigger the Rosetta 2 warning dialog + appContext.triggerCommand("showRosettaWarning", {}); + } + } catch (error) { + console.warn("Could not check Rosetta 2 status:", error); + } +} diff --git a/apps/client/src/layouts/layout_commons.ts b/apps/client/src/layouts/layout_commons.ts index d9559cde2..9c0716c4f 100644 --- a/apps/client/src/layouts/layout_commons.ts +++ b/apps/client/src/layouts/layout_commons.ts @@ -21,6 +21,7 @@ import ConfirmDialog from "../widgets/dialogs/confirm.js"; import RevisionsDialog from "../widgets/dialogs/revisions.js"; import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js"; import InfoDialog from "../widgets/dialogs/info.js"; +import RosettaWarningDialog from "../widgets/dialogs/rosetta_warning.js"; export function applyModals(rootContainer: RootContainer) { rootContainer @@ -45,4 +46,5 @@ export function applyModals(rootContainer: RootContainer) { .child(new InfoDialog()) .child(new ConfirmDialog()) .child(new PromptDialog()) + .child(new RosettaWarningDialog()) } diff --git a/apps/client/src/widgets/dialogs/rosetta_warning.ts b/apps/client/src/widgets/dialogs/rosetta_warning.ts new file mode 100644 index 000000000..1cd067869 --- /dev/null +++ b/apps/client/src/widgets/dialogs/rosetta_warning.ts @@ -0,0 +1,84 @@ +import BasicWidget from "../basic_widget.js"; +import { Modal } from "bootstrap"; +import utils from "../../services/utils.js"; + +const TPL = /*html*/` +`; + +export default class RosettaWarningDialog extends BasicWidget { + private modal!: Modal; + private $downloadButton!: JQuery; + private $continueButton!: JQuery; + + doRender() { + this.$widget = $(TPL); + this.modal = Modal.getOrCreateInstance(this.$widget[0]); + this.$downloadButton = this.$widget.find(".download-correct-version-button"); + this.$continueButton = this.$widget.find(".continue-anyway-button"); + + this.$downloadButton.on("click", () => { + // Open the releases page where users can download the correct version + if (utils.isElectron()) { + const { shell } = utils.dynamicRequire("electron"); + shell.openExternal("https://github.com/TriliumNext/Notes/releases/latest"); + } else { + window.open("https://github.com/TriliumNext/Notes/releases/latest", "_blank"); + } + }); + + // Auto-focus the download button when shown + this.$widget.on("shown.bs.modal", () => { + this.$downloadButton.trigger("focus"); + }); + } + + showRosettaWarningEvent() { + this.modal.show(); + } +} diff --git a/apps/server/src/routes/api/system_info.ts b/apps/server/src/routes/api/system_info.ts new file mode 100644 index 000000000..4d49f4ca2 --- /dev/null +++ b/apps/server/src/routes/api/system_info.ts @@ -0,0 +1,12 @@ +import { isRunningUnderRosetta2 } from "../../services/utils.js"; +import type { Request, Response } from "express"; + +function rosettaCheck(req: Request, res: Response) { + res.json({ + isRunningUnderRosetta2: isRunningUnderRosetta2() + }); +} + +export default { + rosettaCheck +}; diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index b988ecb11..eee979859 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -58,6 +58,7 @@ import ollamaRoute from "./api/ollama.js"; import openaiRoute from "./api/openai.js"; import anthropicRoute from "./api/anthropic.js"; import llmRoute from "./api/llm.js"; +import systemInfoRoute from "./api/system_info.js"; import etapiAuthRoutes from "../etapi/auth.js"; import etapiAppInfoRoutes from "../etapi/app_info.js"; @@ -238,6 +239,7 @@ function register(app: express.Application) { apiRoute(PST, "/api/recent-notes", recentNotesRoute.addRecentNote); apiRoute(GET, "/api/app-info", appInfoRoute.getAppInfo); apiRoute(GET, "/api/metrics", metricsRoute.getMetrics); + apiRoute(GET, "/api/system-info/rosetta-check", systemInfoRoute.rosettaCheck); // docker health check route(GET, "/api/health-check", [], () => ({ status: "ok" }), apiResultHandler); diff --git a/apps/server/src/services/utils.ts b/apps/server/src/services/utils.ts index 89aad1bbb..c02a325d8 100644 --- a/apps/server/src/services/utils.ts +++ b/apps/server/src/services/utils.ts @@ -23,6 +23,34 @@ export const isElectron = !!process.versions["electron"]; export const isDev = !!(process.env.TRILIUM_ENV && process.env.TRILIUM_ENV === "dev"); +/** + * Detects if the application is running under Rosetta 2 translation on Apple Silicon. + * This happens when an x64 version of the app is run on an M1/M2/M3 Mac. + * Uses the macOS sysctl.proc_translated to properly detect translation. + * @returns true if running under Rosetta 2, false otherwise + */ +export const isRunningUnderRosetta2 = () => { + if (!isMac) return false; + + try { + // Use child_process to check sysctl.proc_translated + // This is the proper way to detect Rosetta 2 translation + const { execSync } = require("child_process"); + const result = execSync("sysctl -n sysctl.proc_translated 2>/dev/null", { + encoding: "utf8", + timeout: 1000 + }).trim(); + + // 1 means the process is being translated by Rosetta 2 + // 0 means native execution + // If the sysctl doesn't exist (on Intel Macs), this will return empty/error + return result === "1"; + } catch (error) { + // If sysctl fails or doesn't exist (Intel Macs), not running under Rosetta 2 + return false; + } +}; + export function newEntityId() { return randomString(12); } @@ -395,6 +423,7 @@ export default { isElectron, isEmptyOrWhitespace, isMac, + isRunningUnderRosetta2, isStringNote, isWindows, md5,