From ee870b4dce3eaa0f6a5e73a8130d074dfd7c2629 Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Wed, 10 Jun 2026 14:18:30 +0800 Subject: [PATCH] fix(mobile): keep Agent Mode off the emulated-mobile load path (#2587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): keep Agent Mode off the emulated-mobile load path `app.emulateMobile(true)` flips Platform.isMobile to true (stubbing Node's built-ins to null) but leaves Platform.isDesktopApp true — so gating Agent Mode on isDesktopApp still imported the @/agentMode barrel, whose module-scope `promisify(execFile)` then crashed the whole plugin with "Cannot read properties of null (reading 'promisify')". Add isDesktopRuntime() (Platform.isDesktopApp && !Platform.isMobile) and gate every Agent Mode load/registration on it so the barrel is never imported on a Node-less runtime. Update the mobile-load smoke gate rule to enforce the new gate and add a unit test. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(lint): ban Platform.isDesktopApp; route desktop gating through isDesktopRuntime() Platform.isDesktopApp stays true under app.emulateMobile(true) (which stubs Node), so it doesn't keep desktop-only/Node code off the emulated-mobile path. Add a no-restricted-properties ESLint rule banning it (the canonical check in src/utils/desktopRuntime.ts is exempt via an inline disable), and migrate all 14 existing usages — web viewer, encryption/keychain, Miyo discovery, web-tab and @-mention chat hooks, and main.ts's web-viewer gates — to isDesktopRuntime(). Behavior is unchanged on real desktop and mobile; only emulateMobile now faithfully hides these desktop-only features. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- eslint.config.mjs | 16 +++++++++ scripts/mobile-load-smoke.cjs | 18 +++++----- src/commands/index.ts | 11 +++--- .../chat-components/ChatContextMenu.tsx | 5 +-- .../hooks/useActiveWebTabState.ts | 6 ++-- .../hooks/useAtMentionCategories.tsx | 5 +-- .../hooks/useAtMentionSearch.ts | 7 ++-- .../chat-components/hooks/useOpenWebTabs.ts | 5 +-- .../pills/ActiveWebTabPillNode.tsx | 4 +-- .../plugins/SlashCommandPlugin.tsx | 4 +-- src/encryptionService.ts | 3 +- src/main.ts | 25 +++++++------ src/miyo/MiyoServiceDiscovery.ts | 4 +-- .../webViewerService/webViewerService.ts | 4 +-- src/settings/v2/SettingsMainV2.tsx | 6 ++-- .../v2/components/AdvancedSettings.tsx | 9 ++--- src/utils/desktopRuntime.test.ts | 35 +++++++++++++++++++ src/utils/desktopRuntime.ts | 18 ++++++++++ 18 files changed, 133 insertions(+), 52 deletions(-) create mode 100644 src/utils/desktopRuntime.test.ts create mode 100644 src/utils/desktopRuntime.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 28fcfe53..bc506a83 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -119,6 +119,22 @@ export default [ "Don't use the global `app` (footgun in popouts). Thread `app` via useApp() or a parameter. See designdocs/agents/PLUGIN_DEV_GUIDE.md.", }, ], + + // Ban `Platform.isDesktopApp` — it stays true under + // `app.emulateMobile(true)` (which stubs Node's built-ins to null), so + // desktop-only / Node-dependent code still runs there and crashes the + // plugin. Gate on isDesktopRuntime() (desktop app AND not mobile) instead. + // The helper itself (src/utils/desktopRuntime.ts) is exempt via an inline + // disable, since it owns the canonical check. + "no-restricted-properties": [ + "error", + { + object: "Platform", + property: "isDesktopApp", + message: + "Use isDesktopRuntime() from @/utils/desktopRuntime instead. Platform.isDesktopApp stays true under app.emulateMobile(true) (Node stubbed to null), so desktop-only/Node code still runs there and crashes.", + }, + ], }, }, diff --git a/scripts/mobile-load-smoke.cjs b/scripts/mobile-load-smoke.cjs index b35dd7b9..ca13014b 100644 --- a/scripts/mobile-load-smoke.cjs +++ b/scripts/mobile-load-smoke.cjs @@ -96,16 +96,16 @@ function checkAgentModeImportBoundaries() { } } + // Every dynamic Agent Mode import must be gated by `isDesktopRuntime()`, + // NOT a bare `Platform.isDesktopApp`: the latter stays `true` under + // `app.emulateMobile(true)` (which stubs Node to null), so it does not keep the + // `@/agentMode` barrel off the emulated-mobile load path and the plugin crashes. const dynamicImports = Array.from(source.matchAll(dynamicAgentModeImport)); - if (dynamicImports.length > 0) { - if (!source.includes("Platform.isDesktopApp")) { - fail(`${relativePath}: dynamic Agent Mode import must be gated by Platform.isDesktopApp.`); - } - if (source.includes("Platform.isMobile")) { - fail( - `${relativePath}: Agent Mode gates should use Platform.isDesktopApp, not Platform.isMobile.` - ); - } + if (dynamicImports.length > 0 && !source.includes("isDesktopRuntime")) { + fail( + `${relativePath}: dynamic Agent Mode import must be gated by isDesktopRuntime() ` + + `— Platform.isDesktopApp is true under app.emulateMobile(true).` + ); } } } diff --git a/src/commands/index.ts b/src/commands/index.ts index b34dece3..1581a16f 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -27,7 +27,8 @@ import { shouldUseMiyo } from "@/miyo/miyoUtils"; import { getAllQAMarkdownContent } from "@/search/searchUtils"; import { NoteSelectedTextContext, WebSelectedTextContext } from "@/types/message"; import { ensureFolderExists, isSourceModeOn } from "@/utils"; -import { Editor, MarkdownView, Notice, Platform, TFile } from "obsidian"; +import { isDesktopRuntime } from "@/utils/desktopRuntime"; +import { Editor, MarkdownView, Notice, TFile } from "obsidian"; import { v4 as uuidv4 } from "uuid"; import { COMMAND_IDS, COMMAND_ICONS, COMMAND_NAMES, CommandId } from "@/constants"; import { setSelectedTextContexts } from "@/aiParams"; @@ -127,8 +128,9 @@ export function registerCommands(plugin: CopilotPlugin) { }); // Agent Mode is always on, but requires subprocess support — register the - // agent commands on desktop only. - if (Platform.isDesktopApp) { + // agent commands only where the Node runtime exists (real desktop, not + // `emulateMobile`, where importing Agent Mode would crash). + if (isDesktopRuntime()) { addCommand(plugin, COMMAND_IDS.OPEN_AGENT_CHAT_WINDOW, () => { void plugin.activateAgentView(); }); @@ -576,8 +578,7 @@ export function registerCommands(plugin: CopilotPlugin) { // Add web selection to chat context command (manual) addCommand(plugin, COMMAND_IDS.ADD_WEB_SELECTION_TO_CHAT_CONTEXT, async () => { - const { Platform } = await import("obsidian"); - if (!Platform.isDesktopApp) { + if (!isDesktopRuntime()) { new Notice("Web selection is only available on desktop"); return; } diff --git a/src/components/chat-components/ChatContextMenu.tsx b/src/components/chat-components/ChatContextMenu.tsx index d73b0780..fe57b9ca 100644 --- a/src/components/chat-components/ChatContextMenu.tsx +++ b/src/components/chat-components/ChatContextMenu.tsx @@ -1,6 +1,7 @@ import { AlertCircle, CheckCircle, CircleDashed, Loader2 } from "lucide-react"; -import { Platform, TFile, TFolder } from "obsidian"; +import { TFile, TFolder } from "obsidian"; import React, { useRef, useState } from "react"; +import { isDesktopRuntime } from "@/utils/desktopRuntime"; import { Button } from "@/components/ui/button"; import { ContextNoteBadge, @@ -112,7 +113,7 @@ export const ChatContextMenu: React.FC = ({ const activeNoteVisible = includeActiveNote && !hasAnySelection && Boolean(currentActiveFile); const activeWebTabVisible = - includeActiveWebTab && !hasAnySelection && Boolean(activeWebTab) && Platform.isDesktopApp; + includeActiveWebTab && !hasAnySelection && Boolean(activeWebTab) && isDesktopRuntime(); const hasContext = uniqueNotes.length > 0 || diff --git a/src/components/chat-components/hooks/useActiveWebTabState.ts b/src/components/chat-components/hooks/useActiveWebTabState.ts index 27270a1c..93c07c67 100644 --- a/src/components/chat-components/hooks/useActiveWebTabState.ts +++ b/src/components/chat-components/hooks/useActiveWebTabState.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Platform } from "obsidian"; +import { isDesktopRuntime } from "@/utils/desktopRuntime"; import { useApp } from "@/context"; import { getWebViewerService } from "@/services/webViewerService/webViewerServiceSingleton"; @@ -21,7 +21,7 @@ const EMPTY_ACTIVE_WEB_TAB_STATE: ActiveWebTabStateSnapshot = { export function useActiveWebTabState(): ActiveWebTabStateSnapshot { const app = useApp(); const [state, setState] = useState(() => { - if (!Platform.isDesktopApp) { + if (!isDesktopRuntime()) { return EMPTY_ACTIVE_WEB_TAB_STATE; } try { @@ -32,7 +32,7 @@ export function useActiveWebTabState(): ActiveWebTabStateSnapshot { }); useEffect(() => { - if (!Platform.isDesktopApp) { + if (!isDesktopRuntime()) { setState(EMPTY_ACTIVE_WEB_TAB_STATE); return; } diff --git a/src/components/chat-components/hooks/useAtMentionCategories.tsx b/src/components/chat-components/hooks/useAtMentionCategories.tsx index f061e350..fe55c66d 100644 --- a/src/components/chat-components/hooks/useAtMentionCategories.tsx +++ b/src/components/chat-components/hooks/useAtMentionCategories.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from "react"; -import { Platform, TFile, TFolder } from "obsidian"; +import { TFile, TFolder } from "obsidian"; +import { isDesktopRuntime } from "@/utils/desktopRuntime"; import { FileText, Wrench, Folder, Globe, Image } from "lucide-react"; import { TypeaheadOption } from "@/components/chat-components/TypeaheadMenuContent"; import type { WebTabContext } from "@/types/message"; @@ -93,7 +94,7 @@ export function useAtMentionCategories(showTools: boolean = false): CategoryOpti return showTools; } if (cat.category === "webTabs") { - return Platform.isDesktopApp; + return isDesktopRuntime(); } return true; }); diff --git a/src/components/chat-components/hooks/useAtMentionSearch.ts b/src/components/chat-components/hooks/useAtMentionSearch.ts index 55ee5390..2f7a054d 100644 --- a/src/components/chat-components/hooks/useAtMentionSearch.ts +++ b/src/components/chat-components/hooks/useAtMentionSearch.ts @@ -1,5 +1,6 @@ import React, { useMemo } from "react"; -import { Platform, TFolder, TFile } from "obsidian"; +import { TFolder, TFile } from "obsidian"; +import { isDesktopRuntime } from "@/utils/desktopRuntime"; import { FileText, Wrench, Folder, FileClock, Globe, CircleDashed } from "lucide-react"; import fuzzysort from "fuzzysort"; import { getToolDescription } from "@/tools/toolManager"; @@ -34,7 +35,7 @@ export function useAtMentionSearch( // - In category mode with a search query (searching across all categories) // - In search mode when webTabs category is selected const shouldEnableWebTabPolling = - Platform.isDesktopApp && + isDesktopRuntime() && ((mode === "category" && query.trim().length > 0) || (mode === "search" && selectedCategory === "webTabs")); const openWebTabs = useOpenWebTabs({ enabled: shouldEnableWebTabPolling }); @@ -91,7 +92,7 @@ export function useAtMentionSearch( const webTabItems: AtMentionOption[] = useMemo( () => - Platform.isDesktopApp + isDesktopRuntime() ? openWebTabs.map((tab, index) => { const isLoaded = tab.isLoaded !== false; return { diff --git a/src/components/chat-components/hooks/useOpenWebTabs.ts b/src/components/chat-components/hooks/useOpenWebTabs.ts index e8b90697..608fab8c 100644 --- a/src/components/chat-components/hooks/useOpenWebTabs.ts +++ b/src/components/chat-components/hooks/useOpenWebTabs.ts @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; -import { App, Platform } from "obsidian"; +import { App } from "obsidian"; import { useApp } from "@/context"; +import { isDesktopRuntime } from "@/utils/desktopRuntime"; import { getWebViewerService } from "@/services/webViewerService/webViewerServiceSingleton"; import type { WebTabContext } from "@/types/message"; @@ -129,7 +130,7 @@ export function useOpenWebTabs(options: UseOpenWebTabsOptions = {}): WebTabConte } // Web Viewer is desktop-only - if (!Platform.isDesktopApp) { + if (!isDesktopRuntime()) { setTabs([]); return; } diff --git a/src/components/chat-components/pills/ActiveWebTabPillNode.tsx b/src/components/chat-components/pills/ActiveWebTabPillNode.tsx index 713f582e..92cc9dc8 100644 --- a/src/components/chat-components/pills/ActiveWebTabPillNode.tsx +++ b/src/components/chat-components/pills/ActiveWebTabPillNode.tsx @@ -10,7 +10,7 @@ import { $getRoot, } from "lexical"; import { Globe } from "lucide-react"; -import { Platform } from "obsidian"; +import { isDesktopRuntime } from "@/utils/desktopRuntime"; import { ACTIVE_WEB_TAB_MARKER } from "@/constants"; import { BasePillNode, getEditorDocument, SerializedBasePillNode } from "./BasePillNode"; import { TruncatedPillText } from "./TruncatedPillText"; @@ -110,7 +110,7 @@ function ActiveWebTabPillComponent(): JSX.Element { const { activeWebTabForMentions } = useActiveWebTabState(); // Not supported on mobile - if (!Platform.isDesktopApp) { + if (!isDesktopRuntime()) { return (
diff --git a/src/components/chat-components/plugins/SlashCommandPlugin.tsx b/src/components/chat-components/plugins/SlashCommandPlugin.tsx index f759a50d..436e8d78 100644 --- a/src/components/chat-components/plugins/SlashCommandPlugin.tsx +++ b/src/components/chat-components/plugins/SlashCommandPlugin.tsx @@ -2,13 +2,13 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $getSelection, $isRangeSelection, TextNode } from "lexical"; import fuzzysort from "fuzzysort"; -import { Platform } from "obsidian"; import { useCustomCommands } from "@/commands/state"; import { CustomCommandManager } from "@/commands/customCommandManager"; import { sortSlashCommands } from "@/commands/customCommandUtils"; import { logError } from "@/logger"; import { useSettingsValue } from "@/settings/model"; +import { isDesktopRuntime } from "@/utils/desktopRuntime"; import { TypeaheadMenuPortal } from "@/components/chat-components/TypeaheadMenuPortal"; import { TypeaheadOption } from "@/components/chat-components/TypeaheadMenuContent"; import { @@ -40,7 +40,7 @@ function useAgentSlashData(): AgentSlashData { const [data, setData] = useState(EMPTY_AGENT_SLASH_DATA); useEffect(() => { - if (!Platform.isDesktopApp) return; + if (!isDesktopRuntime()) return; let cancelled = false; let unsubscribe: (() => void) | undefined; diff --git a/src/encryptionService.ts b/src/encryptionService.ts index 7ca18847..aa7578bf 100644 --- a/src/encryptionService.ts +++ b/src/encryptionService.ts @@ -22,6 +22,7 @@ // eslint-disable-next-line import/no-nodejs-modules import { Buffer } from "buffer"; import { Platform } from "obsidian"; +import { isDesktopRuntime } from "@/utils/desktopRuntime"; interface SafeStorage { isEncryptionAvailable(): boolean; @@ -37,7 +38,7 @@ let safeStorageInternal: SafeStorage | null = null; * @returns The Electron safeStorage instance, or `null` when unavailable. */ function getSafeStorage(): SafeStorage | null { - if (!Platform.isDesktopApp && !Platform.isDesktop) return null; + if (!isDesktopRuntime() && !Platform.isDesktop) return null; if (safeStorageInternal) return safeStorageInternal; try { // eslint-disable-next-line @typescript-eslint/no-require-imports diff --git a/src/main.ts b/src/main.ts index 574adf3b..079cf02b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -67,6 +67,7 @@ import { } from "@/settings/model"; import { dehydrateDeviceProfile, hydrateDeviceProfile } from "@/settings/deviceProfiles"; import { getDeviceId } from "@/utils/deviceId"; +import { isDesktopRuntime } from "@/utils/desktopRuntime"; import { installRendererEventsShim } from "@/utils/rendererEventsShim"; import { ProjectContextCache } from "@/cache/projectContextCache"; import { ContextProcessor } from "@/contextProcessor"; @@ -88,7 +89,6 @@ import { MarkdownView, Menu, Notice, - Platform, Plugin, TFile, ViewCreator, @@ -240,8 +240,11 @@ export default class CopilotPlugin extends Plugin { // Initialize ProjectManager this.projectManager = ProjectManager.getInstance(this.app, this); - // Initialize Agent Mode coordinator (desktop only — ACP needs subprocess support). - if (Platform.isDesktopApp) { + // Initialize Agent Mode coordinator (desktop only — ACP needs subprocess + // support). Gate on `isDesktopRuntime()`, not `Platform.isDesktopApp`: + // under `app.emulateMobile(true)` the latter stays true while Node is stubbed, + // so importing the `@/agentMode` barrel there would crash the plugin at load. + if (isDesktopRuntime()) { const { CopilotAgentView, PlanPreviewView, @@ -302,7 +305,7 @@ export default class CopilotPlugin extends Plugin { // Single source of truth for Active Web Tab ({activeWebTab}) state // Preserves activeWebTab when switching to Chat view // Only run on desktop - Web Viewer is not available on mobile - if (Platform.isDesktopApp) { + if (isDesktopRuntime()) { const { activeLeafRef, layoutRef } = startActiveWebTabTracking(this.app, { preserveOnViewTypes: [CHAT_VIEWTYPE], }); @@ -320,7 +323,7 @@ export default class CopilotPlugin extends Plugin { (leaf: WorkspaceLeaf) => new RelevantNotesView(leaf, this) ); if ( - Platform.isDesktopApp && + isDesktopRuntime() && this.CopilotAgentView && this.PlanPreviewView && this.planPreviewViewType @@ -470,8 +473,10 @@ export default class CopilotPlugin extends Plugin { this.projectRegister?.cleanup(); this.settingsUnsubscriber?.(); - // Tear down skills vault watchers + debounce timers. - if (Platform.isDesktopApp) { + // Tear down skills vault watchers + debounce timers. Gate matches onload so + // we never import the `@/agentMode` barrel on a Node-less runtime (mobile / + // emulateMobile), which would crash during unload. + if (isDesktopRuntime()) { const { SkillManager } = await import("@/agentMode"); if (SkillManager.hasInstance()) { SkillManager.getInstance().dispose(); @@ -738,7 +743,7 @@ export default class CopilotPlugin extends Plugin { */ initWebSelectionWatcher() { // Only run on desktop - if (!Platform.isDesktopApp) { + if (!isDesktopRuntime()) { return; } @@ -951,7 +956,7 @@ export default class CopilotPlugin extends Plugin { } private requireAgentView(): AgentSessionManager | null { - if (!Platform.isDesktopApp) { + if (!isDesktopRuntime()) { new Notice("Agent Chat is not available on mobile."); return null; } @@ -963,7 +968,7 @@ export default class CopilotPlugin extends Plugin { } private canUseAgentView(): boolean { - return !!this.agentSessionManager && Platform.isDesktopApp; + return !!this.agentSessionManager && isDesktopRuntime(); } private isCopilotAgentView( diff --git a/src/miyo/MiyoServiceDiscovery.ts b/src/miyo/MiyoServiceDiscovery.ts index d638c236..effa8bd5 100644 --- a/src/miyo/MiyoServiceDiscovery.ts +++ b/src/miyo/MiyoServiceDiscovery.ts @@ -1,7 +1,7 @@ import { logInfo, logWarn } from "@/logger"; import { getSettings } from "@/settings/model"; import { err2String } from "@/utils"; -import { Platform } from "obsidian"; +import { isDesktopRuntime } from "@/utils/desktopRuntime"; /** * Service discovery payload for Miyo local service. @@ -55,7 +55,7 @@ export class MiyoServiceDiscovery { return this.cachedBaseUrl; } - if (!Platform.isDesktopApp) { + if (!isDesktopRuntime()) { return null; } diff --git a/src/services/webViewerService/webViewerService.ts b/src/services/webViewerService/webViewerService.ts index 8e77bd01..517b993e 100644 --- a/src/services/webViewerService/webViewerService.ts +++ b/src/services/webViewerService/webViewerService.ts @@ -8,9 +8,9 @@ */ import type { App } from "obsidian"; -import { Platform } from "obsidian"; import { logError, logWarn } from "@/logger"; +import { isDesktopRuntime } from "@/utils/desktopRuntime"; import * as actions from "@/services/webViewerService/webViewerServiceActions"; import { getCommandManager, @@ -73,7 +73,7 @@ export class WebViewerService { /** Check if the current platform supports Web Viewer (desktop only). */ isSupportedPlatform(): boolean { - return Platform.isDesktopApp === true; + return isDesktopRuntime(); } /** Get detailed Web Viewer availability information. */ diff --git a/src/settings/v2/SettingsMainV2.tsx b/src/settings/v2/SettingsMainV2.tsx index 724457ad..ffdd25bb 100644 --- a/src/settings/v2/SettingsMainV2.tsx +++ b/src/settings/v2/SettingsMainV2.tsx @@ -9,8 +9,8 @@ import { ByokPanel, ModelManagementProvider } from "@/modelManagement"; import { resetSettings } from "@/settings/model"; import { CommandSettings } from "@/settings/v2/components/CommandSettings"; import { Bot, Cog, Command, Cpu, Database, Sparkle, Sparkles, Wrench } from "lucide-react"; -import { Platform } from "obsidian"; import React from "react"; +import { isDesktopRuntime } from "@/utils/desktopRuntime"; import { AdvancedSettings } from "./components/AdvancedSettings"; import { BasicSettings } from "./components/BasicSettings"; import { CopilotPlusSettings } from "./components/CopilotPlusSettings"; @@ -33,7 +33,7 @@ const DesktopOnlySettingsPanel: React.FC = () => ( ); const AgentSettingsPanel: React.FC = () => { - if (!Platform.isDesktopApp) return ; + if (!isDesktopRuntime()) return ; return ( @@ -42,7 +42,7 @@ const AgentSettingsPanel: React.FC = () => { }; const SkillsSettingsPanel: React.FC = () => { - if (!Platform.isDesktopApp) return ; + if (!isDesktopRuntime()) return ; return ( diff --git a/src/settings/v2/components/AdvancedSettings.tsx b/src/settings/v2/components/AdvancedSettings.tsx index 424aeb33..1571074b 100644 --- a/src/settings/v2/components/AdvancedSettings.tsx +++ b/src/settings/v2/components/AdvancedSettings.tsx @@ -25,8 +25,9 @@ import { import { ArrowUpRight, Info, Plus, ShieldCheck, Trash2, Unlock } from "lucide-react"; import { ConfirmModal } from "@/components/modals/ConfirmModal"; import { MigrateConfirmModal } from "@/components/modals/MigrateConfirmModal"; -import { type App, Notice, Platform } from "obsidian"; +import { type App, Notice } from "obsidian"; import React, { useCallback, useEffect, useState } from "react"; +import { isDesktopRuntime } from "@/utils/desktopRuntime"; import { getPromptFilePath, SystemPromptAddModal } from "@/system-prompts"; import { useSystemPrompts } from "@/system-prompts/state"; @@ -62,7 +63,7 @@ export const AdvancedSettings: React.FC = () => { const [frameLogPath, setFrameLogPath] = useState(DESKTOP_UNAVAILABLE_FRAME_LOG_PATH); useEffect(() => { - if (!Platform.isDesktopApp) return; + if (!isDesktopRuntime()) return; let cancelled = false; void import("@/agentMode").then(({ acpFrameSink }) => { @@ -491,7 +492,7 @@ export const AdvancedSettings: React.FC = () => { variant="secondary" size="sm" onClick={async () => { - if (!Platform.isDesktopApp) { + if (!isDesktopRuntime()) { new Notice("Agent Mode frame logs are available on desktop only."); return; } @@ -510,7 +511,7 @@ export const AdvancedSettings: React.FC = () => { variant="secondary" size="sm" onClick={async () => { - if (!Platform.isDesktopApp) { + if (!isDesktopRuntime()) { new Notice("Agent Mode frame logs are available on desktop only."); return; } diff --git a/src/utils/desktopRuntime.test.ts b/src/utils/desktopRuntime.test.ts new file mode 100644 index 00000000..8511beb1 --- /dev/null +++ b/src/utils/desktopRuntime.test.ts @@ -0,0 +1,35 @@ +jest.mock("obsidian", () => ({ Platform: { isDesktopApp: false, isMobile: false } })); + +import { isDesktopRuntime } from "./desktopRuntime"; + +const obsidian: { Platform: { isDesktopApp: boolean; isMobile: boolean } } = + jest.requireMock("obsidian"); + +function setPlatform(isDesktopApp: boolean, isMobile: boolean): void { + obsidian.Platform.isDesktopApp = isDesktopApp; + obsidian.Platform.isMobile = isMobile; +} + +describe("isDesktopRuntime", () => { + it("is true on the real desktop app", () => { + setPlatform(true, false); + expect(isDesktopRuntime()).toBe(true); + }); + + it("is false under app.emulateMobile(true) — isDesktopApp stays true but isMobile flips", () => { + // The bug this guards: gating only on Platform.isDesktopApp let desktop-only + // code load under emulateMobile (where Node is stubbed), crashing the plugin. + setPlatform(true, true); + expect(isDesktopRuntime()).toBe(false); + }); + + it("is false on real mobile", () => { + setPlatform(false, true); + expect(isDesktopRuntime()).toBe(false); + }); + + it("is false on any non-desktop runtime", () => { + setPlatform(false, false); + expect(isDesktopRuntime()).toBe(false); + }); +}); diff --git a/src/utils/desktopRuntime.ts b/src/utils/desktopRuntime.ts new file mode 100644 index 00000000..6cad0c1f --- /dev/null +++ b/src/utils/desktopRuntime.ts @@ -0,0 +1,18 @@ +import { Platform } from "obsidian"; + +/** + * True only in a real desktop (Electron) app — an environment with Node and + * subprocess support. Use this to gate any desktop-only feature (Agent Mode, + * Node built-ins, the web viewer, etc.). + * + * `Platform.isDesktopApp` alone is NOT sufficient: `app.emulateMobile(true)` + * keeps `isDesktopApp === true` (you're still in the Electron binary) but stubs + * Node's built-in modules to `null` to mimic mobile, so desktop-only code that + * runs there crashes. The flag that flips under emulation *and* on real mobile + * is `Platform.isMobile`, so the correct check is "desktop app AND not + * (emulated-)mobile". + */ +export function isDesktopRuntime(): boolean { + // eslint-disable-next-line no-restricted-properties -- this helper owns the canonical check + return Platform.isDesktopApp && !Platform.isMobile; +}