fix(mobile): keep Agent Mode off the emulated-mobile load path (#2587)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-06-10 14:18:30 +08:00 committed by GitHub
parent e962c79f60
commit ee870b4dce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 133 additions and 52 deletions

View file

@ -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.",
},
],
},
},

View file

@ -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).`
);
}
}
}

View file

@ -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;
}

View file

@ -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<ChatContextMenuProps> = ({
const activeNoteVisible = includeActiveNote && !hasAnySelection && Boolean(currentActiveFile);
const activeWebTabVisible =
includeActiveWebTab && !hasAnySelection && Boolean(activeWebTab) && Platform.isDesktopApp;
includeActiveWebTab && !hasAnySelection && Boolean(activeWebTab) && isDesktopRuntime();
const hasContext =
uniqueNotes.length > 0 ||

View file

@ -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<ActiveWebTabStateSnapshot>(() => {
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;
}

View file

@ -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;
});

View file

@ -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 {

View file

@ -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;
}

View file

@ -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 (
<PillBadge>
<div className="tw-flex tw-items-center tw-gap-1">

View file

@ -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<AgentSlashData>(EMPTY_AGENT_SLASH_DATA);
useEffect(() => {
if (!Platform.isDesktopApp) return;
if (!isDesktopRuntime()) return;
let cancelled = false;
let unsubscribe: (() => void) | undefined;

View file

@ -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

View file

@ -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(

View file

@ -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;
}

View file

@ -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. */

View file

@ -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 <DesktopOnlySettingsPanel />;
if (!isDesktopRuntime()) return <DesktopOnlySettingsPanel />;
return (
<React.Suspense fallback={null}>
<LazyAgentSettings />
@ -42,7 +42,7 @@ const AgentSettingsPanel: React.FC = () => {
};
const SkillsSettingsPanel: React.FC = () => {
if (!Platform.isDesktopApp) return <DesktopOnlySettingsPanel />;
if (!isDesktopRuntime()) return <DesktopOnlySettingsPanel />;
return (
<React.Suspense fallback={null}>
<LazySkillsSettings />

View file

@ -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;
}

View file

@ -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);
});
});

View file

@ -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;
}