-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.js
More file actions
700 lines (681 loc) · 47.2 KB
/
Copy pathmain.js
File metadata and controls
700 lines (681 loc) · 47.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
// FilenMount — Electron 메인 프로세스
// Filen 전용: rclone 엔진으로 Filen 계정을 네트워크 드라이브로 마운트, 여러 계정을 union으로 묶기,
// 부팅 시 자동 시작, 트레이 상주, VFS 캐시로 데이터 안전. (cloudmount 엔진의 Filen 특화판)
const { app, BrowserWindow, Tray, Menu, Notification, nativeImage, ipcMain, dialog, shell, nativeTheme, session, safeStorage, net: electronNet } = require("electron");
const { spawn, execFile } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
const net = require("net");
const crypto = require("crypto");
const https = require("https");
const isMac = process.platform === "darwin";
// 외부 다운로드는 Electron net.fetch(Chromium 스택) — 시스템 프록시/인증서/IPv4·6 폴백 처리
const netFetch = (url, opts = {}) => electronNet.fetch(url, { ...opts, headers: { "User-Agent": "FilenMount", ...(opts.headers || {}) } });
// ── i18n: locales/*.json 자동 탐색 — 파일 추가만으로 언어 확장 ──────
const LOCALE_DIR = path.join(__dirname, "locales");
const locales = {};
try { for (const f of fs.readdirSync(LOCALE_DIR).filter(f => f.endsWith(".json"))) { try { locales[f.replace(".json", "")] = JSON.parse(fs.readFileSync(path.join(LOCALE_DIR, f), "utf8")); } catch {} } } catch {}
const languages = Object.entries(locales).map(([code, d]) => ({ code, name: d["_meta.name"] || code }));
const L = (key, vars) => {
let s = (locales[conf && conf.language] || locales.ko || locales.en || {})[key] || key;
for (const [k, v] of Object.entries(vars || {})) s = s.split(`{${k}}`).join(v);
return s;
};
// OS 언어 → 지원 언어 매핑(첫 실행). 중국어는 번체/간체 구분, 지원 안 하면 영어.
function pickLocale(sys) {
const s = String(sys || "").toLowerCase();
const codes = Object.keys(locales);
if (s.startsWith("zh")) { const hant = /hant|-tw|-hk|-mo/.test(s); if (hant && codes.includes("zh-Hant")) return "zh-Hant"; return codes.includes("zh") ? "zh" : "en"; }
const base = s.split(/[-_]/)[0];
if (codes.includes(base)) return base;
return codes.includes("en") ? "en" : (codes[0] || "ko");
}
// ── 설정 저장 ────────────────────────────────────────────────────
const CONF_PATH = path.join(app.getPath("userData"), "config.json");
const defaultMountBase = () => (isMac ? path.join(os.homedir(), "FilenMount") : "F:\\FilenMount");
const DEFAULTS = {
accounts: [], // [{name, email, folder}]
// 그룹(union) — 각 그룹은 2개 이상 계정을 하나의 드라이브로 합침. 계정은 최대 한 그룹에만 속함.
// 그룹에 안 속한 계정은 개별 마운트. members 순서 = 우선순위(첫 번째 = 대표, 새 파일 우선 저장).
groups: [], // [{ id, name, members:[accountName,...] }]
mountBase: defaultMountBase(),
cacheDir: "",
cacheMaxSize: "50G",
cacheMaxAge: "720h",
autoLaunch: true,
startMinimized: true,
minimizeToTray: true,
forceIpv4: true,
language: "ko",
theme: "system",
backupDir: "",
lastBackup: 0,
autoBackup: false, // 주기적 자동 백업(비번은 OS 보안 저장소에 보관)
};
let conf = { ...DEFAULTS };
const isValidName = (s) => typeof s === "string" && /^[A-Za-z0-9._-]{1,64}$/.test(s);
const isSafeFolder = (s) => typeof s === "string" && s.length > 0 && s.length <= 120 && !/[/\\]|(^\.\.?$)|(^~)/.test(s);
function sanitizeConf(raw) {
const c = { ...DEFAULTS, ...(raw && typeof raw === "object" ? raw : {}) };
if (typeof c.mountBase !== "string" || !path.isAbsolute(c.mountBase)) c.mountBase = DEFAULTS.mountBase;
if (typeof c.language !== "string" || !locales[c.language]) c.language = DEFAULTS.language;
if (c.cacheDir && (typeof c.cacheDir !== "string" || !path.isAbsolute(c.cacheDir))) c.cacheDir = "";
c.accounts = Array.isArray(c.accounts) ? c.accounts.filter(a => a && isValidName(a.name) && isSafeFolder(a.folder)) : [];
c.groups = Array.isArray(c.groups)
? c.groups.filter(g => g && typeof g.id === "string" && Array.isArray(g.members))
.map(g => ({ id: g.id, name: (typeof g.name === "string" && isSafeFolder(g.name)) ? g.name : "Filen", members: g.members.filter(n => isValidName(n)) }))
: [];
return c;
}
try { conf = sanitizeConf(JSON.parse(fs.readFileSync(CONF_PATH, "utf8"))); } catch {}
const isFirstRun = !fs.existsSync(CONF_PATH);
function saveConf() { try { fs.mkdirSync(path.dirname(CONF_PATH), { recursive: true }); fs.writeFileSync(CONF_PATH, JSON.stringify(conf, null, 2)); } catch {} }
// ── 시스템 프록시 → rclone env (Chromium은 되는데 rclone만 막히는 환경 대응) ──
let proxyEnv = null;
async function computeProxyEnv() {
try {
const p = await session.defaultSession.resolveProxy("https://gateway.filen.io");
const m = /(?:HTTPS?\s+PROXY|PROXY|HTTPS)\s+([^\s;]+)/i.exec(p) || /SOCKS5?\s+([^\s;]+)/i.exec(p);
if (m) { const url = (/SOCKS/i.test(p) ? "socks5://" : "http://") + m[1].trim(); proxyEnv = { HTTP_PROXY: url, HTTPS_PROXY: url, http_proxy: url, https_proxy: url }; }
else proxyEnv = null;
} catch { proxyEnv = null; }
}
const rcloneEnv = () => ({ ...process.env, ...(proxyEnv || {}) });
// 깨진 IPv6 경로에서 rclone이 멈추는 것 방지 (설정 시 IPv4 강제)
const ipv4Bind = () => (conf.forceIpv4 !== false ? ["--bind", "0.0.0.0"] : []);
// ── rclone 엔진 (앱이 소유: userData/bin) ─────────────────────────
const RCLONE_DIR = path.join(app.getPath("userData"), "bin");
const RCLONE = path.join(RCLONE_DIR, isMac ? "rclone" : "rclone.exe");
const RCLONE_ARCH = isMac ? "osx-arm64" : "windows-amd64";
let rcloneReady = false;
let rcloneBusy = false; // 엔진 자동 다운로드 진행 중 여부(렌더러 스피너용)
function pushState() { try { if (win && !win.isDestroyed()) win.webContents.send("state", stateForRenderer()); } catch {} }
function haveRclone() { try { return fs.existsSync(RCLONE) && fs.statSync(RCLONE).size > 0; } catch { return false; } }
const rcloneExec = (args, cb) => execFile(RCLONE, [...args, ...ipv4Bind()], { timeout: 120000, maxBuffer: 8 * 1024 * 1024, env: rcloneEnv() }, cb);
// 다운로드 + SHA256 검증 (버전 확정 후 그 버전의 SHA256SUMS로 zip 해시 대조)
async function ensureRclone(onProgress) {
if (haveRclone()) { rcloneReady = true; return { ok: true }; }
rcloneBusy = true; pushState(); // 스피너 켜기
try {
fs.mkdirSync(RCLONE_DIR, { recursive: true });
const verTxt = (await (await netFetch("https://downloads.rclone.org/version.txt")).text()).trim(); // "rclone v1.7x.x"
const ver = verTxt.split(/\s+/).pop().replace(/^v/, "");
const base = `rclone-v${ver}-${RCLONE_ARCH}`;
const zipUrl = `https://downloads.rclone.org/v${ver}/${base}.zip`;
const sums = await (await netFetch(`https://downloads.rclone.org/v${ver}/SHA256SUMS`)).text();
const want = (sums.split(/\r?\n/).find(l => l.includes(`${base}.zip`)) || "").split(/\s+/)[0];
const buf = Buffer.from(await (await netFetch(zipUrl)).arrayBuffer());
const got = crypto.createHash("sha256").update(buf).digest("hex");
if (!want || want !== got) return { ok: false, err: L("err.hashFail") };
const zipPath = path.join(RCLONE_DIR, "rclone.zip");
fs.writeFileSync(zipPath, buf);
const exDir = path.join(RCLONE_DIR, "_ex");
fs.rmSync(exDir, { recursive: true, force: true });
await new Promise((res, rej) => {
if (isMac) execFile("/usr/bin/ditto", ["-x", "-k", zipPath, exDir], e => e ? rej(e) : res());
else execFile("powershell.exe", ["-NoProfile", "-Command", `Expand-Archive -LiteralPath '${zipPath.replace(/'/g, "''")}' -DestinationPath '${exDir.replace(/'/g, "''")}' -Force`], e => e ? rej(e) : res());
});
const binName = isMac ? "rclone" : "rclone.exe";
const found = path.join(exDir, base, binName);
fs.copyFileSync(found, RCLONE);
if (isMac) fs.chmodSync(RCLONE, 0o755);
rcloneReady = true;
return { ok: true };
} catch (e) { return { ok: false, err: String(e.message || e) }; }
finally { rcloneBusy = false; pushState(); } // 스피너 끄기
}
// ── 암호화 백업/복원 (AES-256-GCM + scrypt) ──────────────────────
// 백업에는 Filen 자격증명이 담긴 rclone.conf가 포함되므로 항상 비밀번호로 암호화.
function encryptPayload(obj, password) {
const salt = crypto.randomBytes(16), iv = crypto.randomBytes(12);
const key = crypto.scryptSync(password, salt, 32);
const c = crypto.createCipheriv("aes-256-gcm", key, iv);
const data = Buffer.concat([c.update(JSON.stringify(obj), "utf8"), c.final()]);
return JSON.stringify({ v: 1, salt: salt.toString("base64"), iv: iv.toString("base64"), tag: c.getAuthTag().toString("base64"), data: data.toString("base64") });
}
function decryptPayload(text, password) {
const e = JSON.parse(text);
const key = crypto.scryptSync(password, Buffer.from(e.salt, "base64"), 32);
const d = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(e.iv, "base64"));
d.setAuthTag(Buffer.from(e.tag, "base64"));
return JSON.parse(Buffer.concat([d.update(Buffer.from(e.data, "base64")), d.final()]).toString("utf8"));
}
function rcloneConfPath(cb) {
execFile(RCLONE, ["config", "file"], { env: rcloneEnv() }, (e, out) => {
if (e) return cb("");
cb((String(out).split(/\r?\n/).map(s => s.trim()).filter(Boolean).pop() || ""));
});
}
const BACKUP_FILE = "filenmount-backup.enc";
// 머신마다 개별로 두는 설정(백업에 포함 안 함, 복원 시 현재 머신 값 유지): 테마·마운트 위치·캐시경로·창위치·자동백업.
const MACHINE_KEYS = ["theme", "mountBase", "cacheDir", "windowBounds", "autoBackup"];
// 백업 실행(수동·자동 공용). rclone.conf(자격증명) 포함 → 비번으로 암호화.
function doBackup(password, dir) {
return new Promise(res => {
if (typeof password !== "string" || password.length < 4) return res({ ok: false, err: L("err.pwShort") });
const d = (dir && path.isAbsolute(dir)) ? dir : conf.backupDir;
if (!d) return res({ ok: false, err: L("err.needBackupDir") });
rcloneConfPath(cfp => {
let rc = ""; try { rc = fs.readFileSync(cfp, "utf8"); } catch {}
try {
fs.mkdirSync(d, { recursive: true });
const cfgForBackup = { ...conf }; MACHINE_KEYS.forEach(k => delete cfgForBackup[k]); // 테마·마운트 위치 등 머신 로컬 제외
fs.writeFileSync(path.join(d, BACKUP_FILE), encryptPayload({ v: 1, ts: Date.now(), config: cfgForBackup, rcloneConf: rc }, password));
conf.backupDir = d; conf.lastBackup = Date.now(); saveConf();
res({ ok: true, state: stateForRenderer() });
} catch (e) { res({ ok: false, err: String(e.message || e) }); }
});
});
}
// 자동 백업용 비밀번호는 OS 보안 저장소(safeStorage)로 로컬 암호화 저장(무인 실행 대비).
const BACKUP_PASS_PATH = path.join(app.getPath("userData"), "backup-pass.bin");
function saveBackupPass(pw) { try { if (pw && safeStorage.isEncryptionAvailable()) fs.writeFileSync(BACKUP_PASS_PATH, safeStorage.encryptString(pw)); } catch {} }
function getBackupPass() { try { return safeStorage.decryptString(fs.readFileSync(BACKUP_PASS_PATH)); } catch { return ""; } }
function clearBackupPass() { try { fs.unlinkSync(BACKUP_PASS_PATH); } catch {} }
function autoBackupTick() { if (!conf.autoBackup || !conf.backupDir) return; const pw = getBackupPass(); if (pw) doBackup(pw, conf.backupDir).then(() => pushState()); }
// ── 이름/경로 헬퍼 ────────────────────────────────────────────────
const groupRemote = (id) => "fmgroup_" + String(id).replace(/[^a-z0-9]/gi, "");
function slugName(s) {
let base = String(s || "filen").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "filen";
let name = base, i = 2;
const taken = new Set(conf.accounts.map(a => a.name));
while (taken.has(name)) name = `${base}-${i++}`;
return name;
}
function volNameOf(folder) {
const inner = String(folder || "").replace(/[()]/g, "").replace(/\s+/g, " ").trim();
return (inner ? `FilenMount (${inner})` : "FilenMount").slice(0, 60);
}
function mountPointOf(folder) { return path.join(conf.mountBase, folder); }
// 유효 그룹(현존 계정 2개 이상). 계정은 members 순서대로.
function activeGroups() {
return conf.groups.map(g => ({ ...g, members: (g.members || []).filter(n => conf.accounts.some(a => a.name === n)) }))
.filter(g => g.members.length >= 2);
}
// 실제 마운트 대상: 각 유효 그룹은 union 하나, 그룹에 안 속한 계정은 개별.
function mountTargets() {
const groups = activeGroups();
const grouped = new Set([].concat(...groups.map(g => g.members)));
const targets = groups.map(g => ({ remote: groupRemote(g.id), folder: g.name || "Filen", isGroup: true, gid: g.id }));
for (const a of conf.accounts) if (!grouped.has(a.name)) targets.push({ remote: a.name, folder: a.folder, account: a.name });
return targets;
}
// ── 마운트 매니저 ────────────────────────────────────────────────
const mounts = new Map(); // remote → {proc, port, status, pending, lastErr, errBuf, startedAt, miss, fails, nextRetry, retryStopped, needsReauth, about}
let nextPort = 5580;
const usedPorts = new Set(); // 예약된 RC 포트 — 동시 마운트가 같은 포트를 잡는 것 방지
const RC_USER = "filenmount";
const RC_PASS = crypto.randomBytes(18).toString("hex");
const RC_AUTH = "Basic " + Buffer.from(`${RC_USER}:${RC_PASS}`).toString("base64");
const rcFetch = (port, apiPath, opts = {}) => fetch(`http://127.0.0.1:${port}${apiPath}`, { method: "POST", ...opts, headers: { Authorization: RC_AUTH, ...(opts.headers || {}) } });
function isFree(p) {
return new Promise(res => { const srv = net.createServer(); srv.once("error", () => res(false)); srv.listen(p, "127.0.0.1", () => srv.close(() => res(true))); });
}
// 후보 포트를 usedPorts에 **동기적으로 먼저 예약**한 뒤 실사용 가능 여부 확인 → 동시에 여러 마운트가 시작돼도 같은 포트 충돌 없음.
async function allocPort() {
for (let i = 0; i < 2000; i++) {
const p = nextPort++;
if (nextPort > 64000) nextPort = 5580;
if (usedPorts.has(p)) continue;
usedPorts.add(p);
if (await isFree(p)) return p;
usedPorts.delete(p);
}
return nextPort++;
}
function releasePort(p) { if (p) usedPorts.delete(p); }
function mountFlags(remote, folder, port) {
const f = [
"mount", `${remote}:`, mountPointOf(folder),
"--volname", volNameOf(folder),
// VFS 풀 캐시: 로컬 완결 저장 후 백그라운드 업로드(지수 재시도). 캐시 영속·삭제 금지 → 강제종료·오프라인에도 재개.
"--vfs-cache-mode", "full",
"--vfs-cache-max-size", conf.cacheMaxSize,
"--vfs-cache-max-age", conf.cacheMaxAge,
"--vfs-cache-poll-interval", "1m",
"--cache-dir", conf.cacheDir || path.join(app.getPath("userData"), "vfs-cache"),
"--dir-cache-time", "1000h", "--poll-interval", "15s", "--vfs-write-back", "5s",
"--timeout", "30s", "--contimeout", "15s",
"--rc", "--rc-addr", `127.0.0.1:${port}`, "--rc-user", RC_USER, "--rc-pass", RC_PASS,
"--log-level", "INFO",
];
f.push(...ipv4Bind());
return f;
}
const RETRY_MAX = 6;
function markFail(remote) {
const m = mounts.get(remote); if (!m) return;
m.fails = (m.fails || 0) + 1;
m.nextRetry = Date.now() + Math.min(5 * 60e3, 5000 * 2 ** (m.fails - 1));
m.retryStopped = m.fails >= RETRY_MAX;
}
function extractErr(buf) {
if (!buf) return "";
const lines = buf.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
const hit = [...lines].reverse().find(l => /ERROR|Fatal|Failed|couldn'?t|not found|permission|denied|unauthor|invalid|expired|refused|timeout/i.test(l));
return (hit || lines[lines.length - 1] || "").replace(/^\d{4}\/\d{2}\/\d{2}\s[\d:]+\s*/, "").replace(/^(NOTICE|INFO|ERROR|DEBUG|WARNING)\s*:\s*/i, "").slice(0, 300);
}
async function mountOne(remote, folder) {
const cur = mounts.get(remote);
if (cur?.proc || cur?.starting) return;
const mp = mountPointOf(folder);
fs.mkdirSync(isMac ? mp : path.dirname(mp), { recursive: true });
if (!isMac) { try { fs.rmdirSync(mp); } catch {} }
else { await new Promise(res => execFile("/sbin/umount", [mp], () => res())); } // mac: 이전 크래시로 남은 stale 마운트 정리(정상이면 무해)
const reserve = cur || { status: "off", pending: 0, lastErr: "", fails: 0, nextRetry: 0, retryStopped: false };
reserve.starting = true; reserve.proc = null; reserve.folder = folder;
mounts.set(remote, reserve);
const port = await allocPort();
if (mounts.get(remote)?.starting !== true) { releasePort(port); return; }
const startedAt = Date.now();
const proc = spawn(RCLONE, mountFlags(remote, folder, port), { stdio: ["ignore", "ignore", "pipe"], env: rcloneEnv() });
const entry = { proc, port, folder, status: "off", pending: 0, lastErr: cur?.lastErr || "", errBuf: "", startedAt, miss: 0, fails: cur?.fails || 0, nextRetry: cur?.nextRetry || 0, retryStopped: cur?.retryStopped || false, needsReauth: false, about: cur?.about || null };
mounts.set(remote, entry);
proc.stderr?.on("data", d => {
const s = d.toString(); entry.errBuf = (entry.errBuf + s).slice(-2000);
if (/\b401\b|unauthenticated|unauthorized|token expired|cannot fetch token|invalid_grant/i.test(s)) entry.needsReauth = true;
});
proc.on("error", (e) => { entry.proc = null; releasePort(entry.port); entry.lastErr = e.message || "실행 실패"; markFail(remote); });
proc.on("exit", () => {
entry.proc = null; releasePort(entry.port);
if (Date.now() - startedAt >= 8000) { entry.fails = 0; entry.nextRetry = 0; entry.retryStopped = false; }
else { entry.lastErr = extractErr(entry.errBuf) || entry.lastErr; markFail(remote); }
});
}
// FUSE-T(mac)는 rclone 프로세스만 죽이면 NFS 볼륨이 stale로 남아 Finder가 "연결이 끊겼다" 오류를 띄움.
// SIGTERM으로 rclone이 스스로 해제하게 두되, 남으면 명시적 umount → 안 되면 강제 언마운트로 정리.
function forceUnmount(mp) {
if (!isMac || !mp) return; // Windows: WinFsp가 프로세스 종료 시 자동 정리
// 절대경로 사용 — GUI 앱은 PATH에 /sbin·/usr/sbin이 없을 수 있음
try { execFile("/sbin/umount", [mp], (e) => { if (e) execFile("/usr/sbin/diskutil", ["unmount", "force", mp], () => {}); }); } catch {}
}
function unmountOne(remote) {
const m = mounts.get(remote);
if (m) m.starting = false;
const mp = m ? mountPointOf(m.folder) : null;
if (m?.proc) { try { m.proc.kill("SIGTERM"); } catch {} m.proc = null; }
if (m) { m.status = "off"; releasePort(m.port); }
if (mp) setTimeout(() => forceUnmount(mp), 2500);
}
function resetRetry(remote) { const m = mounts.get(remote); if (m) { m.fails = 0; m.nextRetry = 0; m.retryStopped = false; } }
function mountAll() {
if (!rcloneReady) return;
const want = new Set(mountTargets().map(t => t.remote));
for (const remote of [...mounts.keys()]) if (!want.has(remote)) unmountOne(remote); // 대상 아닌 마운트 정리
mountTargets().forEach(t => { resetRetry(t.remote); mountOne(t.remote, t.folder); });
}
const unmountAll = () => [...mounts.keys()].forEach(unmountOne);
// ── 폴링 + 워치독 + 트레이 ────────────────────────────────────────
let prevAgg = "ok", pollCount = 0, lastPushSig = "";
async function poll() {
const wantAbout = pollCount % 120 === 0; pollCount++;
const targets = mountTargets();
for (const t of targets) {
const m = mounts.get(t.remote);
if (m && m.starting) continue;
if (!m || !m.proc) { if (m) m.status = "err"; continue; }
try {
const j = await (await rcFetch(m.port, "/vfs/stats", { signal: AbortSignal.timeout(4000) })).json();
const d = j.diskCache || {};
m.pending = (d.uploadsInProgress || 0) + (d.uploadsQueued || 0);
const core = await (await rcFetch(m.port, "/core/stats", { signal: AbortSignal.timeout(4000) })).json();
m.lastErr = core.fatalError ? (core.lastError || "fatal") : "";
m.status = m.lastErr ? "err" : m.pending > 0 ? "warn" : "ok";
if (m.status === "ok") m.needsReauth = false;
m.miss = 0;
if (wantAbout || !m.about) {
try {
const ab = await (await rcFetch(m.port, "/operations/about", { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fs: `${t.remote}:` }), signal: AbortSignal.timeout(8000) })).json();
if (ab && ab.total > 0) { const total = ab.total; const used = Math.max(0, Math.min(total, ab.used ?? (total - (ab.free || 0)))); m.about = { total, used, free: Math.max(0, total - used) }; }
else if (ab) { m.about = { total: 0, used: 0, free: 0, none: true }; } // 용량 미제공 계정(VPN 가입 등) — 그래프 대신 안내 표시
} catch {}
}
} catch {
m.miss = (m.miss || 0) + 1;
if (Date.now() - (m.startedAt || 0) < 8000) { /* 초기화 중 */ }
else if (m.miss >= 3) m.status = "err";
}
}
// 워치독: 죽은 것만 되살림
if (rcloneReady) targets.forEach(t => { const m = mounts.get(t.remote); if (m?.proc) return; if (m && (m.retryStopped || Date.now() < (m.nextRetry || 0))) return; mountOne(t.remote, t.folder); });
const sts = targets.map(t => mounts.get(t.remote)?.status || "off");
const agg = sts.includes("err") ? "err" : sts.includes("warn") ? "warn" : sts.includes("ok") ? "ok" : "off";
tray?.setImage(trayIcon(agg));
if (agg === "err" && prevAgg !== "err") notify(L("notify.errTitle"), L("notify.errBody"));
if (agg === "ok" && (prevAgg === "err" || prevAgg === "off")) notify(L("notify.okTitle"), L("notify.okBody"));
prevAgg = agg;
// 상태가 실제로 바뀔 때만 트레이 재빌드 + 렌더러 전송(불필요한 IPC/DOM 갱신 방지)
const st = stateForRenderer();
const sig = JSON.stringify({
a: st.accounts.map(a => [a.name, a.status, a.needsReauth, a.desc, a.inGroup, a.about && a.about.used, a.about && a.about.total]),
g: st.groups.map(g => [g.id, g.name, g.status, g.members.join(","), g.about && g.about.used, g.about && g.about.total]),
r: st.rcloneReady, l: conf.language,
});
if (sig !== lastPushSig) { lastPushSig = sig; buildTrayMenu(); win?.webContents.send("state", st); }
}
// ── Filen 계정 / union 관리 ───────────────────────────────────────
// config create를 spawn으로: OAuth는 없지만 프롬프트 방지 위해 stdin 닫고, 실패 시 원문 반환
function configCreate(args, cb) {
let out = "", done = false;
const finish = (err) => { if (done) return; done = true; clearTimeout(timer); cb(err, out); };
const proc = spawn(RCLONE, [...args, ...ipv4Bind()], { stdio: ["pipe", "pipe", "pipe"], env: rcloneEnv() });
const timer = setTimeout(() => { try { proc.kill("SIGTERM"); } catch {} finish(new Error("timeout")); }, 120000);
proc.stdout.on("data", d => { out += d; });
proc.stderr.on("data", d => { out += d; });
proc.stdin.on("error", () => {});
try { proc.stdin.end(); } catch {}
proc.on("error", e => finish(e));
proc.on("close", code => finish(code === 0 ? null : new Error((out || `exit ${code}`).trim())));
}
// 한 그룹의 union 리모트 재생성. members 순서 = 우선순위(첫 번째=대표, create_policy=ff → 새 파일 먼저 채움).
function rebuildGroup(g, cb) {
const members = (g.members || []).filter(n => conf.accounts.some(a => a.name === n));
if (members.length < 2) { rcloneExec(["config", "delete", groupRemote(g.id)], () => cb && cb()); return; }
const upstreams = members.map(n => `${n}:`).join(" ");
rcloneExec(["config", "delete", groupRemote(g.id)], () => {
configCreate(["config", "create", groupRemote(g.id), "union", `upstreams=${upstreams}`, "create_policy=ff", "search_policy=ff", "action_policy=epall"], () => cb && cb());
});
}
// 모든 그룹 순차 재생성 후 콜백.
function rebuildAllGroups(cb) {
const gs = conf.groups.slice();
(function next(i) { if (i >= gs.length) return cb && cb(); rebuildGroup(gs[i], () => next(i + 1)); })(0);
}
const randId = () => crypto.randomBytes(5).toString("hex");
// ── 트레이 / 아이콘 ──────────────────────────────────────────────
let tray = null, win = null;
const DOT = { ok: "#22c55e", warn: "#f5a623", err: "#ec4250", off: "#8a8f98" };
// 트레이 아이콘은 PNG(base64) 임베드 — Electron nativeImage는 SVG 데이터URL을 디코드하지 못해 빈 아이콘이 됨.
const TRAY_TPL_1X='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAABmJLR0QA/wD/AP+gvaeTAAABsklEQVQ4jYXUz4tPYRQG8M9cdzaEMRbEgoUZJb/Kworky8ZiMDIbPzayZCHxJ7CxYOPXQorFpGRjoTRMs0BSJEOhUEyRMSI0NF+L93zrnW/38tTp3nt6n+c95zmdSzW24iJe4HvEc1zAlhrONPRiGF9wBn1YHdGH0xjHXfTkxCJ734j76EQ/LuN9fM/EbjzEErzGA2yoqmQcv+LZHl/RxL04vw5T+IxludAwfuJ8TcsF9mFFJjSBc7gDpWTsSlzNiAuwqk1sDIsiWv4cxxs0SgyEyCRmx4HDOIRPNRXCE6nlKxgoJcOOSWa30IFrOPAPoRZu42QRpb5tE9mJbZiV5YuoftrYg7u4rLllKqIjyzUwKE1uL26FeCfJ7A9YmhGaUru9WI79eIdLkmc3MR8Lo/JN6C8xErdNZmJHcBQ/ME8a9Sl8xLM481vyZztGiih3D7oyoRnSJLuxS1qPKnQFd7AI1aeRqML1qLoKJ/AYQy2zD0q7M6eGUIW50hTXk8yGl1KvN3D2PwLdkskT2IFXVYd6pN1p4pH0F1iLNUEawx8MaVvWOjSkBR7Ft4jRyG2uIvwFQaRkwJB5QfgAAAAASUVORK5CYII=';
const TRAY_TPL_2X='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAABmJLR0QA/wD/AP+gvaeTAAAD9UlEQVRYhb3Ya4hVVRQH8N+MV0enMlK0wqyEHiMRZM8xsaBELWKCyt6fojAijcqiD32Lgh5UmD2VqOiJBEZ9yB5SOdbkFCGFo0E1DT2mlLJRaJR0+rD36Zx75t5z752Z/MNhv9f+77XXXmvv06RxnIBFmIc2HIfDYtsA+rANnViP70YwR02UcB0+xVCD3yZci3H1TNRUR5/FWIkTM3WD+Axd6MfOWD8NR2Iu2jExM2Y7luO9eohVQivWKF/tJ7gsN1E1TMIVwtZlZTwX2xrCdHyZEfINzq1z7ClYilMzdeehJyOvW9DmMFTasumCJk6O5X48jb11kJmMFZiAf4Tt/jC2TcTzuCaWt2M+dhQJbFWumdF+L+bkL8+1d8ttfyk3YCVOz5R/Vp9mEhwiGHWC76v024fxOBOP4+ZKnRYbvsIzGiBDWO1L+AkvRIJZJBrqjUSSeRbkBZXwbWz8YRSEaiFLqAVfxXKP6KeaY8erpX7mnjEmUQ17cXvMt2EJqQ3dEtN3hRNWDUuMTmv5sR9ho3DabsXrhNiUbNHlOFrlLZuM/UZ22vJf1tgvjXUHMKuEhbFhAG9japXVtUi3uBt/FWiiFtZm8h8I29eScHk1MkxiTDUNTcvUzx0FmUp4P8p9uRmzY2XXGE/SCLpjOrsZM2PhlxqDrs/kbxxjQv0xnUnYvyHx2Km8ZROwR7lhFp22FjyKzXgoji/ClVHmYD50FCF/wdpX0PcuqY85C7/jERyD1YZH+sOTTAm7hZNV7XQlk68Q3H0JT+Lrgv5tufKcmHYIIaoaBkrCHXgqZhR0FEm8Ilyufs3UHyEc1x3YEOvWCteMZsF3vRbrE7exC89mZFwgaLKvJMSROTinBiE4Cadlyk24Uxp27se9eEu44F8keN93cnL+UB6iEpfTU5JewtsVG18rPlZ8fV0aCZHa2GBBf3HOxK91NgsrGRKeMh0FAydlyOzGn4Lqs+irMXkldODQyGF9s/BuSpxivf5lIaYI9nM3fhOc2w0jIHRTTDehNzGyVTFdhAsbFPgwjsLZ2NLg2POlsfQJUqt/Q7h0w30NCh0pxuOxmN+KN7OE9mNZzB9/kAg9IPVPyyKH/wgRIu7qg0RmhuAuCE+sxH+VEYLb8EWm3P4/EWoRfNjnuCPbkCf0Ny6W2tMqPGj4c2kkKCkPyNtwidp+CiH4bZZG9i3Sq+aQ+jS3LvZdIzzDuzPju1R5ShdhEp5R+U7cCKFdubFPKfD29fyOWSD4iGwE3yOssksItDvjZFNwrPAinaf8obhVOE0bjAHGCdG7U3gd1Pu6OCA8c64y3F4roh4N5TFL8K7zhft4/pfej4LBbhTiZG8jwv8FcCwvEokHeywAAAAASUVORK5CYII=';
// ok
// warn
// err
// off
const DOT_PNG={"ok": {"1": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAQElEQVR4nGNgGAWM+CSVjsb9h7HvWS/CqharILJGdIBuEBN+BxIGGAbgsx2bPPVdQLEBuEIblzzFLqA4HQwDAAB3hRQQG7QDaAAAAABJRU5ErkJggg==", "2": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAeklEQVR4nGNgGAWjYBQMMGAkV6PS0bj/6GL3rBeRbB7JGrBZTIlDiFZIjMXkOISJVpYTq48oB9ASEHQAub4nVv/gDgFKfU+MOYM7BEYdMOAOIKdsJ9WcwR0CDAyUhwIh/YM/BBgYyA8FYvQNnfYAMQ6hVq4ZBaNgZAEAy5osIuPifPMAAAAASUVORK5CYII="}, "warn": {"1": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAQElEQVR4nGNgGAWM+CS/LlP+D2NzR93FqharILJGdIBuEBN+BxIGGAbgsx2bPPVdQLEBuEIblzzFLqA4HQwDAABIfhQQAE0pVwAAAABJRU5ErkJggg==", "2": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAeklEQVR4nGNgGAWjYBQMMGAkV+PXZcr/0cW4o+6SbB7JGrBZTIlDiFZIjMXkOISJVpYTq48oB9ASEHQAub4nVv/gDgFKfU+MOYM7BEYdMOAOIKdsJ9WcwR0CDAyUhwIh/YM/BBgYyA8FYvQNnfYAMQ6hVq4ZBaNgZAEA9mcsIobrbGoAAAAASUVORK5CYII="}, "err": {"1": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAQElEQVR4nGNgGAWM+CTfOAX8h7FF9m3AqharILJGdIBuEBN+BxIGGAbgsx2bPPVdQLEBuEIblzzFLqA4HQwDAADZ7xQQilmE3AAAAABJRU5ErkJggg==", "2": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAeklEQVR4nGNgGAWjYBQMMGAkV+Mbp4D/6GIi+zaQbB7JGrBZTIlDiFZIjMXkOISJVpYTq48oB9ASEHQAub4nVv/gDgFKfU+MOYM7BEYdMOAOIKdsJ9WcwR0CDAyUhwIh/YM/BBgYyA8FYvQNnfYAMQ6hVq4ZBaNgZAEA/34sIidN14sAAAAASUVORK5CYII="}, "off": {"1": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAQElEQVR4nGNgGAWM+CS7+mf8h7HLCjOwqsUqiKwRHaAbxITfgYQBhgH4bMcmT30XUGwArtDGJU+xCyhOB8MAAAAyDBQQbKp4XwAAAABJRU5ErkJggg==", "2": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAeklEQVR4nGNgGAWjYBQMMGAkV2NX/4z/6GJlhRkkm0eyBmwWU+IQohUSYzE5DmGileXE6iPKAbQEBB1Aru+J1T+4Q4BS3xNjzuAOgVEHDLgDyCnbSTVncIcAAwPloUBI/+APAQYG8kOBGH1Dpz1AjEOolWtGwSgYWQAAjEYsIhDKOtMAAAAASUVORK5CYII="}};
function trayIcon(agg) {
if (isMac) { // 템플릿(단색) — OS가 메뉴바 테마에 맞춰 틴트. 레티나용 @2x 표현 추가.
const im = nativeImage.createFromDataURL(TRAY_TPL_1X);
im.addRepresentation({ scaleFactor: 2, dataURL: TRAY_TPL_2X });
im.setTemplateImage(true);
return im;
}
const d = DOT_PNG[agg] || DOT_PNG.off; // Windows: 상태색 점
const im = nativeImage.createFromDataURL(d["1"]);
im.addRepresentation({ scaleFactor: 2, dataURL: d["2"] });
return im;
}
// 트레이 메뉴 항목 옆 상태색 점(작게)
function statusDot(status) {
const d = DOT_PNG[status] || DOT_PNG.off;
return nativeImage.createFromDataURL(d["1"]).resize({ width: 12, height: 12 });
}
function buildTrayMenu() {
if (!tray) return;
const targets = mountTargets();
const items = targets.map(t => ({ label: (t.isGroup ? "⊕ " : "") + t.folder, icon: statusDot(mounts.get(t.remote)?.status || "off"), click: () => shell.openPath(mountPointOf(t.folder)) }));
tray.setContextMenu(Menu.buildFromTemplate([
{ label: L("tray.open"), click: showWindow },
{ type: "separator" },
...(items.length ? items : [{ label: L("tray.noAccounts"), enabled: false }]),
{ type: "separator" },
{ label: L("tray.mountAll"), click: mountAll },
{ label: L("tray.unmountAll"), click: unmountAll },
{ type: "separator" },
{ label: L("tray.quit"), click: () => app.quit() },
]));
}
function notify(title, body, onClick) { try { const n = new Notification({ title, body }); n.on("click", onClick || showWindow); n.show(); } catch {} }
// ── 자동 업데이트 확인 (GitHub Releases) ──────────────────────────
const UPDATE_REPO = "catgarret/FilenMount";
function cmpVer(a, b) { const pa = String(a).split("."), pb = String(b).split("."); for (let i = 0; i < 3; i++) { const x = parseInt(pa[i] || 0), y = parseInt(pb[i] || 0); if (x > y) return 1; if (x < y) return -1; } return 0; }
async function checkUpdate() {
try {
const j = await (await netFetch(`https://api.github.com/repos/${UPDATE_REPO}/releases/latest`)).json();
const tag = String(j.tag_name || "").replace(/^v/, "");
if (tag && cmpVer(tag, app.getVersion()) > 0)
notify(L("notify.updateTitle"), L("notify.updateBody", { v: tag }), () => shell.openExternal(j.html_url || `https://github.com/${UPDATE_REPO}/releases`));
} catch {}
}
// ── 렌더러 상태 ──────────────────────────────────────────────────
function stateForRenderer() {
const groups = activeGroups().map(g => {
const m = mounts.get(groupRemote(g.id)) || {};
return { id: g.id, name: g.name, members: g.members, status: m.status || "off", about: m.about || null, needsReauth: !!m.needsReauth, lastErr: m.lastErr || "" };
});
const grouped = new Set([].concat(...groups.map(g => g.members)));
const accounts = conf.accounts.map(a => {
const m = mounts.get(a.name) || {};
const inGroup = grouped.has(a.name);
return { name: a.name, email: a.email, folder: a.folder, desc: a.desc || "", inGroup, status: inGroup ? "" : (m.status || "off"), needsReauth: !inGroup && !!m.needsReauth, lastErr: inGroup ? "" : (m.lastErr || ""), about: inGroup ? null : (m.about || null) };
});
return { accounts, groups, conf, mountBase: conf.mountBase, isMac, rcloneReady, rcloneDownloading: rcloneBusy, appVersion: app.getVersion(), repoUrl: `https://github.com/${UPDATE_REPO}`, i18n: locales[conf.language] || locales.ko, languages };
}
// ── IPC ──────────────────────────────────────────────────────────
ipcMain.handle("state", () => stateForRenderer());
ipcMain.handle("ensure-rclone", async () => { const r = await ensureRclone(); if (r.ok) mountAll(); return { ...r, state: stateForRenderer() }; });
ipcMain.handle("add-account", (_, { email, password, apiKey, desc }) => new Promise(res => {
if (typeof email !== "string" || !email.includes("@")) return res({ ok: false, err: L("err.email") });
if (!password || !apiKey) return res({ ok: false, err: L("err.needPwKey") });
const name = slugName(email.split("@")[0] + "-filen");
// 마운트 폴더명 = 이메일(안전하면). Finder/탐색기에 계정별로 구분되어 보임.
const fld = isSafeFolder(email) ? email : (email.split("@")[0] || "filen").replace(/[^\w.-]/g, "-").slice(0, 60);
const args = ["config", "create", name, "filen", `email=${email}`, `password=${password}`, `api_key=${apiKey}`, "--obscure"];
configCreate(args, (e) => {
if (e) return res({ ok: false, err: String(e.message || e).slice(0, 400) });
conf.accounts.push({ name, email, folder: fld, desc: (typeof desc === "string" ? desc.slice(0, 120) : "") });
saveConf();
mountAll(); // 새 계정은 개별 마운트(그룹 편입은 사용자가 별도로)
res({ ok: true, state: stateForRenderer() });
});
}));
ipcMain.handle("remove-account", (_, name) => new Promise(res => {
if (!isValidName(name) || !conf.accounts.some(a => a.name === name)) return res({ ok: false });
unmountOne(name);
rcloneExec(["config", "delete", name], () => {
conf.accounts = conf.accounts.filter(a => a.name !== name);
// 그룹에서도 제거, 멤버 1개 이하로 줄면 그룹 해제
conf.groups.forEach(g => { g.members = (g.members || []).filter(n => n !== name); });
conf.groups = conf.groups.filter(g => (g.members || []).length >= 2);
saveConf();
rebuildAllGroups(() => { mountAll(); res({ ok: true, state: stateForRenderer() }); });
});
}));
ipcMain.handle("reconnect-account", (_, name) => new Promise(res => {
if (!isValidName(name) || !conf.accounts.some(a => a.name === name)) return res({ ok: false });
unmountOne(name);
configCreate(["config", "reconnect", `${name}:`], (e) => {
const m = mounts.get(name); if (m) m.needsReauth = false;
resetRetry(name); mountAll();
res({ ok: !e, err: e ? String(e.message || e) : "", state: stateForRenderer() });
});
}));
// 그룹 생성: 2개 이상 계정을 묶어 하나의 드라이브로. 이미 다른 그룹에 속한 계정은 제외.
ipcMain.handle("create-group", (_, { members, name }) => new Promise(res => {
const grouped = new Set([].concat(...conf.groups.map(g => g.members || [])));
const mem = (Array.isArray(members) ? members : []).filter(n => isValidName(n) && conf.accounts.some(a => a.name === n) && !grouped.has(n));
if (mem.length < 2) return res({ ok: false, err: L("err.needTwo") });
const nm = (typeof name === "string" && isSafeFolder(name)) ? name : "Filen";
conf.groups.push({ id: randId(), name: nm, members: mem }); saveConf();
rebuildAllGroups(() => { mountAll(); res({ ok: true, state: stateForRenderer() }); });
}));
// 그룹 수정: 이름 변경 / 멤버 순서·구성 변경(드래그로 순서 = 우선순위). members는 그룹 내 최종 순서.
ipcMain.handle("update-group", (_, { id, name, members }) => new Promise(res => {
const g = conf.groups.find(x => x.id === id);
if (!g) return res({ ok: false });
if (typeof name === "string" && isSafeFolder(name)) g.name = name;
if (Array.isArray(members)) {
const grouped = new Set([].concat(...conf.groups.filter(x => x.id !== id).map(x => x.members || [])));
g.members = members.filter(n => isValidName(n) && conf.accounts.some(a => a.name === n) && !grouped.has(n));
}
conf.groups = conf.groups.filter(x => (x.members || []).length >= 2 || x.id === id);
saveConf();
// 멤버가 1개 이하가 되면 그 union은 해제되고 계정은 개별 마운트로 돌아감
if ((g.members || []).length < 2) { unmountOne(groupRemote(id)); conf.groups = conf.groups.filter(x => x.id !== id); saveConf(); }
rebuildAllGroups(() => { mountAll(); res({ ok: true, state: stateForRenderer() }); });
}));
// 계정 설명(별칭) 수정
ipcMain.handle("update-account", (_, { name, desc }) => new Promise(res => {
const a = conf.accounts.find(x => x.name === name);
if (a && typeof desc === "string") { a.desc = desc.slice(0, 120); saveConf(); }
res({ ok: true, state: stateForRenderer() });
}));
// 개별 계정 순서 변경(드래그) — 표시 순서만 저장. 마운트에는 영향 없음.
ipcMain.handle("reorder-accounts", (_, names) => new Promise(res => {
if (!Array.isArray(names)) return res({ ok: false, state: stateForRenderer() });
const ordered = names.filter(n => conf.accounts.some(a => a.name === n));
conf.accounts.forEach(a => { if (!ordered.includes(a.name)) ordered.push(a.name); });
conf.accounts.sort((a, b) => ordered.indexOf(a.name) - ordered.indexOf(b.name));
saveConf();
res({ ok: true, state: stateForRenderer() });
}));
// 그룹 표시 순서 변경(드래그) — conf.groups 순서만 저장. 마운트에는 영향 없음.
ipcMain.handle("reorder-groups", (_, ids) => new Promise(res => {
if (!Array.isArray(ids)) return res({ ok: false, state: stateForRenderer() });
const ordered = ids.filter(id => conf.groups.some(g => g.id === id));
conf.groups.forEach(g => { if (!ordered.includes(g.id)) ordered.push(g.id); });
conf.groups.sort((a, b) => ordered.indexOf(a.id) - ordered.indexOf(b.id));
saveConf();
res({ ok: true, state: stateForRenderer() });
}));
// 그룹 해제: union 마운트 해제 후 멤버들은 개별 마운트로.
ipcMain.handle("delete-group", (_, id) => new Promise(res => {
const g = conf.groups.find(x => x.id === id);
if (g) unmountOne(groupRemote(id));
rcloneExec(["config", "delete", groupRemote(id)], () => {
conf.groups = conf.groups.filter(x => x.id !== id); saveConf();
mountAll(); res({ ok: true, state: stateForRenderer() });
});
}));
const CONF_KEYS = new Set(["mountBase", "cacheDir", "cacheMaxSize", "cacheMaxAge", "autoLaunch", "startMinimized", "minimizeToTray", "forceIpv4", "language", "theme", "backupDir"]);
ipcMain.handle("set-conf", (_, patch) => {
if (!patch || typeof patch !== "object") return stateForRenderer();
const clean = {}; for (const [k, v] of Object.entries(patch)) if (CONF_KEYS.has(k)) clean[k] = v;
if ("mountBase" in clean && (typeof clean.mountBase !== "string" || !path.isAbsolute(clean.mountBase))) delete clean.mountBase;
if ("backupDir" in clean && clean.backupDir !== "" && (typeof clean.backupDir !== "string" || !path.isAbsolute(clean.backupDir))) delete clean.backupDir;
conf = { ...conf, ...clean }; saveConf();
if ("autoLaunch" in clean) app.setLoginItemSettings({ openAtLogin: conf.autoLaunch, openAsHidden: true });
if ("language" in clean) buildTrayMenu();
if ("theme" in clean) nativeTheme.themeSource = conf.theme || "system";
if ("forceIpv4" in clean || "mountBase" in clean) { unmountAll(); setTimeout(mountAll, 800); }
return stateForRenderer();
});
ipcMain.handle("backup-status", () => ({ backupDir: conf.backupDir || "", lastBackup: conf.lastBackup || 0 }));
ipcMain.handle("backup", (_, { password, dir }) => doBackup(password, dir));
// 자동 백업 켜기/끄기 — 켤 때 비번을 safeStorage에 저장하고 즉시 1회 백업, 끄면 저장된 비번 삭제.
ipcMain.handle("set-auto-backup", async (_, { enabled, password }) => {
if (enabled) {
if (typeof password !== "string" || password.length < 4) return { ok: false, err: L("err.pwShort") };
if (!conf.backupDir) return { ok: false, err: L("err.needBackupDir") };
saveBackupPass(password);
conf.autoBackup = true; saveConf();
const r = await doBackup(password, conf.backupDir);
return { ...r, state: stateForRenderer() };
}
conf.autoBackup = false; saveConf(); clearBackupPass();
return { ok: true, state: stateForRenderer() };
});
ipcMain.handle("restore", (_, { password, dir }) => new Promise(res => {
const d = (dir && path.isAbsolute(dir)) ? dir : conf.backupDir;
const fp = d ? path.join(d, BACKUP_FILE) : "";
let text = ""; try { text = fs.readFileSync(fp, "utf8"); } catch { return res({ ok: false, err: L("err.backupNotFound") }); }
let payload; try { payload = decryptPayload(text, password); } catch { return res({ ok: false, err: L("err.badPassword") }); }
rcloneConfPath(cfp => {
try { if (payload.rcloneConf && cfp) { fs.mkdirSync(path.dirname(cfp), { recursive: true }); fs.writeFileSync(cfp, payload.rcloneConf); } } catch {}
const keep = {}; MACHINE_KEYS.forEach(k => { if (k in conf) keep[k] = conf[k]; }); // 현재 머신의 테마·마운트 위치 등 유지
conf = sanitizeConf({ ...payload.config, ...keep }); saveConf();
unmountAll(); setTimeout(mountAll, 1200);
res({ ok: true, state: stateForRenderer() });
});
}));
ipcMain.handle("open-mount", (_, folder) => { if (isSafeFolder(folder)) shell.openPath(mountPointOf(folder)); });
ipcMain.handle("open-external", (_, u) => { if (typeof u === "string" && /^https:\/\//i.test(u)) shell.openExternal(u); });
ipcMain.handle("pick-dir", async () => { const r = await dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"] }); return r.canceled ? null : r.filePaths[0]; });
ipcMain.handle("check-update", async () => {
try {
const j = await (await netFetch(`https://api.github.com/repos/${UPDATE_REPO}/releases/latest`)).json();
const tag = String(j.tag_name || "").replace(/^v/, "");
return { ok: true, version: app.getVersion(), latest: tag, hasUpdate: !!(tag && cmpVer(tag, app.getVersion()) > 0), url: j.html_url || `https://github.com/${UPDATE_REPO}/releases` };
} catch (e) { return { ok: false, err: String(e.message || e) }; }
});
ipcMain.handle("quit", () => app.quit());
// ── 창 / 앱 생명주기 ──────────────────────────────────────────────
let saveBoundsTimer = null;
function saveWindowBounds() {
if (!win || win.isDestroyed() || win.isMinimized() || win.isFullScreen()) return;
clearTimeout(saveBoundsTimer);
saveBoundsTimer = setTimeout(() => { try { conf.windowBounds = win.getBounds(); saveConf(); } catch {} }, 400);
}
function showWindow() {
if (win) { win.show(); win.focus(); return; }
const b = conf.windowBounds && typeof conf.windowBounds === "object" ? conf.windowBounds : {};
win = new BrowserWindow({
width: Math.max(720, b.width || 920), height: Math.max(520, b.height || 660),
...(Number.isInteger(b.x) && Number.isInteger(b.y) ? { x: b.x, y: b.y } : {}),
minWidth: 720, minHeight: 520,
icon: path.join(__dirname, "build", "icon.png"),
titleBarStyle: isMac ? "hiddenInset" : "default",
backgroundColor: "#0b0b0f",
webPreferences: { preload: path.join(__dirname, "preload.js"), nodeIntegration: false, contextIsolation: true, sandbox: false },
});
win.loadFile(path.join(__dirname, "renderer", "index.html"));
// 진단: 렌더러 콘솔/크래시/preload 오류를 userData/logs에 기록 (blank 화면 원인 추적용)
const LOGDIR = path.join(app.getPath("userData"), "logs");
const logLine = (s) => { try { fs.mkdirSync(LOGDIR, { recursive: true }); fs.appendFileSync(path.join(LOGDIR, "filenmount.log"), `[${new Date().toISOString()}] ${s}\n`); } catch {} };
win.webContents.on("console-message", (_e, _lvl, message, line, src) => logLine(`console: ${message} (${src}:${line})`));
win.webContents.on("preload-error", (_e, f, err) => logLine(`preload-error: ${f} ${err && err.stack || err}`));
win.webContents.on("render-process-gone", (_e, d) => logLine(`render-process-gone: ${JSON.stringify(d)}`));
win.webContents.on("did-finish-load", () => { try { win.webContents.send("state", stateForRenderer()); } catch (e) { logLine("state-push-fail: " + (e && e.stack || e)); } });
if (process.env.FM_DEBUG || !app.isPackaged) win.webContents.openDevTools({ mode: "detach" });
win.webContents.on("will-navigate", e => e.preventDefault());
win.webContents.setWindowOpenHandler(({ url }) => { if (/^https:\/\//i.test(url)) shell.openExternal(url); return { action: "deny" }; });
win.on("close", e => {
if (app.isQuitting) return;
if (conf.minimizeToTray !== false) { e.preventDefault(); win.hide(); } // 트레이로 최소화(백그라운드 유지, Dock 아이콘 사라짐)
else if (!isMac) app.quit(); // 트레이 옵션 OFF — Windows는 닫으면 종료(관례)
// mac + OFF: 기본 동작 — 창만 닫히고 앱은 Dock에 남아 계속 실행(맥 관례). Dock 유지, Cmd+Q로 종료.
});
win.on("resize", saveWindowBounds); win.on("move", saveWindowBounds);
win.on("closed", () => { win = null; }); // Dock은 숨기지 않음(OFF 모드에서 앱이 살아있음이 보이도록)
// Dock 아이콘은 창이 보일 때만 표시 → 닫아서 트레이로 가면 Dock에서 사라지고 메뉴바만 남음(맥)
if (isMac) { win.on("show", () => app.dock?.show()); win.on("hide", () => app.dock?.hide()); app.dock?.show(); }
}
app.whenReady().then(async () => {
if (isFirstRun) { conf.language = pickLocale(app.getLocale()); saveConf(); } // 첫 실행: OS 언어 자동 선택
await computeProxyEnv();
nativeTheme.themeSource = conf.theme || "system";
if (isMac) app.dock?.hide();
tray = new Tray(trayIcon("off"));
tray.setToolTip("FilenMount");
buildTrayMenu();
tray.on("double-click", showWindow);
if (!isMac) tray.on("click", showWindow);
app.setLoginItemSettings({ openAtLogin: conf.autoLaunch !== false, openAsHidden: true });
// 창을 먼저 띄워 UI가 즉시 뜨게 하고(엔진 준비 화면), rclone 다운로드는 백그라운드로.
if (isFirstRun || !conf.accounts.length || conf.startMinimized === false) showWindow();
ensureRclone().then(r => { if (r.ok) mountAll(); if (win && !win.isDestroyed()) win.webContents.send("state", stateForRenderer()); }).catch(() => {});
poll(); setInterval(poll, 5000);
setTimeout(checkUpdate, 10000); setInterval(checkUpdate, 24 * 3600e3); // 자동 업데이트 확인(시작 10초 후 + 24h)
setTimeout(autoBackupTick, 60000); setInterval(autoBackupTick, 6 * 3600e3); // 자동 백업(시작 1분 후 + 6h 주기)
});
app.on("activate", () => showWindow());
app.on("window-all-closed", () => {});
let gracefulQuit = false;
app.on("before-quit", (e) => {
app.isQuitting = true;
const live = [...mounts.values()].filter(m => m.proc);
if (gracefulQuit || !live.length) return;
gracefulQuit = true; e.preventDefault();
const mps = live.map(m => mountPointOf(m.folder));
live.forEach(m => { try { m.proc.kill("SIGTERM"); } catch {} });
let waited = 0;
const iv = setInterval(() => {
waited += 500;
if (![...mounts.values()].some(m => m.proc) || waited >= 15000) {
clearInterval(iv);
mps.forEach(forceUnmount); // 잔여 볼륨 명시적 정리 후 종료
setTimeout(() => app.exit(0), 400);
}
}, 500);
});