ehbrowser 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +531 -0
  3. package/dist/api/contract.js +7 -0
  4. package/dist/api/dto/auth.js +5 -0
  5. package/dist/api/dto/common.js +6 -0
  6. package/dist/api/dto/download.js +5 -0
  7. package/dist/api/dto/favorite.js +5 -0
  8. package/dist/api/dto/gallery.js +43 -0
  9. package/dist/api/dto/index.js +6 -0
  10. package/dist/api/dto/library.js +5 -0
  11. package/dist/api/dto/log.js +5 -0
  12. package/dist/api/dto/playlist.js +8 -0
  13. package/dist/api/dto/settings.js +5 -0
  14. package/dist/api/dto/storage.js +5 -0
  15. package/dist/api/dto/translate.js +6 -0
  16. package/dist/api/envelope.js +41 -0
  17. package/dist/api/events.js +20 -0
  18. package/dist/api/index.js +12 -0
  19. package/dist/api/routes.js +516 -0
  20. package/dist/config/atomic.js +85 -0
  21. package/dist/config/auth-setting.js +97 -0
  22. package/dist/config/cli.js +19 -0
  23. package/dist/config/db.js +168 -0
  24. package/dist/config/index.js +48 -0
  25. package/dist/config/json-store.js +219 -0
  26. package/dist/config/migrations.js +100 -0
  27. package/dist/config/schema.js +108 -0
  28. package/dist/config/user-setting.js +21 -0
  29. package/dist/config/validate.js +173 -0
  30. package/dist/eh/api.js +106 -0
  31. package/dist/eh/archives.js +100 -0
  32. package/dist/eh/auth.js +89 -0
  33. package/dist/eh/cookies.js +65 -0
  34. package/dist/eh/favorites.js +94 -0
  35. package/dist/eh/html.js +158 -0
  36. package/dist/eh/http.js +214 -0
  37. package/dist/eh/index.js +13 -0
  38. package/dist/eh/types.js +11 -0
  39. package/dist/eh/urls.js +81 -0
  40. package/dist/main.js +170 -0
  41. package/dist/platform/errors.js +76 -0
  42. package/dist/platform/open-external.js +90 -0
  43. package/dist/platform/os.js +28 -0
  44. package/dist/platform/paths.js +140 -0
  45. package/dist/server.js +1074 -0
  46. package/dist/services/auth-service.js +276 -0
  47. package/dist/services/config-service.js +227 -0
  48. package/dist/services/detail-cache.js +193 -0
  49. package/dist/services/detail-store.js +208 -0
  50. package/dist/services/download-file.js +127 -0
  51. package/dist/services/download-service.js +586 -0
  52. package/dist/services/favorite-service.js +237 -0
  53. package/dist/services/gallery-service.js +403 -0
  54. package/dist/services/local-library.js +471 -0
  55. package/dist/services/log-service.js +139 -0
  56. package/dist/services/playlist-service.js +85 -0
  57. package/dist/services/search-cache.js +78 -0
  58. package/dist/services/storage-service.js +27 -0
  59. package/dist/services/translate-service.js +208 -0
  60. package/dist/services/update-service.js +268 -0
  61. package/dist/services/upstream-service.js +64 -0
  62. package/dist/services/zip.js +210 -0
  63. package/dist/web/assets/index-C0tAHpmx.css +1 -0
  64. package/dist/web/assets/index-myYEnJ1q.js +4 -0
  65. package/dist/web/index.html +13 -0
  66. package/package.json +63 -0
@@ -0,0 +1,81 @@
1
+ /*
2
+ * 站点页面地址构造。与 api.php 一套分开:这里构造的是 HTML 页面地址,不是接口
3
+ */
4
+ const SITE_ORIGINS = {
5
+ "e-hentai": "https://e-hentai.org",
6
+ exhentai: "https://exhentai.org",
7
+ };
8
+ /**
9
+ * 分类位。上游搜索表单只有 10 个分类复选(cat_1 … cat_512),Private 没有对应位,无法过滤
10
+ * f_cats 为排除掩码:默认 0 表示不过滤,1023 表示全排除(上游对 1023 与 >=1024 均按不过滤处理)
11
+ */
12
+ export const CATEGORY_BITS = {
13
+ Misc: 1 << 0,
14
+ Doujinshi: 1 << 1,
15
+ Manga: 1 << 2,
16
+ "Artist CG": 1 << 3,
17
+ "Game CG": 1 << 4,
18
+ Western: 1 << 5,
19
+ "Non-H": 1 << 6,
20
+ "Image Set": 1 << 7,
21
+ Cosplay: 1 << 8,
22
+ "Asian Porn": 1 << 9,
23
+ };
24
+ export const ALL_CATEGORY_BITS = Object.values(CATEGORY_BITS).reduce((a, b) => a | b, 0);
25
+ export function galleryUrl(site, gid, token, thumbPage) {
26
+ const base = `${SITE_ORIGINS[site]}/g/${gid}/${token}/`;
27
+ return thumbPage === undefined || thumbPage <= 0 ? base : `${base}?p=${thumbPage}`;
28
+ }
29
+ export function imagePageUrl(site, gid, page, pageToken, skipHathKey) {
30
+ const base = `${SITE_ORIGINS[site]}/s/${pageToken}/${gid}-${page}`;
31
+ return skipHathKey === undefined ? base : `${base}?nl=${encodeURIComponent(skipHathKey)}`;
32
+ }
33
+ /** 搜索页。语言过滤并入 f_search,上游支持 language: 前缀 */
34
+ export function searchUrl(site, query) {
35
+ const params = new URLSearchParams();
36
+ const terms = [];
37
+ if (query.query !== undefined && query.query.trim() !== "") {
38
+ terms.push(query.query.trim());
39
+ }
40
+ if (query.language !== undefined) {
41
+ terms.push(`language:${query.language}`);
42
+ }
43
+ if (terms.length > 0) {
44
+ params.set("f_search", terms.join(" "));
45
+ }
46
+ let excluded = 0;
47
+ for (const category of query.excludedCategories ?? []) {
48
+ excluded |= CATEGORY_BITS[category] ?? 0;
49
+ }
50
+ if ((query.categories ?? []).length > 0) {
51
+ let included = 0;
52
+ for (const category of query.categories ?? []) {
53
+ included |= CATEGORY_BITS[category] ?? 0;
54
+ }
55
+ excluded |= ALL_CATEGORY_BITS & ~included;
56
+ }
57
+ // 上游仅在 advsearch=1 时应用 f_cats 与 f_sr;全排除与超范围的值会被忽略,因此跳过
58
+ if (excluded !== 0 && excluded !== ALL_CATEGORY_BITS) {
59
+ params.set("advsearch", "1");
60
+ params.set("f_cats", String(excluded));
61
+ }
62
+ if (query.minRating !== undefined) {
63
+ params.set("advsearch", "1");
64
+ params.set("f_sr", "on");
65
+ params.set("f_srdd", String(Math.max(1, Math.round(query.minRating))));
66
+ }
67
+ if (query.page !== undefined && query.page > 0) {
68
+ params.set("page", String(query.page));
69
+ }
70
+ const search = params.toString();
71
+ return search === "" ? `${SITE_ORIGINS[site]}/` : `${SITE_ORIGINS[site]}/?${search}`;
72
+ }
73
+ export function archiveUrl(site, gid, token) {
74
+ return `${SITE_ORIGINS[site]}/archiver.php?gid=${gid}&token=${encodeURIComponent(token)}`;
75
+ }
76
+ export function torrentsUrl(site, gid, token) {
77
+ return `${SITE_ORIGINS[site]}/gallerytorrents.php?gid=${gid}&t=${encodeURIComponent(token)}`;
78
+ }
79
+ export function siteOrigin(site) {
80
+ return SITE_ORIGINS[site];
81
+ }
package/dist/main.js ADDED
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 入口。解析参数 -> 初始化配置 -> 启动本地服务 -> 打开界面
4
+ * 收到 SIGINT / SIGTERM 时先关服务、再刷写配置并关库
5
+ */
6
+ import { createRequire } from "node:module";
7
+ import { initConfig } from "./config/index.js";
8
+ import { hasProxyAgent } from "./eh/index.js";
9
+ import { openExternal } from "./platform/open-external.js";
10
+ import { generateToken, startServer } from "./server.js";
11
+ import { createAuthService } from "./services/auth-service.js";
12
+ import { createConfigService } from "./services/config-service.js";
13
+ import { createDownloadService, defaultDownloadDirectory } from "./services/download-service.js";
14
+ import { createDetailStore } from "./services/detail-store.js";
15
+ import { createGalleryService } from "./services/gallery-service.js";
16
+ import { createLocalLibrary } from "./services/local-library.js";
17
+ import { createPlaylistService } from "./services/playlist-service.js";
18
+ import { createLogService, defaultLogDirectory } from "./services/log-service.js";
19
+ import { createTranslateService } from "./services/translate-service.js";
20
+ import { createStorageService } from "./services/storage-service.js";
21
+ import { createUpdateService } from "./services/update-service.js";
22
+ import { createFavoriteService } from "./services/favorite-service.js";
23
+ import { createUpstreamService } from "./services/upstream-service.js";
24
+ const require = createRequire(import.meta.url);
25
+ const { version } = require("../package.json");
26
+ function parseArgs(argv) {
27
+ let port;
28
+ let open = true;
29
+ for (let index = 0; index < argv.length; index += 1) {
30
+ const arg = argv[index];
31
+ if (arg === "--no-open") {
32
+ open = false;
33
+ continue;
34
+ }
35
+ if (arg === "--port") {
36
+ const raw = argv[index + 1];
37
+ const value = Number(raw);
38
+ if (raw === undefined || !Number.isInteger(value) || value < 0 || value > 65535) {
39
+ throw new Error(`端口不合法:${raw ?? "(缺失)"}`);
40
+ }
41
+ port = value;
42
+ index += 1;
43
+ continue;
44
+ }
45
+ if (arg === "--help" || arg === "-h") {
46
+ console.log("用法:ehbrowser [--port <0-65535>] [--no-open]");
47
+ process.exit(0);
48
+ }
49
+ throw new Error(`未知参数:${arg}`);
50
+ }
51
+ return port === undefined ? { open } : { port, open };
52
+ }
53
+ async function main() {
54
+ console.clear();
55
+ const options = parseArgs(process.argv.slice(2));
56
+ const ctx = await initConfig();
57
+ // 日志:最先创建,后续各服务的 logger 均由它提供,输出同时进入控制台与日志文件
58
+ const logs = createLogService(ctx, { fallbackDirectory: defaultLogDirectory });
59
+ const service = createConfigService(ctx);
60
+ // 构造即按配置应用代理与请求间隔,配置变更时自动跟随
61
+ const upstream = createUpstreamService(ctx, service, {
62
+ logger: logs.logger("upstream"),
63
+ });
64
+ // 详情缓存落盘,位于缓存目录的 gallery/ 下,重启不丢失
65
+ const detailStore = createDetailStore({
66
+ cacheDir: ctx.paths.cacheDir,
67
+ logger: logs.logger("cache"),
68
+ });
69
+ const gallery = createGalleryService(ctx, upstream, {
70
+ store: detailStore,
71
+ logger: logs.logger("gallery"),
72
+ });
73
+ // 启动时按默认条件拉取一次并写入缓存,界面第一次打开即可直接渲染;
74
+ // 不阻塞启动,失败只记日志,界面自己会再发一次检索
75
+ void gallery.warmSearch();
76
+ // 本地库:下载落盘目录、.ehbrowser 元数据与按页取图均由它负责
77
+ const library = createLocalLibrary(ctx, { fallbackDirectory: defaultDownloadDirectory });
78
+ // 储存空间:占用统计与缓存清理
79
+ const storage = createStorageService({ library, store: detailStore });
80
+ const downloads = createDownloadService(ctx, upstream, {
81
+ fallbackDirectory: defaultDownloadDirectory,
82
+ library,
83
+ gallery,
84
+ // 快照用于本地库卡片离线展示,取不到不影响下载
85
+ summarise: (gid, token, signal) => gallery.summaryOf(gid, token, signal),
86
+ logger: logs.logger("download"),
87
+ });
88
+ downloads.recover();
89
+ const auth = createAuthService(ctx, {
90
+ logger: logs.logger("auth"),
91
+ });
92
+ // 用户播放列表:持久化在 SQLite,重启不丢
93
+ const playlist = createPlaylistService(ctx);
94
+ // 收藏:本地收藏夹落库,云端槽位同步到上游
95
+ const favorites = createFavoriteService(ctx, gallery, {
96
+ logger: logs.logger("favorite"),
97
+ });
98
+ // 标签翻译词库:按需下载到缓存目录,不随程序分发
99
+ const translate = createTranslateService({
100
+ cacheDir: ctx.paths.cacheDir,
101
+ logger: logs.logger("translate"),
102
+ });
103
+ // 更新检查:结果只在内存中,重启即清空
104
+ const updates = createUpdateService(library, gallery, {
105
+ logger: logs.logger("update"),
106
+ });
107
+ let server;
108
+ /** 实际的关闭流程在服务启动后赋值。此处先占位,界面发起请求时不会遇到未初始化的绑定 */
109
+ let shutdown = async () => undefined;
110
+ server = await startServer({
111
+ service,
112
+ gallery,
113
+ auth,
114
+ downloads,
115
+ library,
116
+ storage,
117
+ playlist,
118
+ updates,
119
+ favorites,
120
+ translate,
121
+ logs,
122
+ token: generateToken(),
123
+ version,
124
+ port: options.port,
125
+ // 界面上的「关闭服务器」走同一条优雅关闭路径
126
+ onShutdown: () => void shutdown("界面请求"),
127
+ });
128
+ // 启动横幅同样写入日志文件,出现问题时先查看这几行
129
+ logs.append("info", `EhBrowser ${version}`);
130
+ logs.append("info", `地址:${server.url}`);
131
+ logs.append("info", `配置:${ctx.paths.configDir}`);
132
+ logs.append("info", `代理:${hasProxyAgent() ? "已启用" : "未启用"}`);
133
+ logs.append("info", `日志:${logs.state().directory}`);
134
+ logs.append("info", "按 Ctrl+C 退出");
135
+ if (options.open) {
136
+ try {
137
+ await openExternal(server.url);
138
+ }
139
+ catch (error) {
140
+ const reason = error instanceof Error ? error.message : String(error);
141
+ logs.append("warn", `未能自动打开浏览器(${reason}),请手动访问 ${server.url}`);
142
+ }
143
+ }
144
+ let closing = false;
145
+ shutdown = async (signal) => {
146
+ if (closing) {
147
+ return;
148
+ }
149
+ closing = true;
150
+ logs.append("info", `正在关闭服务端,终止信号为 ${signal}`);
151
+ try {
152
+ await server.close();
153
+ await ctx.close();
154
+ // 最后等待日志落盘,否则进程退出快于写文件,日志尾部会丢失
155
+ await logs.flush();
156
+ }
157
+ catch (error) {
158
+ console.error(`退出时出错:${error instanceof Error ? error.message : String(error)}`);
159
+ process.exit(1);
160
+ }
161
+ process.exit(0);
162
+ };
163
+ process.on("SIGINT", () => void shutdown("SIGINT"));
164
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
165
+ }
166
+ main().catch((error) => {
167
+ const reason = error instanceof Error ? error.message : String(error);
168
+ console.error(`启动失败:${reason}`);
169
+ process.exit(1);
170
+ });
@@ -0,0 +1,76 @@
1
+ /*
2
+ * 错误描述。undici 的失败原因嵌在 cause 链里。
3
+ * 只取 message 会得到 "fetch failed",真实原因(连接被拒、超时、DNS)在下一层。
4
+ */
5
+ /** 读取错误对象上的 code。Node 的 DNS 与 syscall 错误只有 code。 */
6
+ export function errorCode(error) {
7
+ let current = error;
8
+ for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
9
+ if (current === null || current === undefined) {
10
+ return "";
11
+ }
12
+ const code = current.code;
13
+ if (typeof code === "string" && code !== "") {
14
+ return code;
15
+ }
16
+ current = current instanceof Error ? current.cause : undefined;
17
+ }
18
+ return "";
19
+ }
20
+ /** 把 message 与各层 cause 合并为一句。重复文本只保留一次。 */
21
+ export function describeError(error) {
22
+ const parts = [];
23
+ let current = error;
24
+ for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
25
+ if (current === null || current === undefined) {
26
+ break;
27
+ }
28
+ const message = current instanceof Error ? current.message : String(current);
29
+ const code = current.code;
30
+ const text = message === "" && typeof code === "string"
31
+ ? code
32
+ : typeof code === "string" && code !== "" && !message.includes(code)
33
+ ? `${message}(${code})`
34
+ : message;
35
+ if (text !== "" && !parts.includes(text)) {
36
+ parts.push(text);
37
+ }
38
+ current = current instanceof Error ? current.cause : undefined;
39
+ }
40
+ if (parts.length === 0) {
41
+ return String(error);
42
+ }
43
+ return parts.join(":");
44
+ }
45
+ /** cause 链可能成环,也有平台自造的深链。 */
46
+ const MAX_DEPTH = 6;
47
+ /*
48
+ * 传输层失败的说明文字。
49
+ * undici 的原文是英文,且只说「fetch failed:Client network socket disconnected…」,
50
+ * 用户看不出是代理在握手阶段断连,还是本站解析出错。这里按特征补一句可能的原因。
51
+ */
52
+ export function hintForTransportFailure(error) {
53
+ const text = describeError(error);
54
+ if (/disconnected before secure TLS|ECONNRESET/i.test(text)) {
55
+ return "连接在 TLS 握手阶段被重置,请求没到达上游:多半是代理/节点不稳定,或该站点没走代理(被拒绝直连);重试一次通常就好";
56
+ }
57
+ if (/socket hang up|other side closed|UND_ERR_SOCKET/i.test(text)) {
58
+ return "连接被对端提前关闭,请求没到达上游:多半是代理/节点不稳定;重试一次通常就好";
59
+ }
60
+ if (/UND_ERR_CONNECT_TIMEOUT|Connect Timeout Error|ETIMEDOUT/i.test(text)) {
61
+ return "连接上游超时:代理/节点不通,或该站点被直连后遭阻断";
62
+ }
63
+ if (/ENOTFOUND|EAI_AGAIN/i.test(text)) {
64
+ return "上游域名解析失败:检查 DNS 与代理设置";
65
+ }
66
+ if (/ECONNREFUSED/i.test(text)) {
67
+ return "连接被拒绝:代理端口可能不对,或代理没有在监听";
68
+ }
69
+ return "";
70
+ }
71
+ /** 上游失败的一句话:原文在前,可能的原因附在后面 */
72
+ export function describeTransportError(error) {
73
+ const reason = describeError(error);
74
+ const hint = hintForTransportFailure(error);
75
+ return hint === "" ? reason : `${reason}(${hint})`;
76
+ }
@@ -0,0 +1,90 @@
1
+ /*
2
+ * 用系统默认程序打开 URL 或本地路径
3
+ * 主要用途:服务启动后将界面地址交给默认浏览器
4
+ * target 可能来自网页内容,因此必须校验:以 "-" 开头的字符串会被 open / cmd start
5
+ * 视为命令行参数,协议亦限定白名单
6
+ */
7
+ import { spawn } from "node:child_process";
8
+ import { isAbsolute } from "node:path";
9
+ import { isWsl, platform } from "./os.js";
10
+ /** 允许的协议 */
11
+ const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "file:"]);
12
+ /** 绝对路径原样返回;其余须为白名单协议 URL。不合法时抛出异常 */
13
+ function normalizeTarget(input) {
14
+ const target = input.trim();
15
+ if (target === "") {
16
+ throw new Error("打开目标为空值");
17
+ }
18
+ // 控制字符(含换行)会破坏命令行语义
19
+ if (/[\u0000-\u001f\u007f]/.test(target)) {
20
+ throw new Error("打开目标包含控制字符");
21
+ }
22
+ if (target.startsWith("-")) {
23
+ throw new Error(`打开目标不能以 "-" 开头:${target}`);
24
+ }
25
+ // 本地路径;Windows 的 "C:\\..." 亦归此类
26
+ if (isAbsolute(target)) {
27
+ return target;
28
+ }
29
+ let parsed;
30
+ try {
31
+ parsed = new URL(target);
32
+ }
33
+ catch {
34
+ throw new Error(`目标既非绝对路径也非合法 URL:${target}`);
35
+ }
36
+ if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
37
+ throw new Error(`不允许的协议:${parsed.protocol}`);
38
+ }
39
+ return parsed.href;
40
+ }
41
+ /** 生成候选命令,按优先级排列。单独导出以便在任意平台测试三种系统的构造结果 */
42
+ export function buildOpenCommands(rawTarget, targetPlatform = platform, inWsl = isWsl) {
43
+ const target = normalizeTarget(rawTarget);
44
+ if (targetPlatform === "windows") {
45
+ // 第二个参数为 start 的窗口标题占位;缺失时带引号的路径会被当作标题
46
+ return [{ command: "cmd", args: ["/c", "start", "", target] }];
47
+ }
48
+ if (targetPlatform === "macos") {
49
+ return [{ command: "open", args: [target] }];
50
+ }
51
+ const candidates = [];
52
+ if (inWsl) {
53
+ // WSL 下 xdg-open 常无效,优先 wslview(wslu),其次 explorer.exe
54
+ candidates.push({ command: "wslview", args: [target] });
55
+ candidates.push({ command: "explorer.exe", args: [target] });
56
+ }
57
+ candidates.push({ command: "xdg-open", args: [target] });
58
+ candidates.push({ command: "gio", args: ["open", target] });
59
+ return candidates;
60
+ }
61
+ function spawnDetached(command, args) {
62
+ return new Promise((resolve, reject) => {
63
+ const child = spawn(command, [...args], { detached: true, stdio: "ignore" });
64
+ child.once("error", reject);
65
+ child.once("spawn", () => {
66
+ // 不等待退出:浏览器与文件管理器均为长驻进程
67
+ child.unref();
68
+ resolve();
69
+ });
70
+ });
71
+ }
72
+ /**
73
+ * 依次尝试候选命令,全部失败时抛出聚合错误
74
+ * 仅确认进程启动成功;xdg-open 存在但执行失败会返回非 0,此处不等待也不检测
75
+ */
76
+ export async function openExternal(rawTarget) {
77
+ const target = normalizeTarget(rawTarget);
78
+ const failures = [];
79
+ for (const candidate of buildOpenCommands(target)) {
80
+ try {
81
+ await spawnDetached(candidate.command, candidate.args);
82
+ return;
83
+ }
84
+ catch (error) {
85
+ const reason = error instanceof Error ? error.message : String(error);
86
+ failures.push(`${candidate.command}: ${reason}`);
87
+ }
88
+ }
89
+ throw new Error(`无法打开 ${target}:已尝试 ${failures.join(";")}`);
90
+ }
@@ -0,0 +1,28 @@
1
+ /*
2
+ * 平台识别。本项目仅此模块读取 process.platform
3
+ */
4
+ function detectPlatform() {
5
+ switch (process.platform) {
6
+ case "win32":
7
+ return "windows";
8
+ case "darwin":
9
+ return "macos";
10
+ default:
11
+ // 其余 Unix 按 Linux 处理,目录惯例与打开命令一致
12
+ return "linux";
13
+ }
14
+ }
15
+ export const platform = detectPlatform();
16
+ export const isWindows = platform === "windows";
17
+ export const isMacOS = platform === "macos";
18
+ export const isLinux = platform === "linux";
19
+ function detectWsl() {
20
+ if (platform !== "linux") {
21
+ return false;
22
+ }
23
+ // WSL 会注入 WSL_DISTRO_NAME / WSL_INTEROP。该环境下 xdg-open 通常无法打开
24
+ // Windows 侧浏览器,需改用 wslview 或 explorer.exe
25
+ return Boolean(process.env["WSL_DISTRO_NAME"] ?? process.env["WSL_INTEROP"]);
26
+ }
27
+ /** 是否运行于 WSL,影响外部程序打开策略 */
28
+ export const isWsl = detectWsl();
@@ -0,0 +1,140 @@
1
+ /*
2
+ * 数据目录解析。
3
+ * 三条根目录:config(配置与 Cookie)、data(数据库、下载、日志)、cache(缩略图等可再生成数据)。
4
+ * 优先级由高到低:initPaths 显式覆盖 > EHBROWSER_*_DIR > EHBROWSER_HOME 便携模式 > 系统默认。
5
+ * 本模块仅解析路径,不创建目录。创建由 ensureDirs 负责。
6
+ */
7
+ import { chmod, mkdir, stat } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { isAbsolute, join, normalize } from "node:path";
10
+ import { isWindows, platform } from "./os.js";
11
+ /** 各平台统一使用的目录名。 */
12
+ export const APP_DIR_NAME = "ehbrowser";
13
+ const CACHE_SUBDIR = "cache";
14
+ /** 目录权限 0700。树下含 Cookie。 */
15
+ const DIR_MODE = 0o700;
16
+ let cached = null;
17
+ // 空字符串按未设置处理
18
+ function readEnv(name) {
19
+ const raw = process.env[name];
20
+ if (raw === undefined) {
21
+ return undefined;
22
+ }
23
+ const value = raw.trim();
24
+ return value === "" ? undefined : value;
25
+ }
26
+ // 展开 ~ 与 ~/…,其余原样返回。
27
+ function expandTilde(input) {
28
+ if (input === "~") {
29
+ return homedir();
30
+ }
31
+ if (input.startsWith("~/") || input.startsWith("~\\")) {
32
+ return join(homedir(), input.slice(2));
33
+ }
34
+ return input;
35
+ }
36
+ function toAbsolute(input) {
37
+ const expanded = expandTilde(input);
38
+ return normalize(isAbsolute(expanded) ? expanded : join(process.cwd(), expanded));
39
+ }
40
+ function osDefaultRoots() {
41
+ if (isWindows) {
42
+ const roaming = readEnv("APPDATA") ?? join(homedir(), "AppData", "Roaming");
43
+ const local = readEnv("LOCALAPPDATA") ?? join(homedir(), "AppData", "Local");
44
+ return {
45
+ configDir: join(roaming, APP_DIR_NAME),
46
+ dataDir: join(local, APP_DIR_NAME),
47
+ // Windows 无独立缓存约定,缓存置于 data 下。
48
+ cacheDir: join(local, APP_DIR_NAME, CACHE_SUBDIR),
49
+ };
50
+ }
51
+ if (platform === "macos") {
52
+ // macOS 惯例:配置与数据同放 Application Support,缓存单独置于 Caches。
53
+ const support = join(homedir(), "Library", "Application Support", APP_DIR_NAME);
54
+ return {
55
+ configDir: support,
56
+ dataDir: support,
57
+ cacheDir: join(homedir(), "Library", "Caches", APP_DIR_NAME),
58
+ };
59
+ }
60
+ // 遵循 XDG。XDG_*_HOME 常常未设置,须回退到 ~/.config 等默认值,
61
+ // 否则会拼出 undefined/ehbrowser 这样的路径。
62
+ return {
63
+ configDir: join(readEnv("XDG_CONFIG_HOME") ?? join(homedir(), ".config"), APP_DIR_NAME),
64
+ dataDir: join(readEnv("XDG_DATA_HOME") ?? join(homedir(), ".local", "share"), APP_DIR_NAME),
65
+ cacheDir: join(readEnv("XDG_CACHE_HOME") ?? join(homedir(), ".cache"), APP_DIR_NAME),
66
+ };
67
+ }
68
+ function portableRoots(home) {
69
+ const base = toAbsolute(home);
70
+ return {
71
+ configDir: join(base, "config"),
72
+ dataDir: join(base, "data"),
73
+ cacheDir: join(base, CACHE_SUBDIR),
74
+ };
75
+ }
76
+ /** 解析并缓存。重复调用会按最新参数与环境变量重新解析。 */
77
+ export function initPaths(overrides = {}) {
78
+ const portableHome = readEnv("EHBROWSER_HOME");
79
+ const base = portableHome === undefined ? osDefaultRoots() : portableRoots(portableHome);
80
+ const sources = {
81
+ configDir: portableHome === undefined ? "os-default" : "portable-home",
82
+ dataDir: portableHome === undefined ? "os-default" : "portable-home",
83
+ cacheDir: portableHome === undefined ? "os-default" : "portable-home",
84
+ };
85
+ const resolveOne = (key, envName, override) => {
86
+ if (override !== undefined && override.trim() !== "") {
87
+ sources[key] = "override";
88
+ return toAbsolute(override);
89
+ }
90
+ const fromEnv = readEnv(envName);
91
+ if (fromEnv !== undefined) {
92
+ sources[key] = "env";
93
+ return toAbsolute(fromEnv);
94
+ }
95
+ return base[key];
96
+ };
97
+ const resolved = {
98
+ configDir: resolveOne("configDir", "EHBROWSER_CONFIG_DIR", overrides.configDir),
99
+ dataDir: resolveOne("dataDir", "EHBROWSER_DATA_DIR", overrides.dataDir),
100
+ cacheDir: resolveOne("cacheDir", "EHBROWSER_CACHE_DIR", overrides.cacheDir),
101
+ sources,
102
+ };
103
+ cached = resolved;
104
+ return resolved;
105
+ }
106
+ /** 取缓存结果,未解析时先解析一次 */
107
+ export function getPaths() {
108
+ return cached ?? initPaths();
109
+ }
110
+ /** 清空缓存,供测试使用。下次 getPaths 重新读取环境变量。 */
111
+ export function resetPaths() {
112
+ cached = null;
113
+ }
114
+ /*
115
+ * 创建三条根目录。POSIX 下将已存在目录的权限收紧至 0700(树下含 Cookie)。
116
+ * Windows 上 chmod 无效,跳过。
117
+ */
118
+ export async function ensureDirs(roots = getPaths()) {
119
+ for (const dir of [roots.configDir, roots.dataDir, roots.cacheDir]) {
120
+ await mkdir(dir, { recursive: true, mode: DIR_MODE });
121
+ if (isWindows) {
122
+ continue;
123
+ }
124
+ const info = await stat(dir);
125
+ if ((info.mode & 0o777) !== DIR_MODE) {
126
+ await chmod(dir, DIR_MODE);
127
+ }
128
+ }
129
+ }
130
+ /** 打印三条根目录及其来源,用于诊断。 */
131
+ export function describePaths(roots = getPaths()) {
132
+ const rows = [
133
+ ["config", roots.configDir, roots.sources.configDir],
134
+ ["data", roots.dataDir, roots.sources.dataDir],
135
+ ["cache", roots.cacheDir, roots.sources.cacheDir],
136
+ ];
137
+ return rows
138
+ .map(([name, value, source]) => `${name.padEnd(6)} ${value} (${source})`)
139
+ .join("\n");
140
+ }