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();
|
|||
|
|
});
|