Files
mnote/desktop-electron/main.js
T
2026-01-15 20:54:21 +08:00

550 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});