const { app, BrowserWindow, dialog } = require("electron"); const path = require("path"); const fs = require("fs"); const net = require("net"); /** * 桌面端目标(M0):让你回来后可直接安装并运行(不依赖 pnpm / 不要求客户端 Docker)。 * * 说明: * - 这里采用“内置 Next standalone 服务 + Electron 壳”的方式。 * - Next 的 standalone 产物由 `pnpm run build:desktop:next` 生成,并在打包时放入 * `process.resourcesPath\\desktop-next`。 * - 运行期数据放在安装目录下的 `data\\`(满足“非 C 盘且可读写”要求)。 */ function isPackaged() { return app.isPackaged; } function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); } function readJson(filePath) { try { const raw = fs.readFileSync(filePath, { encoding: "utf8" }); return JSON.parse(raw); } catch { return null; } } function writeJson(filePath, value) { fs.writeFileSync(filePath, JSON.stringify(value, null, 2), { encoding: "utf8" }); } function normalizeSecret(raw) { const trimmed = String(raw || "").trim(); if (!trimmed) return ""; if ( (trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) || (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2) ) { return trimmed.slice(1, -1).trim(); } return trimmed; } function getDefaultDataDir() { // 安装后:<安装目录>\MNOTE.exe -> <安装目录>\data if (isPackaged()) { return path.join(path.dirname(process.execPath), "data"); } // 开发态:仓库根目录\data-local return path.join(process.cwd(), "data-local"); } function getDesktopNextDir() { if (isPackaged()) { return path.join(process.resourcesPath, "desktop-next"); } // 开发态:仓库内的准备产物目录 return path.join(process.cwd(), "desktop-electron", "desktop-next"); } function normalizeHttpUrl(input) { const raw = String(input || "").trim(); if (!raw) return ""; return raw.replace(/\/+$/, ""); } function getTcpTargetFromUrl(urlString) { try { const u = new URL(urlString); if (u.protocol !== "http:" && u.protocol !== "https:") return null; const port = Number(u.port) || (u.protocol === "https:" ? 443 : 80); return { host: u.hostname, port }; } catch { return null; } } async function checkTcpOpen(host, port, timeoutMs = 900) { return await new Promise((resolve) => { const socket = net.createConnection({ host, port }); const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs); socket.once("connect", () => { clearTimeout(timer); socket.end(); resolve(true); }); socket.once("error", () => { clearTimeout(timer); resolve(false); }); }); } async function checkUrlReachable(urlString, timeoutMs = 900) { const target = getTcpTargetFromUrl(urlString); if (!target) return false; return await checkTcpOpen(target.host, target.port, timeoutMs); } async function applyDesktopEnvFromConfig(dataDir, logger) { const cfgDir = path.join(dataDir, "electron"); ensureDir(cfgDir); const cfgPath = path.join(cfgDir, "config.json"); // 兼容:用户可能会误以为配置在“安装目录/electron/config.json”或“安装目录/config.json”。 // 为了让配置更“可发现”,这里按“最近修改的文件优先”来选择配置来源。 const installDir = isPackaged() ? path.dirname(process.execPath) : process.cwd(); const altCfgPaths = [ path.join(installDir, "electron", "config.json"), path.join(installDir, "config.json"), ]; const candidateCfgPaths = [cfgPath, ...altCfgPaths]; const pickLatestExistingConfigPath = () => { const existingPaths = candidateCfgPaths.filter((p) => fs.existsSync(p)); if (existingPaths.length === 0) return cfgPath; const withMtime = existingPaths.map((p) => { try { return { p, mtimeMs: fs.statSync(p).mtimeMs }; } catch { return { p, mtimeMs: 0 }; } }); withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs); return withMtime[0]?.p ?? cfgPath; }; const effectiveCfgPath = pickLatestExistingConfigPath(); const JWT_SECRET_PLACEHOLDER = "change-me-to-strong-secret"; // 公共环境文件(桌面端与网页端共用):wolai-frontend/public/mnote-env.json // 说明:打包后该文件会被复制到 resources/desktop-next/public/mnote-env.json const publicEnvCandidates = [ path.join(getDesktopNextDir(), "public", "mnote-env.json"), path.join(process.cwd(), "wolai-frontend", "public", "mnote-env.json"), ]; const publicEnvPath = publicEnvCandidates.find((p) => fs.existsSync(p)); const publicEnv = publicEnvPath ? readJson(publicEnvPath) : null; if (publicEnvPath) { logger.info(`公共环境文件:${publicEnvPath}`); } const defaults = { // networkMode: "remote-client" | "auto" | "local" | "tunnel" // remote-client:桌面端默认按“远程客户端”行为:Supabase/Backend 强制走 Tunnel;OnlyOffice 优先本地,没有再走 Tunnel。 networkMode: "remote-client", // 说明:当需要走 Tunnel(例如远程测试机器上没有本地服务)时使用。 cloudflareAppOrigin: normalizeHttpUrl(publicEnv?.cloudflareAppOrigin) || "https://frp-dry.com:16630", // 说明:桌面端默认强制走 Tunnel(远程客户端),避免使用 127.0.0.1 等本机地址导致远程不可达。 supabaseUrl: normalizeHttpUrl(publicEnv?.supabaseUrl) || "https://frp-dry.com:38877", supabaseUrlTunnel: normalizeHttpUrl(publicEnv?.supabaseUrl) || "https://frp-dry.com:38877", // 注意:anon key 是公开的(浏览器端也会用),这里存储是为了桌面端可改成其它服务端地址。 supabaseAnonKey: String(publicEnv?.supabaseAnonKey || "").trim() || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc2NDExNTA0OSwiZXhwIjoyMDc5NDc1MDQ5fQ.18ohcQZXVkoR1TIF56QWJxHyoVnA9aarH-XfTyBJn1Y", backendUrl: normalizeHttpUrl(publicEnv?.backendUrl) || "https://frp-dry.com:44399", backendUrlTunnel: normalizeHttpUrl(publicEnv?.backendUrl) || "https://frp-dry.com:44399", onlyofficeBaseUrl: normalizeHttpUrl(publicEnv?.onlyofficeBaseUrlDesktop) || "http://localhost:8081", onlyofficeBaseUrlTunnel: normalizeHttpUrl(publicEnv?.onlyofficeBaseUrlWeb) || "https://frp-dry.com:54284", onlyofficeStorageHostOverride: String(publicEnv?.onlyofficeStorageHostOverrideDesktop || "").trim(), onlyofficeStorageHostOverrideTunnel: String(publicEnv?.onlyofficeStorageHostOverrideWeb || "").trim(), onlyofficeProxyOriginTunnel: normalizeHttpUrl(publicEnv?.onlyofficeProxyOriginWeb) || normalizeHttpUrl(publicEnv?.cloudflareAppOrigin) || "https://frp-dry.com:16630", // 说明:如果你的 ONLYOFFICE 容器开启了 JWT,这里必须与容器里一致。 // 默认不启用(当前 onlyoffice-dev 容器通常是 JWT_ENABLED=false) onlyofficeJwtSecret: "", // 可选:AI 工具相关的内部服务(本地优先,失败走 Tunnel) lightragUrl: "http://127.0.0.1:7777", // 说明:当前默认不提供公网 LightRAG 隧道,避免误用旧域名导致不可用 lightragUrlTunnel: "", mineruEndpoint: "http://127.0.0.1:18888", // 说明:当前默认不提供公网 MinerU 隧道,避免误用旧域名导致不可用 mineruEndpointTunnel: "", searxngBaseUrl: "http://127.0.0.1:8889", searxngBaseUrlTunnel: "", // 说明:如果你对公网 searxng 开了 token,可在这里填;不填则按公开实例访问 searxngApiToken: "", }; logger.info(`桌面端配置候选:${candidateCfgPaths.join(" | ")}`); logger.info(`桌面端配置来源:${effectiveCfgPath}`); const existing = fs.existsSync(effectiveCfgPath) ? readJson(effectiveCfgPath) : null; const cfg = { ...defaults, ...(existing && typeof existing === "object" ? existing : {}) }; // 兼容旧配置:如果用户 config.json 里把 key 置空,会导致 Supabase 返回 “No API key found in request” if (!String(cfg.supabaseAnonKey || "").trim()) { cfg.supabaseAnonKey = defaults.supabaseAnonKey; } if (!String(cfg.supabaseUrl || "").trim()) { cfg.supabaseUrl = defaults.supabaseUrl; } if (!String(cfg.supabaseUrlTunnel || "").trim()) { cfg.supabaseUrlTunnel = defaults.supabaseUrlTunnel; } if (!existing) { // 仅在不存在任何可用配置时,才写入默认配置到标准路径(data/electron/config.json) writeJson(cfgPath, cfg); } logger.info(`加载桌面端配置:${existing ? effectiveCfgPath : cfgPath}`); const modeRaw = String(cfg.networkMode || "").toLowerCase(); const mode = modeRaw && modeRaw !== "auto" ? modeRaw : "remote-client"; const localSupabaseUrl = normalizeHttpUrl(cfg.supabaseUrl); const tunnelSupabaseUrl = normalizeHttpUrl(cfg.supabaseUrlTunnel); const localOnlyofficeBaseUrl = normalizeHttpUrl(cfg.onlyofficeBaseUrl); const tunnelOnlyofficeBaseUrl = normalizeHttpUrl(cfg.onlyofficeBaseUrlTunnel); const localOnlyofficeStorageHostOverride = String(cfg.onlyofficeStorageHostOverride || "").trim(); const tunnelOnlyofficeStorageHostOverride = String(cfg.onlyofficeStorageHostOverrideTunnel || "").trim(); const localBackendUrl = normalizeHttpUrl(cfg.backendUrl); const tunnelBackendUrl = normalizeHttpUrl(cfg.backendUrlTunnel); const localLightragUrl = normalizeHttpUrl(cfg.lightragUrl); const tunnelLightragUrl = normalizeHttpUrl(cfg.lightragUrlTunnel); const localMineruEndpoint = normalizeHttpUrl(cfg.mineruEndpoint); const tunnelMineruEndpoint = normalizeHttpUrl(cfg.mineruEndpointTunnel); const localSearxngBaseUrl = normalizeHttpUrl(cfg.searxngBaseUrl); const tunnelSearxngBaseUrl = normalizeHttpUrl(cfg.searxngBaseUrlTunnel); let effectiveSupabaseUrl = localSupabaseUrl; let effectiveBackendUrl = localBackendUrl; let effectiveOnlyofficeBaseUrl = localOnlyofficeBaseUrl; let effectiveOnlyofficeStorageHostOverride = localOnlyofficeStorageHostOverride; let effectiveOnlyofficeProxyOrigin = ""; let onlyofficeModeLabel = "local"; if (mode === "remote-client") { // 远程客户端默认:Supabase/Backend 强制走 Tunnel。 effectiveSupabaseUrl = tunnelSupabaseUrl || localSupabaseUrl; effectiveBackendUrl = tunnelBackendUrl || localBackendUrl; // OnlyOffice:优先本地,本地不可达再用 Tunnel。 const localOnlyofficeOk = await checkUrlReachable(localOnlyofficeBaseUrl); const useTunnelForOnlyoffice = !localOnlyofficeOk && !!tunnelOnlyofficeBaseUrl; onlyofficeModeLabel = useTunnelForOnlyoffice ? "tunnel" : "local"; effectiveOnlyofficeBaseUrl = useTunnelForOnlyoffice ? tunnelOnlyofficeBaseUrl : localOnlyofficeBaseUrl; // 关键:在“远程客户端 + 本地 OnlyOffice”的组合里,必须禁用 storageHostOverride, // 否则会把 https://supabase.<域名> 的地址强行改写成 host.docker.internal,导致拉取失败。 effectiveOnlyofficeStorageHostOverride = useTunnelForOnlyoffice ? tunnelOnlyofficeStorageHostOverride : ""; effectiveOnlyofficeProxyOrigin = useTunnelForOnlyoffice ? normalizeHttpUrl(cfg.onlyofficeProxyOriginTunnel || cfg.cloudflareAppOrigin) : ""; } else { // 兼容旧模式:auto/local/tunnel // 关键一致性:ONLYOFFICE 与 Supabase 必须处于同一“可达网络域”,否则文档服务器无法拉取文件。 const localSupabaseOk = await checkUrlReachable(localSupabaseUrl); const localOnlyofficeOk = await checkUrlReachable(localOnlyofficeBaseUrl); const useTunnelForDoc = mode === "tunnel" || (mode !== "local" && !(localSupabaseOk && localOnlyofficeOk)); effectiveSupabaseUrl = useTunnelForDoc && tunnelSupabaseUrl ? tunnelSupabaseUrl : localSupabaseUrl; effectiveOnlyofficeBaseUrl = useTunnelForDoc && tunnelOnlyofficeBaseUrl ? tunnelOnlyofficeBaseUrl : localOnlyofficeBaseUrl; effectiveOnlyofficeStorageHostOverride = useTunnelForDoc ? tunnelOnlyofficeStorageHostOverride : localOnlyofficeStorageHostOverride; onlyofficeModeLabel = useTunnelForDoc ? "tunnel" : "local"; const localBackendOk = await checkUrlReachable(localBackendUrl); const useTunnelForBackend = mode === "tunnel" || (mode !== "local" && !localBackendOk); effectiveBackendUrl = useTunnelForBackend && tunnelBackendUrl ? tunnelBackendUrl : localBackendUrl; effectiveOnlyofficeProxyOrigin = useTunnelForDoc ? normalizeHttpUrl(cfg.onlyofficeProxyOriginTunnel || cfg.cloudflareAppOrigin) : ""; } const localLightragOk = await checkUrlReachable(localLightragUrl); const useTunnelForLightrag = mode === "tunnel" || (mode !== "local" && !localLightragOk); const effectiveLightragUrl = useTunnelForLightrag && tunnelLightragUrl ? tunnelLightragUrl : localLightragUrl; const localMineruOk = await checkUrlReachable(localMineruEndpoint); const useTunnelForMineru = mode === "tunnel" || (mode !== "local" && !localMineruOk); const effectiveMineruEndpoint = useTunnelForMineru && tunnelMineruEndpoint ? tunnelMineruEndpoint : localMineruEndpoint; const localSearxngOk = await checkUrlReachable(localSearxngBaseUrl); const effectiveSearxngBaseUrl = mode === "tunnel" || (mode !== "local" && !localSearxngOk) ? tunnelSearxngBaseUrl || localSearxngBaseUrl : localSearxngBaseUrl; const supabaseModeLabel = tunnelSupabaseUrl && effectiveSupabaseUrl === tunnelSupabaseUrl ? "tunnel" : "local"; const backendModeLabel = tunnelBackendUrl && effectiveBackendUrl === tunnelBackendUrl ? "tunnel" : "local"; logger.info( `桌面端网络模式:${mode}(supabase=${supabaseModeLabel} backend=${backendModeLabel} onlyoffice=${onlyofficeModeLabel})`, ); logger.info(`Supabase:${effectiveSupabaseUrl}`); logger.info(`Backend:${effectiveBackendUrl}`); logger.info(`OnlyOffice:${effectiveOnlyofficeBaseUrl}`); logger.info(`Supabase anon key 长度:${String(cfg.supabaseAnonKey || "").length}`); // 桌面端标记 process.env.MNOTE_DESKTOP = "1"; // 这些值会被 Next 运行期注入到 window.__MNOTE_RUNTIME_CONFIG__,客户端优先读取它们。 process.env.NEXT_PUBLIC_SUPABASE_URL = effectiveSupabaseUrl; process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = cfg.supabaseAnonKey; process.env.NEXT_PUBLIC_BACKEND_URL = effectiveBackendUrl; process.env.BACKEND_URL = effectiveBackendUrl; process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL = effectiveOnlyofficeBaseUrl; process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE = effectiveOnlyofficeStorageHostOverride; process.env.NEXT_PUBLIC_CLOUDFLARE_APP_ORIGIN = normalizeHttpUrl(cfg.cloudflareAppOrigin); // 服务端用(route handlers) process.env.SUPABASE_INTERNAL_URL = effectiveSupabaseUrl; process.env.SUPABASE_URL = effectiveSupabaseUrl; process.env.SUPABASE_ANON_KEY = cfg.supabaseAnonKey; // AI 工具:内部服务地址(route handlers 使用) if (effectiveLightragUrl) process.env.LIGHTRAG_URL = effectiveLightragUrl; if (effectiveMineruEndpoint) process.env.MINERU_ENDPOINT = effectiveMineruEndpoint; if (effectiveSearxngBaseUrl) process.env.SEARXNG_BASE_URL = effectiveSearxngBaseUrl; if (cfg.searxngApiToken) process.env.SEARXNG_API_TOKEN = String(cfg.searxngApiToken || "").trim(); // ONLYOFFICE:远端文档服务器需要可访问的 proxy origin delete process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN; delete process.env.ONLYOFFICE_PROXY_ORIGIN; if (effectiveOnlyofficeProxyOrigin) { process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN = effectiveOnlyofficeProxyOrigin; process.env.ONLYOFFICE_PROXY_ORIGIN = effectiveOnlyofficeProxyOrigin; } // 确保运行期行为可预测:优先以桌面端 config.json 为准(避免外部 .env 残留干扰) delete process.env.ONLYOFFICE_JWT_SECRET; const ooSecret = normalizeSecret(cfg.onlyofficeJwtSecret); if (ooSecret && ooSecret !== JWT_SECRET_PLACEHOLDER) { process.env.ONLYOFFICE_JWT_SECRET = ooSecret; } else if (ooSecret === JWT_SECRET_PLACEHOLDER) { logger.warn( `检测到 ONLYOFFICE JWT 占位 secret(${JWT_SECRET_PLACEHOLDER}),已按未启用处理;如需启用 JWT,请在 ${cfgPath} 填写真实 secret 并同步到 onlyoffice 容器。`, ); } } async function getFreePort() { return await new Promise((resolve, reject) => { const server = net.createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const { port } = server.address(); server.close(() => resolve(port)); }); }); } async function waitForPort(host, port, timeoutMs = 20_000) { const start = Date.now(); // 简单轮询即可(避免引入额外依赖) // eslint-disable-next-line no-constant-condition while (true) { const ok = await new Promise((resolve) => { const socket = net.createConnection({ host, port }); const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 500); socket.once("connect", () => { clearTimeout(timer); socket.end(); resolve(true); }); socket.once("error", () => { clearTimeout(timer); resolve(false); }); }); if (ok) return; if (Date.now() - start > timeoutMs) { throw new Error(`等待本地服务启动超时:${host}:${port}`); } // 100ms 间隔 // eslint-disable-next-line no-await-in-loop await new Promise((r) => setTimeout(r, 100)); } } function initLogging(dataDir) { try { const logDir = path.join(dataDir, "logs"); ensureDir(logDir); const logFile = path.join(logDir, "desktop.log"); const write = (level, message) => { const line = `[${new Date().toISOString()}] [${level}] ${message}\n`; try { fs.appendFileSync(logFile, line, { encoding: "utf8" }); } catch { // ignore } }; return { info: (m) => write("INFO", m), warn: (m) => write("WARN", m), error: (m) => write("ERROR", m), }; } catch { return { info: () => {}, warn: () => {}, error: () => {}, }; } } async function startEmbeddedNext({ dataDir, logger }) { const nextDir = getDesktopNextDir(); logger.info(`准备启动内置 Next:resources=${process.resourcesPath} nextDir=${nextDir}`); if (!fs.existsSync(nextDir)) { throw new Error( `找不到内置前端产物目录:${nextDir}。请先执行 pnpm run build:desktop:next 或重新打包安装包。`, ); } // 运行期数据目录(文档/导图/日志等) ensureDir(dataDir); ensureDir(path.join(dataDir, "documents")); ensureDir(path.join(dataDir, "logs")); // 让 Next 的 server routes 使用可写目录,而非 process.cwd()/public process.env.MNOTE_DATA_DIR = dataDir; // 禁用遥测 process.env.NEXT_TELEMETRY_DISABLED = "1"; const forcedPort = Number(process.env.MNOTE_PORT || 0); // 说明:桌面端默认固定用 3000(更利于 Supabase/后端的 CORS 规则配置);若被占用再回退随机端口。 let port = forcedPort || 3000; if (!forcedPort) { try { const probe = net.createServer(); await new Promise((resolve, reject) => { probe.once("error", reject); probe.listen(port, "127.0.0.1", () => resolve()); }); await new Promise((resolve) => probe.close(resolve)); } catch { port = await getFreePort(); } } process.env.PORT = String(port); process.env.HOSTNAME = "127.0.0.1"; process.env.NODE_ENV = "production"; // Next standalone 默认用当前工作目录作为根目录,因此这里 chdir 到 standalone 根 process.chdir(nextDir); logger.info(`启动内置 Next:dir=${nextDir} port=${port}`); try { // eslint-disable-next-line global-require, import/no-dynamic-require require(path.join(nextDir, "server.js")); } catch (error) { const message = error instanceof Error ? error.stack || error.message : String(error); logger.error(`启动 Next 失败:${message}`); throw error; } await waitForPort("127.0.0.1", port); logger.info(`内置 Next 已就绪:http://127.0.0.1:${port}`); return { port, url: `http://127.0.0.1:${port}` }; } async function createMainWindow(loadUrl, { dataDir, logger }) { const win = new BrowserWindow({ width: 1280, height: 800, show: false, backgroundColor: "#ffffff", webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, nodeIntegration: false, }, }); win.once("ready-to-show", () => win.show()); win.webContents.on("did-fail-load", (_e, code, desc, validatedUrl) => { logger.error(`页面加载失败:code=${code} desc=${desc} url=${validatedUrl}`); }); // 将 Electron 的 userData 也固定到安装目录下,避免散落到 C:\Users\...\AppData await win.loadURL(loadUrl); } async function main() { const dataDir = process.env.MNOTE_DATA_DIR || getDefaultDataDir(); const logger = initLogging(dataDir); logger.info(`MNOTE 启动:packaged=${isPackaged()} execPath=${process.execPath} dataDir=${dataDir}`); // 尽早固定 userData(建议在 ready 前设置,但这里仍尽量提前到窗口创建之前) try { app.setPath("userData", path.join(dataDir, "electron")); } catch { // ignore } // 桌面端运行期配置(避免 build 时的 .env.production.local 影响桌面体验) await applyDesktopEnvFromConfig(dataDir, logger); // 调试模式:允许直接指定 URL(例如你本地已手动启动 wolai-frontend dev) const directUrl = process.env.MNOTE_LOAD_URL; if (directUrl) { logger.info(`使用外部 URL:${directUrl}`); await createMainWindow(directUrl, { dataDir, logger }); return; } const { url } = await startEmbeddedNext({ dataDir, logger }); await createMainWindow(url, { dataDir, logger }); } app.on("window-all-closed", () => { // macOS 才保留常驻;Windows 直接退出 if (process.platform !== "darwin") { app.quit(); } }); app.on("activate", () => { // no-op:当前只做单窗口 }); app.whenReady().then(main).catch((error) => { dialog.showErrorBox( "MNOTE 启动失败", error instanceof Error ? error.stack || error.message : String(error), ); app.quit(); });