Files
mnote/desktop-electron/main.js
T

550 lines
22 KiB
JavaScript
Raw Normal View History

2026-01-15 20:54:21 +08:00
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 强制走 TunnelOnlyOffice 优先本地,没有再走 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(`准备启动内置 Nextresources=${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(`启动内置 Nextdir=${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();
});