mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
* fix(mobile): shim `process` so the bundle survives module-eval on mobile Follow-up to #2576 (preview issue #125). After the EventEmitter fix, mobile now crashes at load with "ReferenceError: Can't find variable: process". Cause: Agent Mode is desktop-only but its modules are statically imported, so they're evaluated on every platform. Several read the Node global `process` at module scope (e.g. `InstallCommandRow.tsx`'s `const DEFAULT_LABEL = process.platform === "win32" ? ...`, the codex/claude/opencode descriptors), and bundled Node deps read `process.env` at init. Mobile's WebView has no `process`, so evaluating any of them throws during import and kills the plugin. Fix: prepend a minimal `process` shim in the esbuild banner — `var process = globalThis.process || { env:{}, platform:"", ... }`. Desktop (Electron) has a real `globalThis.process` and uses it unchanged; mobile gets the stub, so module evaluation no longer throws. The desktop-only code paths that actually use `process` never run on mobile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(test-vault): copy-deploy into iCloud-synced vaults for mobile testing `npm run test:vault` symlinks main.js/styles.css by default, but iCloud syncs file contents, not link targets, so a symlinked build never reaches the phone. Detect iCloud vaults (path under "/Mobile Documents/") and copy real files instead, so deploying to an iCloud vault makes the build testable on Obsidian mobile. Mirrors the existing WSL copy-mode rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mobile): keep agent mode off load path * test(mobile): add load smoke check * fix(mobile): share agent chat mode constant --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
370 lines
8.6 KiB
JavaScript
370 lines
8.6 KiB
JavaScript
"use strict";
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const vm = require("node:vm");
|
|
|
|
const repoRoot = path.resolve(__dirname, "..");
|
|
const failures = [];
|
|
|
|
const loadCriticalFiles = [
|
|
"src/main.ts",
|
|
"src/commands/index.ts",
|
|
"src/settings/SettingsPage.tsx",
|
|
"src/settings/v2/SettingsMainV2.tsx",
|
|
"src/settings/v2/components/AdvancedSettings.tsx",
|
|
"src/components/chat-components/plugins/SlashCommandPlugin.tsx",
|
|
"src/components/chat-components/plugins/slashMenuItems.ts",
|
|
];
|
|
|
|
const nodeModuleIds = new Set([
|
|
"async_hooks",
|
|
"buffer",
|
|
"child_process",
|
|
"crypto",
|
|
"electron",
|
|
"events",
|
|
"fs",
|
|
"fs/promises",
|
|
"module",
|
|
"os",
|
|
"path",
|
|
"process",
|
|
"readline",
|
|
"stream",
|
|
"url",
|
|
"util",
|
|
"node:async_hooks",
|
|
"node:buffer",
|
|
"node:child_process",
|
|
"node:crypto",
|
|
"node:events",
|
|
"node:fs",
|
|
"node:fs/promises",
|
|
"node:module",
|
|
"node:os",
|
|
"node:path",
|
|
"node:process",
|
|
"node:readline",
|
|
"node:stream",
|
|
"node:url",
|
|
"node:util",
|
|
]);
|
|
|
|
function readRepoFile(relativePath) {
|
|
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
|
}
|
|
|
|
function fail(message) {
|
|
failures.push(message);
|
|
}
|
|
|
|
function formatError(error) {
|
|
if (!(error instanceof Error)) return String(error);
|
|
|
|
const stackLines = (error.stack ?? "")
|
|
.split("\n")
|
|
.filter((line) => !line.includes("main.js:1:"))
|
|
.slice(0, 6);
|
|
|
|
return [`${error.name}: ${error.message}`, ...stackLines.slice(1)].join("\n");
|
|
}
|
|
|
|
function isTypeOnlyImport(importStatement) {
|
|
if (/^import\s+type\b/.test(importStatement.trim())) return true;
|
|
const named = importStatement.match(/import\s*\{([^}]*)\}\s*from/);
|
|
if (!named) return false;
|
|
const specifiers = named[1]
|
|
.split(",")
|
|
.map((part) => part.trim())
|
|
.filter(Boolean);
|
|
return specifiers.length > 0 && specifiers.every((part) => part.startsWith("type "));
|
|
}
|
|
|
|
function checkAgentModeImportBoundaries() {
|
|
const staticAgentModeImport =
|
|
/import\s+(?:type\s+)?[^;]+?\s+from\s+["']@\/agentMode(?:\/[^"']*)?["']\s*;?/g;
|
|
const dynamicAgentModeImport = /import\s*\(\s*["']@\/agentMode(?:\/[^"']*)?["']\s*\)/g;
|
|
|
|
for (const relativePath of loadCriticalFiles) {
|
|
const source = readRepoFile(relativePath);
|
|
|
|
for (const match of source.matchAll(staticAgentModeImport)) {
|
|
const statement = match[0];
|
|
if (!isTypeOnlyImport(statement)) {
|
|
fail(`${relativePath}: value import from Agent Mode is on the mobile load path.`);
|
|
}
|
|
}
|
|
|
|
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.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function createCallableStub(name) {
|
|
function Stub() {}
|
|
Object.defineProperty(Stub, "name", { value: name.replace(/[^A-Za-z0-9_$]/g, "_") || "Stub" });
|
|
return new Proxy(Stub, {
|
|
apply() {
|
|
return undefined;
|
|
},
|
|
construct() {
|
|
return {};
|
|
},
|
|
get(target, prop) {
|
|
if (prop === "prototype") return target.prototype;
|
|
if (prop === Symbol.toStringTag) return name;
|
|
return createCallableStub(`${name}.${String(prop)}`);
|
|
},
|
|
});
|
|
}
|
|
|
|
function createPoisonModule(id) {
|
|
return new Proxy(
|
|
{},
|
|
{
|
|
get(_target, prop) {
|
|
if (prop === Symbol.toStringTag) return id;
|
|
throw new Error(
|
|
`mobile-load-smoke: desktop-only module '${id}' was accessed at '${String(prop)}'.`
|
|
);
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
function isObsidianBrowserExternal(id) {
|
|
return id.startsWith("@codemirror/") || id.startsWith("@lezer/");
|
|
}
|
|
|
|
function createObsidianStub() {
|
|
class Component {
|
|
registerEvent() {}
|
|
registerDomEvent() {}
|
|
registerInterval() {}
|
|
load() {}
|
|
unload() {}
|
|
}
|
|
|
|
class Plugin extends Component {
|
|
constructor(app, manifest) {
|
|
super();
|
|
this.app = app;
|
|
this.manifest = manifest ?? { id: "copilot", version: "mobile-load-smoke" };
|
|
}
|
|
|
|
addCommand() {}
|
|
addRibbonIcon() {
|
|
return { addClass() {}, removeClass() {}, setAttribute() {} };
|
|
}
|
|
addSettingTab() {}
|
|
loadData() {
|
|
return Promise.resolve(null);
|
|
}
|
|
saveData() {
|
|
return Promise.resolve();
|
|
}
|
|
registerView() {}
|
|
registerEditorExtension() {}
|
|
}
|
|
|
|
class PluginSettingTab {
|
|
constructor(app, plugin) {
|
|
this.app = app;
|
|
this.plugin = plugin;
|
|
this.containerEl = { empty() {}, addClass() {}, createDiv: () => ({}) };
|
|
}
|
|
}
|
|
|
|
class ItemView extends Component {
|
|
constructor(leaf) {
|
|
super();
|
|
this.leaf = leaf;
|
|
this.containerEl = {};
|
|
this.contentEl = {};
|
|
}
|
|
}
|
|
|
|
class Modal extends Component {
|
|
constructor(app) {
|
|
super();
|
|
this.app = app;
|
|
}
|
|
open() {}
|
|
close() {}
|
|
}
|
|
|
|
class Notice {
|
|
constructor() {}
|
|
}
|
|
|
|
class TFile {}
|
|
class TFolder {}
|
|
class FileSystemAdapter {}
|
|
class MarkdownView {}
|
|
|
|
const obsidian = {
|
|
App: class App {},
|
|
Component,
|
|
FileSystemAdapter,
|
|
ItemView,
|
|
MarkdownView,
|
|
Modal,
|
|
Notice,
|
|
Platform: Object.freeze({
|
|
isDesktop: false,
|
|
isDesktopApp: false,
|
|
isMobile: true,
|
|
isMobileApp: true,
|
|
isMacOS: false,
|
|
isPhone: true,
|
|
isTablet: false,
|
|
isWin: false,
|
|
}),
|
|
Plugin,
|
|
PluginSettingTab,
|
|
TFile,
|
|
TFolder,
|
|
WorkspaceLeaf: class WorkspaceLeaf {},
|
|
MarkdownRenderer: { render: async () => {} },
|
|
addIcon() {},
|
|
debounce(fn) {
|
|
return fn;
|
|
},
|
|
moment: () => ({ format: () => "" }),
|
|
normalizePath(value) {
|
|
return String(value).replace(/\\/g, "/");
|
|
},
|
|
parseYaml() {
|
|
return {};
|
|
},
|
|
requestUrl: async () => ({ json: {}, text: "", status: 200 }),
|
|
stringifyYaml() {
|
|
return "";
|
|
},
|
|
};
|
|
|
|
return new Proxy(obsidian, {
|
|
get(target, prop) {
|
|
if (prop in target) return target[prop];
|
|
return createCallableStub(`obsidian.${String(prop)}`);
|
|
},
|
|
});
|
|
}
|
|
|
|
class SmokeEvent {
|
|
constructor(type, init = {}) {
|
|
this.type = type;
|
|
Object.assign(this, init);
|
|
}
|
|
}
|
|
|
|
class SmokeCustomEvent extends SmokeEvent {
|
|
constructor(type, init = {}) {
|
|
super(type, init);
|
|
this.detail = init.detail;
|
|
}
|
|
}
|
|
|
|
class SmokeEventTarget {
|
|
addEventListener() {}
|
|
removeEventListener() {}
|
|
dispatchEvent() {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function runBundleEvaluationSmoke() {
|
|
const bundlePath = path.join(repoRoot, "main.js");
|
|
if (!fs.existsSync(bundlePath)) {
|
|
fail("main.js is missing. Run npm run build before the mobile-load smoke test.");
|
|
return;
|
|
}
|
|
|
|
const source = fs.readFileSync(bundlePath, "utf8");
|
|
const module = { exports: {} };
|
|
const context = {
|
|
AbortController,
|
|
clearInterval,
|
|
clearTimeout,
|
|
console,
|
|
crypto: {
|
|
getRandomValues(array) {
|
|
array.fill(7);
|
|
return array;
|
|
},
|
|
randomUUID() {
|
|
return "00000000-0000-4000-8000-000000000000";
|
|
},
|
|
},
|
|
CustomEvent: SmokeCustomEvent,
|
|
Event: SmokeEvent,
|
|
EventTarget: SmokeEventTarget,
|
|
fetch: async () => ({ ok: true, json: async () => ({}), text: async () => "", body: null }),
|
|
Blob,
|
|
module,
|
|
exports: module.exports,
|
|
FormData,
|
|
Headers,
|
|
navigator: { userAgent: "ObsidianMobileSmoke/1.0" },
|
|
Promise,
|
|
queueMicrotask,
|
|
ReadableStream,
|
|
Request,
|
|
Response,
|
|
require(id) {
|
|
if (id === "obsidian") return createObsidianStub();
|
|
if (isObsidianBrowserExternal(id)) return createCallableStub(id);
|
|
if (nodeModuleIds.has(id)) return createPoisonModule(id);
|
|
throw new Error(`mobile-load-smoke: unexpected external require '${id}'.`);
|
|
},
|
|
setInterval,
|
|
setTimeout,
|
|
TextDecoder,
|
|
TextEncoder,
|
|
TransformStream,
|
|
URL,
|
|
URLSearchParams,
|
|
WritableStream,
|
|
};
|
|
context.globalThis = context;
|
|
context.self = context;
|
|
context.window = context;
|
|
|
|
try {
|
|
vm.runInNewContext(source, context, {
|
|
filename: "main.js",
|
|
timeout: 5000,
|
|
});
|
|
} catch (error) {
|
|
fail(`main.js failed mobile bundle evaluation: ${formatError(error)}`);
|
|
return;
|
|
}
|
|
|
|
const pluginExport = module.exports.default ?? module.exports;
|
|
if (typeof pluginExport !== "function") {
|
|
fail("main.js did not export the plugin class.");
|
|
}
|
|
}
|
|
|
|
checkAgentModeImportBoundaries();
|
|
runBundleEvaluationSmoke();
|
|
|
|
if (failures.length > 0) {
|
|
console.error("Mobile load smoke test failed:");
|
|
for (const failure of failures) {
|
|
console.error(`- ${failure}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("Mobile load smoke test passed.");
|