550 lines
22 KiB
JavaScript
550 lines
22 KiB
JavaScript
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();
|
||
});
|