Repository navigation
Expand file tree
/
Copy pathlauncherService.ts
More file actions
828 lines (742 loc) · 25.5 KB
/
Copy pathlauncherService.ts
File metadata and controls
828 lines (742 loc) · 25.5 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
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
// ========== Types ==========
export type GameChannel =
| "official"
| "bilibili"
| "global"
| "google_play"
| "arknights"
| "arknights_bilibili";
export interface GameStatus {
is_installed: boolean;
has_update: boolean;
local_version: string | null;
remote_version: string | null;
has_preload: boolean;
preload_version: string | null;
preload_installable: boolean;
/** 预下载分卷需下载的总字节数(无预下载时为 0) */
preload_total_bytes: number;
preload_completed: boolean;
active_operation: ActiveOperation | null;
}
export interface ActiveOperation {
type: "installing" | "verifying" | "repairing" | "switching";
stage?: string;
phase?: string;
downloaded?: number;
total?: number;
current_file?: string | null;
file_index?: number;
file_count?: number;
verified_bytes?: number;
from_channel?: string;
to_channel?: string;
}
export interface RemotePackage {
version: string;
resource_base_url: string;
/** 服务端是否下发了版本补丁包(`patch`) */
has_patch: boolean;
/** 补丁分卷(加密)下载总字节数 */
patch_total_bytes: number;
/** 是否存在未加密的整补丁包(`patch.url`) */
has_patch_whole: boolean;
/** 补丁包是否为差量形态(V2 索引非空,需要 hpatch;此时走整补丁 zip 或清单逐文件) */
patch_is_delta: boolean;
/** 未加密整补丁包地址(差量包的兜底形态,条目就是最终游戏文件) */
patch_whole_url: string;
/** 未加密整补丁包的 MD5(可能为空) */
patch_whole_md5: string;
/** 未加密整补丁包字节数 */
patch_whole_bytes: number;
/** 预下载分卷需下载的总字节数 */
preload_total_bytes: number;
}
export interface DownloadProgress {
downloaded: number;
total: number;
stage: string;
current_file: string | null;
file_index: number;
file_count: number;
verified_bytes: number;
}
export interface BackgroundMedia {
image_url: string | null;
video_url: string | null;
}
/**
* 后端启动结果里的 `<token>: <detail>` 契约:token 决定用哪条 i18n 文案,
* detail 是后端给的技术细节(英文诊断/错误码),不含用户可见文案。
*/
export const LAUNCH_TOKEN_KEYS: Record<string, string> = {
not_found: "launcher.launch_err_not_found",
no_launcher: "launcher.launch_err_no_launcher",
cancelled: "launcher.launch_err_cancelled",
create: "launcher.launch_err_create",
wait_failed: "launcher.launch_err_wait",
fallback: "launcher.launch_fallback_body",
overlay_unavailable: "launcher.launch_overlay_unavailable",
unknown: "launcher.launch_err_unknown",
};
/** 非致命提示的标题文案:按 token 区分,缺省沿用「已直接启动」。 */
export const LAUNCH_WARNING_TITLE_KEYS: Record<string, string> = {
fallback: "launcher.launch_fallback_title",
overlay_unavailable: "launcher.launch_overlay_title",
};
/**
* 「停止游戏」失败的 `<token>: <detail>` 契约(`launcher_kill_game` 返回的 Err 串),
* 与启动结果同格式,便于复用 `splitLaunchToken`。
*/
export const KILL_TOKEN_KEYS: Record<string, string> = {
cancelled: "launcher.stop_err_cancelled",
kill_failed: "launcher.stop_err_kill_failed",
unknown: "launcher.stop_err_unknown",
};
export function splitLaunchToken(message: string): {
token: string;
detail: string;
} {
const separator = message.indexOf(":");
if (separator < 0) return { token: "", detail: message.trim() };
return {
token: message.slice(0, separator).trim(),
detail: message.slice(separator + 1).trim(),
};
}
export interface LauncherResult {
success: boolean;
message: string;
version: string | null;
/** 非致命提示,格式同 message(`<token>: <detail>`)。 */
warning?: string | null;
}
export interface PayloadState {
channel: string;
version: string;
manifest_sha256: string;
file_count: number;
total_bytes: number;
updated_at: string;
preload_version: string | null;
preload_completed: boolean;
}
export interface BannerItem {
image_url: string;
jump_url: string;
}
export interface AnnouncementItem {
category: string;
title: string;
date: string;
jump_url: string;
}
export interface LauncherNoticeContent {
banners: BannerItem[];
announcements: AnnouncementItem[];
/** 官方启动器右侧栏数据(社媒二维码 + 工具入口) */
sidebars: SidebarItem[];
}
/**
* 侧边栏条目(后端 `get_sidebar_rsp` 已合并成扁平结构)。
*
* `media` 是渠道标识(`Skland`/`Bilibili`/`Weibo`/`Taptap`/`Douyin`/
* `Xiaohongshu`/`Wechat`/`CS`/`SklandTool`),前端据此匹配本地图标;
* `display_type` 为 `DisplayType_LIST`(单链接)或 `DisplayType_GRID`(一组工具链接)。
*/
export interface SidebarItem {
media: string;
display_type: string;
/** 悬停提示 / 二维码说明文案 */
title: string;
/** 二维码图片地址,空串表示无图 */
qr_url: string;
/** 点击打开的网页地址,空串表示只能扫码 */
jump_url: string;
need_token: boolean;
}
export interface FileScanResult {
channel_detected: boolean;
detected_channel: string | null;
total_files: number;
valid_files: number;
corrupted_files: number;
missing_files: number;
existing_bytes: number;
download_bytes: number;
corrupted_file_list: string[];
missing_file_list: string[];
}
export interface DiskSpace {
total: number;
free: number;
}
/** 游戏运行时长快照(仅统计 EndProtocol 运行期间) */
export interface GamePlaytime {
/** 已累计的总时长(不含当前会话),秒 */
total_seconds: number;
/** 当前会话已运行时长(未运行时为 0),秒 */
session_seconds: number;
/** 游戏进程当前是否在运行 */
running: boolean;
}
// ========== API Calls ==========
export async function checkGameStatus(
channel: GameChannel,
installPath: string,
): Promise<GameStatus> {
return invoke<GameStatus>("launcher_check_status", { channel, installPath });
}
export async function installOrUpdate(
channel: GameChannel,
installPath: string,
maxConcurrent?: number,
resume?: boolean,
): Promise<LauncherResult> {
return invoke<LauncherResult>("launcher_install_or_update", {
channel,
installPath,
maxConcurrent: maxConcurrent ?? null,
resume: resume ?? true,
});
}
export async function verifyAndRepair(
channel: GameChannel,
installPath: string,
maxConcurrent?: number,
quick?: boolean,
resume?: boolean,
): Promise<LauncherResult> {
const result = await invoke<LauncherResult>("launcher_verify_and_repair", {
channel,
installPath,
maxConcurrent: maxConcurrent ?? null,
quick: quick ?? false,
resume: resume ?? true,
});
if (!result.success) {
throw result.message;
}
return result;
}
export function getProcessReadBytes(): Promise<number> {
return invoke<number>("launcher_get_process_read_bytes");
}
export async function getRemoteVersion(
channel: GameChannel,
): Promise<RemotePackage> {
return invoke<RemotePackage>("launcher_get_remote_version", { channel });
}
export async function getPayloadState(
channel: GameChannel,
): Promise<PayloadState | null> {
return invoke<PayloadState | null>("launcher_get_payload_state", { channel });
}
export async function cancelDownload(installPath?: string): Promise<void> {
return invoke("launcher_cancel_download", { installPath: installPath ?? null });
}
export async function preloadDownload(
channel: GameChannel,
installPath: string,
maxConcurrent?: number,
resume?: boolean,
): Promise<LauncherResult> {
return invoke<LauncherResult>("launcher_preload_download", {
channel,
installPath,
maxConcurrent: maxConcurrent ?? null,
resume: resume ?? true,
});
}
export async function installPreload(
channel: GameChannel,
installPath: string,
maxConcurrent?: number,
resume?: boolean,
): Promise<LauncherResult> {
return invoke<LauncherResult>("launcher_install_preload", {
channel,
installPath,
maxConcurrent: maxConcurrent ?? null,
resume: resume ?? true,
});
}
export async function resetDownloadCancel(): Promise<void> {
return invoke("launcher_reset_download_cancel");
}
export async function hasDownloadCache(installPath: string): Promise<boolean> {
return invoke<boolean>("launcher_has_download_cache", { installPath });
}
export async function getBanners(
channel: GameChannel,
): Promise<BannerItem[]> {
return invoke<BannerItem[]>("launcher_get_banners", { channel });
}
export async function getAnnouncements(
channel: GameChannel,
): Promise<AnnouncementItem[]> {
return invoke<AnnouncementItem[]>("launcher_get_announcements", { channel });
}
export async function getNoticeContent(
channel: GameChannel,
): Promise<LauncherNoticeContent> {
return invoke<LauncherNoticeContent>("launcher_get_notice_content", {
channel,
});
}
export async function getBackgroundImage(
channel: GameChannel,
): Promise<BackgroundMedia | null> {
return invoke<BackgroundMedia | null>("launcher_get_background_image", {
channel,
});
}
export async function startGame(
installPath: string,
channel: GameChannel,
dx11?: boolean,
): Promise<LauncherResult> {
// 启动游戏把「本游戏的安装路径 / 渠道」写进后端配置:驻守实例(Steam 快捷方式)读的
// 就是这组键,而安装路径与渠道按游戏分区,两个游戏互不覆盖。
const game = CHANNEL_GAME[channel];
await invoke("set_config", {
key: gameConfigKey("launcher_install_path", game),
value: installPath,
});
await invoke("set_config", {
key: gameConfigKey("launcher_channel", game),
value: channel,
});
return invoke<LauncherResult>("launcher_start_game", {
installPath,
channel,
dx11,
});
}
/** 把前端 localStorage 里两个游戏的启动配置同步到后端(静默启动 / 快捷方式读后端)。
*
* 渠道必须**属于该游戏**才同步:把别的游戏的渠道写进这个分区,
* 静默启动就会拿错渠道去拼 exe 路径,界面也会一直判不出「已安装」。
*/
export async function syncSilentLaunchConfig(): Promise<void> {
for (const game of GAME_ORDER) {
const installPath = localStorage.getItem(
gameConfigKey("launcher_install_path", game),
);
const channel = localStorage.getItem(
gameConfigKey("launcher_channel", game),
);
const dx11 = localStorage.getItem(gameConfigKey("launcher_dx11", game));
if (installPath) {
await invoke("set_config", {
key: gameConfigKey("launcher_install_path", game),
value: installPath,
});
}
if (channel && CHANNEL_GAME[channel as GameChannel] === game) {
await invoke("set_config", {
key: gameConfigKey("launcher_channel", game),
value: channel,
});
}
if (dx11 !== null) {
await invoke("set_config", {
key: gameConfigKey("launcher_dx11", game),
value: dx11 === "true",
});
}
}
}
/** 后端配置回灌 localStorage(按游戏逐个回灌)。
*
* 渠道必须**属于该游戏**才回灌:分区里的渠道要是被别的游戏写过,拿它去查状态
* 就会用错的 exe 名(`Arknights.exe` / `Endfield.exe`)判安装,
* `is_installed` 恒为 false,界面一直要求下载游戏。
*/
export async function restoreLaunchConfig(): Promise<void> {
try {
for (const game of GAME_ORDER) {
const installPath = await invoke<string | null>("get_config", {
key: gameConfigKey("launcher_install_path", game),
});
if (installPath) {
localStorage.setItem(
gameConfigKey("launcher_install_path", game),
installPath,
);
}
const channel = await invoke<string | null>("get_config", {
key: gameConfigKey("launcher_channel", game),
});
if (channel && CHANNEL_GAME[channel as GameChannel] === game) {
localStorage.setItem(
gameConfigKey("launcher_channel", game),
channel,
);
}
const dx11 = await invoke<boolean | null>("get_config", {
key: gameConfigKey("launcher_dx11", game),
});
if (typeof dx11 === "boolean") {
localStorage.setItem(gameConfigKey("launcher_dx11", game), String(dx11));
}
}
} catch {
// 后端不可用时保留 localStorage 现状
}
}
export async function getGameLaunchCommand(game: LauncherGame): Promise<string> {
return invoke<string>("get_game_launch_command", { game });
}
export interface LaunchExportOptions {
dx11?: boolean;
/** 目标游戏;缺省为终末地 */
game?: LauncherGame;
}
export async function createGameShortcut(
options?: LaunchExportOptions,
): Promise<string | null> {
return invoke<string | null>("create_game_shortcut", {
dx11: options?.dx11,
game: options?.game ?? null,
});
}
/** Steam 第三方游戏条目的可编辑参数(对应后端 `SteamShortcutOptions`)。 */
export interface SteamShortcutOptions extends LaunchExportOptions {
name: string;
iconPath: string | null;
createAdminShortcut: boolean;
elevateStartMenu: boolean;
}
/** Steam 弹窗默认值(对应后端 `SteamShortcutDefaults`)。 */
export interface SteamShortcutDefaults {
name: string;
icon_path: string;
icon_extracted: boolean;
game_exe_found: boolean;
dx11: boolean;
create_admin_shortcut: boolean;
elevate_start_menu: boolean;
start_menu_shortcut_count: number;
is_elevated: boolean;
}
/** `add_steam_shortcut` 的返回值(对应后端 `AddSteamShortcutResult`)。 */
export interface AddSteamShortcutResult {
shortcuts_path: string;
admin_shortcut: string | null;
elevated_start_menu: string[];
}
export async function getSteamShortcutDefaults(
game: LauncherGame,
): Promise<SteamShortcutDefaults> {
return invoke<SteamShortcutDefaults>("steam_shortcut_defaults", { game });
}
export async function pickSteamIcon(): Promise<string | null> {
return invoke<string | null>("pick_steam_icon");
}
export async function pickSteamIconSource(): Promise<string | null> {
return invoke<string | null>("pick_steam_icon_source");
}
export async function extractSteamIcon(exePath: string): Promise<string> {
return invoke<string>("extract_steam_icon", { exePath });
}
export async function addSteamShortcut(
options: SteamShortcutOptions,
): Promise<AddSteamShortcutResult> {
return invoke<AddSteamShortcutResult>("add_steam_shortcut", {
options: {
name: options.name,
game: options.game ?? null,
icon_path: options.iconPath,
dx11: options.dx11,
create_admin_shortcut: options.createAdminShortcut,
elevate_start_menu: options.elevateStartMenu,
},
});
}
export async function browseFolder(): Promise<string | null> {
return invoke<string | null>("launcher_browse_folder");
}
export async function checkExecutable(
installPath: string,
channel: GameChannel,
): Promise<boolean> {
return invoke<boolean>("launcher_check_executable", {
installPath,
channel,
});
}
export async function checkGameRunning(
channel: GameChannel,
): Promise<boolean> {
return invoke<boolean>("launcher_check_game_running", {
channel,
});
}
/**
* 结束游戏进程(`launcher_kill_game`)。
*
* 返回 `true` = 进程已结束,`false` = 本来就没有同名进程。**失败会 reject**,错误串是同
* `<token>: <detail>` 契约(token 为 `cancelled` / `kill_failed` / `unknown`)——调用方据此
* 展示「需要管理员授权」之类的提示,而不是像以前那样静默忽略。
*/
export async function killGame(channel: GameChannel): Promise<boolean> {
return invoke<boolean>("launcher_kill_game", {
channel,
});
}
export async function getGamePlaytime(): Promise<GamePlaytime> {
return invoke<GamePlaytime>("launcher_get_game_playtime");
}
export async function detectChannel(
installPath: string,
): Promise<GameChannel | null> {
return invoke<GameChannel | null>("launcher_detect_channel", { installPath });
}
export async function scanInstallDir(
channel: GameChannel,
installPath: string,
): Promise<FileScanResult> {
return invoke<FileScanResult>("launcher_scan_install_dir", {
channel,
installPath,
});
}
export async function getDiskSpace(path: string): Promise<DiskSpace> {
return invoke<DiskSpace>("launcher_get_disk_space", { path });
}
export async function switchChannel(
fromChannel: GameChannel,
toChannel: GameChannel,
installPath: string,
): Promise<string> {
return invoke<string>("launcher_switch_channel", {
fromChannel,
toChannel,
installPath,
});
}
export async function cancelSwitch(): Promise<void> {
return invoke<void>("launcher_cancel_switch");
}
export async function cancelAll(): Promise<void> {
return invoke<void>("launcher_cancel_all");
}
// ========== Event Listener ==========
export function onLauncherProgress(
callback: (progress: DownloadProgress) => void,
): Promise<UnlistenFn> {
return listen<DownloadProgress>("launcher-progress", (event) => {
callback(event.payload);
});
}
export function onProgress(
callback: (op: ActiveOperation) => void,
): Promise<UnlistenFn> {
return listen<ActiveOperation>("launcher://progress", (event) => {
callback(event.payload);
});
}
export function onSwitchProgress(
callback: (progress: DownloadProgress) => void,
): Promise<UnlistenFn> {
return listen<DownloadProgress>("launcher://switch-progress", (event) => {
callback(event.payload);
});
}
// ========== Helpers ==========
export const CHANNEL_LABELS: Record<GameChannel, Record<string, string>> = {
official: { zh: "终末地 官服", en: "Endfield CN" },
bilibili: { zh: "终末地 B服", en: "Endfield Bilibili" },
global: { zh: "终末地 国际服", en: "Endfield Global" },
google_play: { zh: "终末地 Google Play", en: "Endfield Google Play" },
arknights: { zh: "明日方舟 官服", en: "Arknights CN" },
arknights_bilibili: { zh: "明日方舟 B服", en: "Arknights Bilibili" },
};
/** 渠道列表按钮上的圆形徽标文字 */
export const CHANNEL_SHORT: Record<GameChannel, string> = {
official: "官",
bilibili: "B",
global: "G",
google_play: "GP",
arknights: "方",
arknights_bilibili: "方B",
};
/** 游戏标识:与后端 `GameChannel::game_id()` 一致 */
export type GameId = "endfield" | "arknights";
/** 按游戏分组的渠道表(安装/选择渠道时按组展示) */
export const GAME_CHANNELS: Record<GameId, GameChannel[]> = {
endfield: ["official", "bilibili", "global", "google_play"],
arknights: ["arknights", "arknights_bilibili"],
};
/** 游戏分组展示顺序 */
export const GAME_ORDER: GameId[] = ["endfield", "arknights"];
/** 渠道 → 所属游戏 */
export const CHANNEL_GAME: Record<GameChannel, GameId> = {
official: "endfield",
bilibili: "endfield",
global: "endfield",
google_play: "endfield",
arknights: "arknights",
arknights_bilibili: "arknights",
};
/** 全部渠道(选择渠道/安装入口用) */
export const ALL_CHANNELS: GameChannel[] = GAME_ORDER.flatMap(
(game) => GAME_CHANNELS[game],
);
// ========== 按游戏分区的配置键 ==========
//
// 安装路径 / 渠道 / DX11 都是"每个游戏一份":两个游戏的目录、exe、CDN 完全不同,
// 共用一份配置会互相覆盖。终末地沿用历史键名(老用户配置原样可用),
// 明日方舟用 `_arknights` 后缀;后端 `commands/startup.rs::LauncherGame::*_key` 用同一套规则。
export type LauncherGame = "endfield" | "arknights";
/** 某个游戏在 localStorage / 后端 config 里的键名后缀 */
function gameSuffix(game: LauncherGame): string {
return game === "arknights" ? "_arknights" : "";
}
/** 键名:`launcher_install_path` / `launcher_install_path_arknights` */
export function gameConfigKey(
base: "launcher_install_path" | "launcher_channel" | "launcher_dx11",
game: LauncherGame,
): string {
return `${base}${gameSuffix(game)}`;
}
/** 渠道 → 所属游戏(与 `CHANNEL_GAME` 同源,供不引入 launcherService 的场景使用) */
export function gameOfChannel(channel: GameChannel): LauncherGame {
return CHANNEL_GAME[channel];
}
/** 每个游戏在没有记录时使用的默认渠道 */
export const DEFAULT_CHANNEL: Record<LauncherGame, GameChannel> = {
endfield: "official",
arknights: "arknights",
};
/** 读取某个游戏的安装路径(localStorage) */
export function readGameInstallPath(game: LauncherGame): string {
try {
return localStorage.getItem(gameConfigKey("launcher_install_path", game)) || "";
} catch {
return "";
}
}
/** 读取某个游戏上次使用的渠道(localStorage);没有记录时给该游戏的默认渠道 */
export function readGameChannel(game: LauncherGame): GameChannel {
try {
const stored = localStorage.getItem(gameConfigKey("launcher_channel", game));
if (stored && CHANNEL_GAME[stored as GameChannel] === game) {
return stored as GameChannel;
}
} catch {}
return DEFAULT_CHANNEL[game];
}
/**
* 把「某个安装目录 + 检测出的渠道」记到**渠道所属游戏**的分区里。
*
* 两个游戏的安装路径与渠道各存一份(`launcher_install_path[_arknights]` /
* `launcher_channel[_arknights]`),所以识别出渠道时必须按渠道的归属游戏落盘,
* 不能落到"当前面板正在看的那个游戏"上 —— 后者会让某个分区存着
* 「A 游戏的目录 + B 游戏的渠道」这种对不上的组合,读回时按归属校验会把渠道丢掉,
* 于是「已安装」永远判不出来,界面一直要求下载游戏。
*
* 同时把**另一个**分区里指向同一目录的旧记录清掉:那个目录只属于一个游戏,
* 留着会让另一个游戏也以为装在同一个目录里,两边互相覆盖对方的配置。
*
* localStorage 与后端 config(`app_config.json`)都要写:后端那一份是静默启动、
* 快捷方式的真正依据,只改 localStorage 的话,下次启动 `restoreLaunchConfig`
* 又会把写歪的路径搬回来。
*
* 返回该渠道所属的游戏,供调用方同步当前游戏的选择。
*/
export async function persistDetectedInstall(
installPath: string,
detected: GameChannel,
): Promise<LauncherGame> {
const owner = CHANNEL_GAME[detected];
const other: LauncherGame = owner === "endfield" ? "arknights" : "endfield";
try {
localStorage.setItem(
gameConfigKey("launcher_install_path", owner),
installPath,
);
localStorage.setItem(gameConfigKey("launcher_channel", owner), detected);
// 另一个分区若也指着这个目录,说明之前识别错游戏时写歪了:清掉它的路径,
// 避免两个游戏抢同一个目录。它分区里那个「不属于自己」的渠道也要一起清,
// 免得下次读回时又是一对错位的组合。
if (readGameInstallPath(other) === installPath) {
localStorage.removeItem(gameConfigKey("launcher_install_path", other));
await invoke("remove_config", {
key: gameConfigKey("launcher_install_path", other),
});
const otherChannel = localStorage.getItem(
gameConfigKey("launcher_channel", other),
) as GameChannel | null;
if (otherChannel && CHANNEL_GAME[otherChannel] !== other) {
localStorage.removeItem(gameConfigKey("launcher_channel", other));
await invoke("remove_config", {
key: gameConfigKey("launcher_channel", other),
});
}
}
} catch {}
await invoke("set_config", {
key: gameConfigKey("launcher_install_path", owner),
value: installPath,
});
await invoke("set_config", {
key: gameConfigKey("launcher_channel", owner),
value: detected,
});
return owner;
}
/**
* 只记安装目录,不动渠道。
*
* 用于「目录已选好、但还没识别出渠道」(例如游戏还没装)的情况:路径仍要留在
* 当前游戏的分区里,重启后还在;但渠道留给用户选,不去动别的游戏的记录。
*/
export async function persistInstallPath(
installPath: string,
game: LauncherGame,
): Promise<void> {
try {
localStorage.setItem(
gameConfigKey("launcher_install_path", game),
installPath,
);
} catch {}
await invoke("set_config", {
key: gameConfigKey("launcher_install_path", game),
value: installPath,
});
}
/**
* 可以切换到的渠道。
*
* 两个游戏都支持换服:渠道差异都完整落在各自的清单里(实测 2026-11-08)。
* - 终末地:官/B/国际/GooglePlay 四渠道互切;
* - 明日方舟:官服 ↔ B 服(channel 1/2),diff 约 706 MB
* (B 服独有 `BLPlatform64/` 186 MB、官服独有 `hgsdk.dll` 等 1.9 MB、
* MD5 不同 520 MB),切换后 `config.ini` 的 channel 随包更新。
*/
export function switchableChannels(from: GameChannel): GameChannel[] {
const game = CHANNEL_GAME[from];
return GAME_CHANNELS[game].filter((channel) => channel !== from);
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
export const STAGE_LABELS: Record<string, Record<string, string>> = {
checking: { zh: "检查中", en: "Checking" },
comparing: { zh: "比较中", en: "Comparing" },
downloading: { zh: "下载中", en: "Downloading" },
verifying: { zh: "校验中", en: "Verifying" },
repairing: { zh: "修复中", en: "Repairing" },
applying: { zh: "应用中", en: "Applying" },
completed: { zh: "已完成", en: "Completed" },
error: { zh: "出错了", en: "Error" },
};