diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index f7a5dfb1e6..3c7873e8b6 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -28,6 +28,7 @@ import type { NativeImage, TouchBar } from "electron"; import TouchBarComponent from "./touch_bar.js"; import type { CKTextEditor } from "@triliumnext/ckeditor5"; import type CodeMirror from "@triliumnext/codemirror"; +import { StartupChecks } from "./startup_checks.js"; interface Layout { getRootWidget: (appContext: AppContext) => RootWidget; @@ -128,6 +129,7 @@ export type CommandMappings = { openAboutDialog: CommandData; hideFloatingButtons: {}; hideLeftPane: CommandData; + showCpuArchWarning: CommandData; showLeftPane: CommandData; hoistNote: CommandData & { noteId: string }; leaveProtectedSession: CommandData; @@ -473,7 +475,14 @@ export class AppContext extends Component { initComponents() { this.tabManager = new TabManager(); - this.components = [this.tabManager, new RootCommandExecutor(), new Entrypoints(), new MainTreeExecutors(), new ShortcutComponent()]; + this.components = [ + this.tabManager, + new RootCommandExecutor(), + new Entrypoints(), + new MainTreeExecutors(), + new ShortcutComponent(), + new StartupChecks() + ]; if (utils.isMobile()) { this.components.push(new MobileScreenSwitcherExecutor()); diff --git a/apps/client/src/components/startup_checks.ts b/apps/client/src/components/startup_checks.ts new file mode 100644 index 0000000000..b320d7e134 --- /dev/null +++ b/apps/client/src/components/startup_checks.ts @@ -0,0 +1,26 @@ +import server from "../services/server"; +import Component from "./component"; + +// TODO: Deduplicate. +interface CpuArchResponse { + isCpuArchMismatch: boolean; +} + +export class StartupChecks extends Component { + + constructor() { + super(); + this.checkCpuArchMismatch(); + } + + async checkCpuArchMismatch() { + try { + const response = await server.get("system-checks") as CpuArchResponse; + if (response.isCpuArchMismatch) { + this.triggerCommand("showCpuArchWarning", {}); + } + } catch (error) { + console.warn("Could not check CPU arch status:", error); + } + } +} diff --git a/apps/client/src/desktop.ts b/apps/client/src/desktop.ts index 1a0f7e8a99..65e2e285a4 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"; diff --git a/apps/client/src/layouts/layout_commons.ts b/apps/client/src/layouts/layout_commons.ts index d9559cde22..e538398391 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 IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.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 IncorrectCpuArchDialog()) } diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 4601580559..ab7933617f 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1918,5 +1918,14 @@ "title": "Appearance", "word_wrapping": "Word wrapping", "color-scheme": "Color scheme" + }, + "cpu_arch_warning": { + "title": "Please download the ARM64 version", + "message_macos": "TriliumNext is currently running under Rosetta 2 translation, which means you're using the Intel (x64) version on Apple Silicon Mac. This will significantly impact performance and battery life.", + "message_windows": "TriliumNext is currently running emulation, which means you're using the Intel (x64) version on a Windows on ARM device. This will significantly impact performance and battery life.", + "recommendation": "For the best experience, please download the native ARM64 version of TriliumNext from our releases page.", + "download_link": "Download Native Version", + "continue_anyway": "Continue Anyway", + "dont_show_again": "Don't show this warning again" } } diff --git a/apps/client/src/widgets/dialogs/incorrect_cpu_arch.ts b/apps/client/src/widgets/dialogs/incorrect_cpu_arch.ts new file mode 100644 index 0000000000..8e30060fec --- /dev/null +++ b/apps/client/src/widgets/dialogs/incorrect_cpu_arch.ts @@ -0,0 +1,59 @@ +import BasicWidget from "../basic_widget.js"; +import { Modal } from "bootstrap"; +import utils from "../../services/utils.js"; +import { t } from "../../services/i18n.js"; + +const TPL = /*html*/` +`; + +export default class IncorrectCpuArchDialog extends BasicWidget { + private modal!: Modal; + private $downloadButton!: JQuery; + + doRender() { + this.$widget = $(TPL); + this.modal = Modal.getOrCreateInstance(this.$widget[0]); + this.$downloadButton = this.$widget.find(".download-correct-version-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"); + }); + } + + showCpuArchWarningEvent() { + 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 0000000000..32c7f91f7f --- /dev/null +++ b/apps/server/src/routes/api/system_info.ts @@ -0,0 +1,45 @@ +import { execSync } from "child_process"; +import { isMac, isWindows } from "../../services/utils"; +import { arch, cpus } from "os"; + +function systemChecks() { + return { + isCpuArchMismatch: isCpuArchMismatch() + } +} + +/** + * Detects if the application is running under emulation on Apple Silicon or Windows on ARM. + * This happens when an x64 version of the app is run on an M1/M2/M3 Mac or on a Windows Snapdragon chip. + * @returns true if running on x86 emulation on ARM, false otherwise. + */ +export const isCpuArchMismatch = () => { + if (isMac) { + try { + // Use child_process to check sysctl.proc_translated + // This is the proper way to detect Rosetta 2 translation + 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; + } + } else if (isWindows && arch() === "x64") { + return cpus().some(cpu => + cpu.model.includes('Microsoft SQ') || + cpu.model.includes('Snapdragon')); + } else { + return false; + } +}; + +export default { + systemChecks +}; diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index b988ecb114..6b984aed4a 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-checks", systemInfoRoute.systemChecks); // docker health check route(GET, "/api/health-check", [], () => ({ status: "ok" }), apiResultHandler);