emoemu 0.1.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.
- package/.claude/settings.local.json +77 -0
- package/.node-version +1 -0
- package/CLAUDE.md +435 -0
- package/README.md +404 -0
- package/TODO.md +655 -0
- package/dist/index.cjs +25108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25085 -0
- package/docs/building-libretro-cores-arm-mac.md +237 -0
- package/docs/config-file-format.md +488 -0
- package/docs/cores-trd.md +425 -0
- package/docs/headless-hardware-rendering-trd.md +676 -0
- package/docs/libretro-cores-trd.md +997 -0
- package/docs/mupen64-software-rendering-trd.md +751 -0
- package/docs/n64-support-trd.md +306 -0
- package/docs/native-rendering-trd.md +540 -0
- package/docs/native-ui-rendering-trd.md +1195 -0
- package/docs/netplay-trd.md +665 -0
- package/docs/retroarch-netplay-docs.md +277 -0
- package/docs/save-state-format.md +666 -0
- package/eslint.config.js +111 -0
- package/icon/icon.png +0 -0
- package/icon/icon.pxd +0 -0
- package/package.json +63 -0
- package/pnpm-workspace.yaml +10 -0
- package/src/Emulator/consts.ts +14 -0
- package/src/Emulator/index.ts +2496 -0
- package/src/Emulator/saveState/index.ts +155 -0
- package/src/Emulator/screenshot/index.ts +160 -0
- package/src/Emulator/terminalDimensions/index.ts +79 -0
- package/src/Emulator/types.ts +83 -0
- package/src/cli/commands/consts.ts +10 -0
- package/src/cli/commands/index.ts +462 -0
- package/src/cli/parseArgs/consts.ts +17 -0
- package/src/cli/parseArgs/index.ts +457 -0
- package/src/cli/parseArgs/types.ts +61 -0
- package/src/cli/runEmulator/index.ts +406 -0
- package/src/cli/runEmulator/types.ts +7 -0
- package/src/consts.ts +19 -0
- package/src/core/button/consts.ts +35 -0
- package/src/core/button/index.ts +123 -0
- package/src/core/core.ts +300 -0
- package/src/core/index.ts +19 -0
- package/src/cores/libretro/api/index.ts +334 -0
- package/src/cores/libretro/api/types.ts +148 -0
- package/src/cores/libretro/callbacks/consts.ts +41 -0
- package/src/cores/libretro/callbacks/index.ts +456 -0
- package/src/cores/libretro/consts.ts +45 -0
- package/src/cores/libretro/coreOptions/consts.ts +36 -0
- package/src/cores/libretro/coreOptions/index.ts +222 -0
- package/src/cores/libretro/environment/consts.ts +118 -0
- package/src/cores/libretro/environment/index.ts +1095 -0
- package/src/cores/libretro/index.ts +937 -0
- package/src/cores/libretro/loader/index.ts +496 -0
- package/src/cores/libretro/pixelFormat/consts.ts +43 -0
- package/src/cores/libretro/pixelFormat/index.ts +397 -0
- package/src/cores/libretro/types.ts +339 -0
- package/src/frontend/AudioManager/index.ts +420 -0
- package/src/frontend/SettingsManager/index.ts +250 -0
- package/src/frontend/config/index.ts +608 -0
- package/src/frontend/config/tests.ts +354 -0
- package/src/frontend/config/types.ts +36 -0
- package/src/frontend/consts.ts +114 -0
- package/src/frontend/coreBuilder/index.ts +644 -0
- package/src/frontend/coreBuilder/types.ts +15 -0
- package/src/frontend/coreDownloader/index.ts +620 -0
- package/src/frontend/coreDownloader/types.ts +17 -0
- package/src/frontend/corePreferences/index.ts +69 -0
- package/src/frontend/coreRegistry/index.ts +276 -0
- package/src/frontend/directoryCache/index.ts +75 -0
- package/src/frontend/index.ts +79 -0
- package/src/frontend/notifications/consts.ts +14 -0
- package/src/frontend/notifications/index.ts +250 -0
- package/src/frontend/playlist/consts.ts +168 -0
- package/src/frontend/playlist/index.ts +899 -0
- package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
- package/src/frontend/playlist/labelFormatter/index.ts +155 -0
- package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
- package/src/frontend/playlist/reader/index.ts +559 -0
- package/src/frontend/playlist/sync/index.ts +511 -0
- package/src/frontend/playlist/systemLookup/index.ts +233 -0
- package/src/frontend/playlist/utils/index.ts +50 -0
- package/src/frontend/romScanner/consts.ts +348 -0
- package/src/frontend/romScanner/index.ts +1957 -0
- package/src/frontend/saveServices/consts.ts +2 -0
- package/src/frontend/saveServices/index.ts +313 -0
- package/src/frontend/serviceProvider/index.ts +108 -0
- package/src/frontend/serviceProvider/types.ts +13 -0
- package/src/index.ts +428 -0
- package/src/input/Controller/consts.ts +50 -0
- package/src/input/Controller/index.ts +81 -0
- package/src/input/GamepadManager/consts.ts +22 -0
- package/src/input/GamepadManager/index.ts +418 -0
- package/src/input/InputManager/consts.ts +86 -0
- package/src/input/InputManager/index.ts +593 -0
- package/src/input/InputMapper/consts.ts +33 -0
- package/src/input/InputMapper/index.ts +436 -0
- package/src/input/consts.ts +410 -0
- package/src/input/gamepadProfiles/index.ts +1100 -0
- package/src/input/index.ts +38 -0
- package/src/input/inputUtils/index.ts +31 -0
- package/src/netplay/FrameBuffer/consts.ts +2 -0
- package/src/netplay/FrameBuffer/index.ts +364 -0
- package/src/netplay/FrameBuffer/tests.ts +286 -0
- package/src/netplay/InputBuffer/consts.ts +7 -0
- package/src/netplay/InputBuffer/index.ts +347 -0
- package/src/netplay/InputBuffer/tests.ts +166 -0
- package/src/netplay/NetplayClient/index.ts +976 -0
- package/src/netplay/NetplayConnection/index.ts +536 -0
- package/src/netplay/NetplayDiscovery/consts.ts +41 -0
- package/src/netplay/NetplayDiscovery/index.ts +525 -0
- package/src/netplay/NetplayServer/index.ts +1407 -0
- package/src/netplay/SyncManager/index.ts +984 -0
- package/src/netplay/SyncManager/tests.ts +419 -0
- package/src/netplay/consts.ts +371 -0
- package/src/netplay/crc32/consts.ts +14 -0
- package/src/netplay/crc32/index.ts +97 -0
- package/src/netplay/crc32/tests.ts +40 -0
- package/src/netplay/index.ts +41 -0
- package/src/netplay/netplayLogger/consts.ts +30 -0
- package/src/netplay/netplayLogger/index.ts +345 -0
- package/src/netplay/protocol/consts.ts +86 -0
- package/src/netplay/protocol/index.ts +1280 -0
- package/src/netplay/protocol/tests.ts +606 -0
- package/src/netplay/protocol/types.ts +20 -0
- package/src/netplay/types.ts +395 -0
- package/src/rendering/KittyRenderer/index.ts +616 -0
- package/src/rendering/NativeRenderer/index.ts +279 -0
- package/src/rendering/NativeRenderer/tests.ts +133 -0
- package/src/rendering/TerminalRenderer/index.ts +770 -0
- package/src/rendering/consts.ts +401 -0
- package/src/rendering/fonts/CozetteVector.ttf +0 -0
- package/src/rendering/index.ts +26 -0
- package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
- package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
- package/src/rendering/nativeUi/consts.ts +6 -0
- package/src/rendering/nativeUi/index.ts +20 -0
- package/src/rendering/postProcessing/consts.ts +38 -0
- package/src/rendering/postProcessing/index.ts +923 -0
- package/src/rendering/shared/ansi/consts.ts +12 -0
- package/src/rendering/shared/ansi/index.ts +104 -0
- package/src/rendering/shared/consts.ts +2 -0
- package/src/rendering/shared/fitToTerminal/index.ts +67 -0
- package/src/ui/AddRomsPrompt/consts.ts +13 -0
- package/src/ui/AddRomsPrompt/index.tsx +781 -0
- package/src/ui/App/consts.ts +2 -0
- package/src/ui/App/index.tsx +456 -0
- package/src/ui/AppCapabilities/index.tsx +67 -0
- package/src/ui/ConfigContext/index.tsx +56 -0
- package/src/ui/CoreManager/consts.ts +11 -0
- package/src/ui/CoreManager/index.tsx +779 -0
- package/src/ui/CoreSelector/consts.ts +2 -0
- package/src/ui/CoreSelector/index.tsx +251 -0
- package/src/ui/DialogContainer/index.tsx +42 -0
- package/src/ui/DialogOptionsList/index.tsx +61 -0
- package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
- package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
- package/src/ui/GamepadContext/consts.ts +15 -0
- package/src/ui/GamepadContext/index.tsx +295 -0
- package/src/ui/NativeDialog/index.tsx +120 -0
- package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
- package/src/ui/NetplayPauseMenu/consts.ts +2 -0
- package/src/ui/NetplayPauseMenu/index.tsx +97 -0
- package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
- package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
- package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
- package/src/ui/RomBrowser/consts.ts +61 -0
- package/src/ui/RomBrowser/index.tsx +1164 -0
- package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
- package/src/ui/RomBrowser/types.ts +67 -0
- package/src/ui/SaveStateDialog/consts.ts +2 -0
- package/src/ui/SaveStateDialog/index.tsx +225 -0
- package/src/ui/WarningDialog/index.tsx +113 -0
- package/src/ui/consts.ts +27 -0
- package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
- package/src/ui/hooks/useClearTerminal/index.ts +37 -0
- package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
- package/src/ui/hooks/useGamepad/consts.ts +21 -0
- package/src/ui/hooks/useGamepad/index.ts +194 -0
- package/src/ui/index.ts +27 -0
- package/src/utils/buffer/consts.ts +17 -0
- package/src/utils/buffer/index.ts +129 -0
- package/src/utils/color/consts.ts +58 -0
- package/src/utils/color/index.ts +183 -0
- package/src/utils/compression/consts.ts +50 -0
- package/src/utils/compression/index.ts +101 -0
- package/src/utils/consts.ts +2 -0
- package/src/utils/crc32/consts.ts +22 -0
- package/src/utils/crc32/index.ts +83 -0
- package/src/utils/ensureDirectory/index.ts +10 -0
- package/src/utils/format/consts.ts +8 -0
- package/src/utils/format/index.ts +53 -0
- package/src/utils/getErrorMessage/index.ts +10 -0
- package/src/utils/index.ts +113 -0
- package/src/utils/ini/index.ts +200 -0
- package/src/utils/kitty/consts.ts +13 -0
- package/src/utils/kitty/index.ts +181 -0
- package/src/utils/logger/consts.ts +35 -0
- package/src/utils/logger/index.ts +217 -0
- package/src/utils/paths/consts.ts +18 -0
- package/src/utils/paths/index.ts +151 -0
- package/src/utils/png/consts.ts +34 -0
- package/src/utils/png/index.ts +131 -0
- package/src/utils/readJsonFile/index.ts +16 -0
- package/src/utils/rotateLogFile/index.ts +44 -0
- package/src/utils/safeClose/index.ts +10 -0
- package/src/utils/terminal/consts.ts +8 -0
- package/src/utils/terminal/index.ts +102 -0
- package/src/utils/thumbnailRenderer/consts.ts +2 -0
- package/src/utils/thumbnailRenderer/index.ts +147 -0
- package/src/utils/typedError/index.ts +26 -0
- package/tsconfig.json +31 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Downloader Service
|
|
3
|
+
*
|
|
4
|
+
* Downloads and installs libretro cores from the RetroArch buildbot.
|
|
5
|
+
* Provides progress callbacks for UI integration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createWriteStream, existsSync, writeFileSync } from "fs";
|
|
9
|
+
import { readJsonFile } from '../../utils/readJsonFile';
|
|
10
|
+
import { ensureDirectory } from '../../utils/ensureDirectory';
|
|
11
|
+
import { mkdir, unlink, chmod } from "fs/promises";
|
|
12
|
+
import { pipeline } from "stream/promises";
|
|
13
|
+
import { dirname, join } from "path";
|
|
14
|
+
import { platform, arch } from "os";
|
|
15
|
+
import { Readable } from "stream";
|
|
16
|
+
import { execSync } from "child_process";
|
|
17
|
+
import { getCoresDirectory } from "../config";
|
|
18
|
+
import { getConfigDirectory } from "../../utils/paths";
|
|
19
|
+
import {
|
|
20
|
+
requiresBuildFromSource,
|
|
21
|
+
getBuildReason,
|
|
22
|
+
buildCore,
|
|
23
|
+
type BuildProgress,
|
|
24
|
+
} from "../coreBuilder";
|
|
25
|
+
import { logger } from "../../utils/logger";
|
|
26
|
+
import { getErrorMessage } from "../../utils/getErrorMessage";
|
|
27
|
+
import { CoreDownloadError } from "./types";
|
|
28
|
+
|
|
29
|
+
/** Base URL for the RetroArch buildbot */
|
|
30
|
+
const BUILDBOT_BASE_URL = "https://buildbot.libretro.com/nightly";
|
|
31
|
+
|
|
32
|
+
/** Cache duration: 1 week in milliseconds */
|
|
33
|
+
const DAYS_PER_WEEK = 7;
|
|
34
|
+
const HOURS_PER_DAY = 24;
|
|
35
|
+
const MINUTES_PER_HOUR = 60;
|
|
36
|
+
const SECONDS_PER_MINUTE = 60;
|
|
37
|
+
const MS_PER_SECOND = 1000;
|
|
38
|
+
const CACHE_DURATION_MS = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
39
|
+
|
|
40
|
+
/** Path to the cores index cache file */
|
|
41
|
+
const getCacheFilePath = (): string => join(getConfigDirectory(), "cache", "cores-index.json");
|
|
42
|
+
|
|
43
|
+
/** Index file suffix for the buildbot */
|
|
44
|
+
const INDEX_SUFFIX = ".index-extended";
|
|
45
|
+
|
|
46
|
+
/** File permissions for executable cores */
|
|
47
|
+
const EXECUTABLE_PERMISSIONS = 0o755;
|
|
48
|
+
|
|
49
|
+
/** Recommended cores with descriptions - popular systems only */
|
|
50
|
+
export const RECOMMENDED_CORES = [
|
|
51
|
+
{
|
|
52
|
+
name: "bsnes",
|
|
53
|
+
description: "SNES",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "mgba",
|
|
57
|
+
description: "Game Boy Advance",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "gambatte",
|
|
61
|
+
description: "Game Boy / Color",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "picodrive",
|
|
65
|
+
description: "Sega Genesis / Mega Drive",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "mupen64plus_next",
|
|
69
|
+
description: "Nintendo 64 (software rendering)",
|
|
70
|
+
},
|
|
71
|
+
] as const;
|
|
72
|
+
|
|
73
|
+
/** Set of recommended core names for quick lookup */
|
|
74
|
+
export const RECOMMENDED_CORE_NAMES: Set<string> = new Set(RECOMMENDED_CORES.map(c => c.name));
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Comprehensive mapping of libretro core names to system descriptions.
|
|
78
|
+
* Used to display what system each core emulates.
|
|
79
|
+
*/
|
|
80
|
+
export const CORE_DESCRIPTIONS: Record<string, string> = {
|
|
81
|
+
// Build from recommended cores first
|
|
82
|
+
...Object.fromEntries(RECOMMENDED_CORES.map(c => [c.name, c.description])),
|
|
83
|
+
|
|
84
|
+
// Additional cores not in recommended list
|
|
85
|
+
// NES / Famicom
|
|
86
|
+
quicknes: "NES (fast)",
|
|
87
|
+
bnes: "NES",
|
|
88
|
+
fixnes: "NES",
|
|
89
|
+
|
|
90
|
+
// SNES
|
|
91
|
+
snes9x_next: "SNES",
|
|
92
|
+
snes9x2002: "SNES (very fast)",
|
|
93
|
+
snes9x2005: "SNES (fast)",
|
|
94
|
+
snes9x2005_plus: "SNES (fast)",
|
|
95
|
+
snes9x2010: "SNES",
|
|
96
|
+
bsnes_mercury_accuracy: "SNES (high accuracy)",
|
|
97
|
+
bsnes_mercury_balanced: "SNES (balanced)",
|
|
98
|
+
bsnes_mercury_performance: "SNES (performance)",
|
|
99
|
+
bsnes_hd_beta: "SNES (HD mode 7)",
|
|
100
|
+
bsnes_cplusplus98: "SNES",
|
|
101
|
+
mednafen_snes: "SNES",
|
|
102
|
+
mesen_s: "SNES (cycle-accurate)",
|
|
103
|
+
|
|
104
|
+
// Game Boy / Color
|
|
105
|
+
sameboy: "Game Boy / Color (accurate)",
|
|
106
|
+
gearboy: "Game Boy / Color",
|
|
107
|
+
mgba: "Game Boy Advance",
|
|
108
|
+
vbam: "Game Boy Advance",
|
|
109
|
+
meteor: "Game Boy Advance",
|
|
110
|
+
|
|
111
|
+
// Nintendo 64
|
|
112
|
+
mupen64plus_next: "Nintendo 64 (software rendering)",
|
|
113
|
+
parallel_n64: "Nintendo 64 (requires GPU)",
|
|
114
|
+
|
|
115
|
+
// Nintendo DS
|
|
116
|
+
melonds: "Nintendo DS",
|
|
117
|
+
desmume: "Nintendo DS",
|
|
118
|
+
desmume2015: "Nintendo DS",
|
|
119
|
+
|
|
120
|
+
// Sega
|
|
121
|
+
genesis_plus_gx_wide: "Genesis (widescreen)",
|
|
122
|
+
smsplus: "Master System / Game Gear",
|
|
123
|
+
emux_sms: "Master System",
|
|
124
|
+
flycast: "Dreamcast",
|
|
125
|
+
redream: "Dreamcast",
|
|
126
|
+
kronos: "Saturn",
|
|
127
|
+
mednafen_saturn: "Saturn",
|
|
128
|
+
yabause: "Saturn",
|
|
129
|
+
|
|
130
|
+
// PlayStation
|
|
131
|
+
beetle_psx: "PlayStation (accurate)",
|
|
132
|
+
beetle_psx_hw: "PlayStation (hardware)",
|
|
133
|
+
duckstation: "PlayStation",
|
|
134
|
+
swanstation: "PlayStation",
|
|
135
|
+
pcsx1: "PlayStation",
|
|
136
|
+
|
|
137
|
+
// PSP
|
|
138
|
+
ppsspp: "PlayStation Portable",
|
|
139
|
+
|
|
140
|
+
// Atari
|
|
141
|
+
stella2014: "Atari 2600",
|
|
142
|
+
atari800: "Atari 8-bit / 5200",
|
|
143
|
+
a5200: "Atari 5200",
|
|
144
|
+
virtualjaguar: "Atari Jaguar",
|
|
145
|
+
|
|
146
|
+
// Other consoles
|
|
147
|
+
mednafen_lynx: "Atari Lynx",
|
|
148
|
+
beetle_lynx: "Atari Lynx",
|
|
149
|
+
mednafen_pcfx: "PC-FX",
|
|
150
|
+
beetle_pcfx: "PC-FX",
|
|
151
|
+
opera: "3DO",
|
|
152
|
+
"4do": "3DO",
|
|
153
|
+
neocd: "Neo Geo CD",
|
|
154
|
+
race: "Neo Geo Pocket",
|
|
155
|
+
beetle_ngp: "Neo Geo Pocket",
|
|
156
|
+
beetle_wswan: "WonderSwan",
|
|
157
|
+
beetle_vb: "Virtual Boy",
|
|
158
|
+
beetle_supergrafx: "SuperGrafx",
|
|
159
|
+
mednafen_supergrafx: "SuperGrafx",
|
|
160
|
+
|
|
161
|
+
// Arcade
|
|
162
|
+
mame: "Arcade (MAME)",
|
|
163
|
+
mame2000: "Arcade (MAME 2000)",
|
|
164
|
+
mame2003: "Arcade (MAME 2003)",
|
|
165
|
+
mame2003_plus: "Arcade (MAME 2003+)",
|
|
166
|
+
mame2010: "Arcade (MAME 2010)",
|
|
167
|
+
mame2015: "Arcade (MAME 2015)",
|
|
168
|
+
mame2016: "Arcade (MAME 2016)",
|
|
169
|
+
fbneo: "Arcade (FinalBurn Neo)",
|
|
170
|
+
fbalpha: "Arcade (FinalBurn Alpha)",
|
|
171
|
+
fbalpha2012: "Arcade (FBA 2012)",
|
|
172
|
+
fbalpha2012_cps1: "Arcade (CPS1)",
|
|
173
|
+
fbalpha2012_cps2: "Arcade (CPS2)",
|
|
174
|
+
fbalpha2012_cps3: "Arcade (CPS3)",
|
|
175
|
+
fbalpha2012_neogeo: "Arcade (Neo Geo)",
|
|
176
|
+
|
|
177
|
+
// Computers
|
|
178
|
+
dosbox: "DOS",
|
|
179
|
+
dosbox_core: "DOS",
|
|
180
|
+
dosbox_pure: "DOS",
|
|
181
|
+
dosbox_svn: "DOS",
|
|
182
|
+
puae: "Amiga",
|
|
183
|
+
uae4arm: "Amiga",
|
|
184
|
+
vice_x64: "Commodore 64",
|
|
185
|
+
vice_x64sc: "Commodore 64 (accurate)",
|
|
186
|
+
vice_x128: "Commodore 128",
|
|
187
|
+
vice_xpet: "Commodore PET",
|
|
188
|
+
vice_xplus4: "Commodore Plus/4",
|
|
189
|
+
vice_xvic: "VIC-20",
|
|
190
|
+
vice_xcbm2: "Commodore CBM-II",
|
|
191
|
+
vice_xcbm5x0: "Commodore CBM 5x0",
|
|
192
|
+
frodo: "Commodore 64",
|
|
193
|
+
fmsx: "MSX / MSX2",
|
|
194
|
+
bluemsx: "MSX / MSX2 / ColecoVision",
|
|
195
|
+
hatari: "Atari ST",
|
|
196
|
+
theodore: "Thomson MO/TO",
|
|
197
|
+
x1: "Sharp X1",
|
|
198
|
+
px68k: "Sharp X68000",
|
|
199
|
+
np2kai: "PC-98",
|
|
200
|
+
nekop2: "PC-98",
|
|
201
|
+
quasi88: "PC-88",
|
|
202
|
+
pc88: "PC-88",
|
|
203
|
+
ep128emu: "Enterprise 128",
|
|
204
|
+
|
|
205
|
+
// ScummVM / game engines
|
|
206
|
+
scummvm: "ScummVM (adventure games)",
|
|
207
|
+
easyrpg: "RPG Maker 2000/2003",
|
|
208
|
+
prboom: "Doom",
|
|
209
|
+
tyrquake: "Quake",
|
|
210
|
+
vitaquake2: "Quake II",
|
|
211
|
+
vitaquake3: "Quake III",
|
|
212
|
+
ecwolf: "Wolfenstein 3D",
|
|
213
|
+
cannonball: "OutRun",
|
|
214
|
+
dinothawr: "Dinothawr (puzzle game)",
|
|
215
|
+
mrboom: "Mr. Boom (Bomberman clone)",
|
|
216
|
+
gong: "Pong",
|
|
217
|
+
"2048": "2048",
|
|
218
|
+
craft: "Minecraft clone",
|
|
219
|
+
|
|
220
|
+
// Other
|
|
221
|
+
uzem: "Uzebox",
|
|
222
|
+
lowresnx: "LowRes NX (fantasy console)",
|
|
223
|
+
tic80: "TIC-80 (fantasy console)",
|
|
224
|
+
lutro: "Lutro (Lua game framework)",
|
|
225
|
+
chailove: "ChaiLove (2D game framework)",
|
|
226
|
+
pokemini: "Pokemon Mini",
|
|
227
|
+
mednafen_coverflow: "Channel F",
|
|
228
|
+
freechaf: "Channel F",
|
|
229
|
+
nxengine: "Cave Story",
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** Information about an available core from the buildbot */
|
|
233
|
+
export interface AvailableCoreInfo {
|
|
234
|
+
name: string;
|
|
235
|
+
filename: string;
|
|
236
|
+
size: number;
|
|
237
|
+
date: string;
|
|
238
|
+
description?: string;
|
|
239
|
+
isRecommended: boolean;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Cache structure for storing cores list with timestamp */
|
|
243
|
+
interface CoresCache {
|
|
244
|
+
timestamp: number;
|
|
245
|
+
platform: string;
|
|
246
|
+
arch: string;
|
|
247
|
+
cores: AvailableCoreInfo[];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Type guard to validate cache structure
|
|
252
|
+
*/
|
|
253
|
+
const isValidCoresCache = (data: unknown): data is CoresCache =>
|
|
254
|
+
typeof data === "object" &&
|
|
255
|
+
data !== null &&
|
|
256
|
+
"timestamp" in data &&
|
|
257
|
+
typeof data.timestamp === "number" &&
|
|
258
|
+
"platform" in data &&
|
|
259
|
+
typeof data.platform === "string" &&
|
|
260
|
+
"arch" in data &&
|
|
261
|
+
typeof data.arch === "string" &&
|
|
262
|
+
"cores" in data &&
|
|
263
|
+
Array.isArray(data.cores);
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Read the cached cores list if it exists and is valid
|
|
267
|
+
* @returns Cached cores or null if cache is missing/expired/invalid
|
|
268
|
+
*/
|
|
269
|
+
const readCoresCache = (): AvailableCoreInfo[] | null => {
|
|
270
|
+
const data = readJsonFile(getCacheFilePath());
|
|
271
|
+
|
|
272
|
+
if (!isValidCoresCache(data)) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Check if cache is for current platform/arch
|
|
277
|
+
if (data.platform !== platform() || data.arch !== arch()) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Check if cache has expired
|
|
282
|
+
const age = Date.now() - data.timestamp;
|
|
283
|
+
if (age > CACHE_DURATION_MS) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return data.cores;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Write the cores list to cache
|
|
292
|
+
*/
|
|
293
|
+
const writeCoresCache = (cores: AvailableCoreInfo[]): void => {
|
|
294
|
+
const cachePath = getCacheFilePath();
|
|
295
|
+
const cacheDir = dirname(cachePath);
|
|
296
|
+
|
|
297
|
+
ensureDirectory(cacheDir);
|
|
298
|
+
|
|
299
|
+
const cache: CoresCache = {
|
|
300
|
+
timestamp: Date.now(),
|
|
301
|
+
platform: platform(),
|
|
302
|
+
arch: arch(),
|
|
303
|
+
cores,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
writeFileSync(cachePath, JSON.stringify(cache), "utf-8");
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** Progress callback for download operations */
|
|
310
|
+
export interface DownloadProgress {
|
|
311
|
+
bytesDownloaded: number;
|
|
312
|
+
totalBytes: number | null;
|
|
313
|
+
phase: "downloading" | "extracting" | "building" | "complete";
|
|
314
|
+
/** Build-specific message when phase is "building" */
|
|
315
|
+
buildMessage?: string;
|
|
316
|
+
/** Build progress percentage (0-100) when phase is "building" */
|
|
317
|
+
buildProgressPercent?: number;
|
|
318
|
+
/** Human-readable build progress (e.g., "15 of 238 files") */
|
|
319
|
+
buildProgressText?: string;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Get the buildbot URL path and file extension for the current platform
|
|
324
|
+
*/
|
|
325
|
+
export const getBuildPath = (): { path: string; ext: string } => {
|
|
326
|
+
const p = platform();
|
|
327
|
+
const a = arch();
|
|
328
|
+
|
|
329
|
+
switch (p) {
|
|
330
|
+
case "darwin":
|
|
331
|
+
// macOS - arm64 or x86_64
|
|
332
|
+
if (a === "arm64") {
|
|
333
|
+
return { path: "apple/osx/arm64", ext: ".dylib" };
|
|
334
|
+
} else {
|
|
335
|
+
return { path: "apple/osx/x86_64", ext: ".dylib" };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
case "linux":
|
|
339
|
+
// Linux - x86_64 or arm
|
|
340
|
+
if (a === "arm64" || a === "arm") {
|
|
341
|
+
return { path: "linux/armv7-neon-hf", ext: ".so" };
|
|
342
|
+
} else {
|
|
343
|
+
return { path: "linux/x86_64", ext: ".so" };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
case "win32":
|
|
347
|
+
// Windows - x86_64 or x86
|
|
348
|
+
if (a === "x64") {
|
|
349
|
+
return { path: "windows/x86_64", ext: ".dll" };
|
|
350
|
+
} else {
|
|
351
|
+
return { path: "windows/x86", ext: ".dll" };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
default:
|
|
355
|
+
throw new CoreDownloadError('UNSUPPORTED_PLATFORM', p);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Parse the buildbot index file to get available cores
|
|
361
|
+
* Format: YYYY-MM-DD hexhash filename
|
|
362
|
+
* Example: 2026-01-21 eabc1f27 mgba_libretro.dylib.zip
|
|
363
|
+
*/
|
|
364
|
+
const parseIndexFile = (content: string, ext: string): AvailableCoreInfo[] => {
|
|
365
|
+
const cores: AvailableCoreInfo[] = [];
|
|
366
|
+
const lines = content.split("\n");
|
|
367
|
+
|
|
368
|
+
for (const line of lines) {
|
|
369
|
+
// Format: YYYY-MM-DD hexhash filename
|
|
370
|
+
const match = line.match(/^(\d{4}-\d{2}-\d{2}) [a-f0-9]+ (.+)$/);
|
|
371
|
+
if (!match) { continue; }
|
|
372
|
+
|
|
373
|
+
const [, date, filename] = match;
|
|
374
|
+
|
|
375
|
+
// Only include core files (with .zip extension)
|
|
376
|
+
if (!filename.endsWith(`${ext}.zip`)) { continue; }
|
|
377
|
+
|
|
378
|
+
// Extract core name from filename: corename_libretro.ext.zip
|
|
379
|
+
const coreMatch = filename.match(/^(.+)_libretro/);
|
|
380
|
+
if (!coreMatch) { continue; }
|
|
381
|
+
|
|
382
|
+
const name = coreMatch[1];
|
|
383
|
+
|
|
384
|
+
cores.push({
|
|
385
|
+
name,
|
|
386
|
+
filename,
|
|
387
|
+
size: 0, // Size not available in this index format
|
|
388
|
+
date,
|
|
389
|
+
description: CORE_DESCRIPTIONS[name],
|
|
390
|
+
isRecommended: RECOMMENDED_CORE_NAMES.has(name),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return cores;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Fetch the list of available cores from the buildbot.
|
|
399
|
+
* Uses a local cache that's refreshed at most once per week.
|
|
400
|
+
*
|
|
401
|
+
* @param forceRefresh If true, bypasses the cache and fetches fresh data
|
|
402
|
+
*/
|
|
403
|
+
export const fetchAvailableCores = async (forceRefresh = false): Promise<AvailableCoreInfo[]> => {
|
|
404
|
+
// Check cache first unless force refresh requested
|
|
405
|
+
if (!forceRefresh) {
|
|
406
|
+
const cached = readCoresCache();
|
|
407
|
+
if (cached) {
|
|
408
|
+
return cached;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const { path: buildPath, ext } = getBuildPath();
|
|
413
|
+
const indexUrl = `${BUILDBOT_BASE_URL}/${buildPath}/latest/${INDEX_SUFFIX}`;
|
|
414
|
+
|
|
415
|
+
const response = await fetch(indexUrl);
|
|
416
|
+
if (!response.ok) {
|
|
417
|
+
throw new CoreDownloadError('FETCH_INDEX_FAILED', `HTTP ${response.status}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const content = await response.text();
|
|
421
|
+
const cores = parseIndexFile(content, ext);
|
|
422
|
+
|
|
423
|
+
// Save to cache
|
|
424
|
+
writeCoresCache(cores);
|
|
425
|
+
|
|
426
|
+
return cores;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Extract a zip file to a destination directory
|
|
431
|
+
*/
|
|
432
|
+
const extractZip = async (zipPath: string, destDir: string): Promise<void> => {
|
|
433
|
+
try {
|
|
434
|
+
execSync(`unzip -o -q "${zipPath}" -d "${destDir}"`, {
|
|
435
|
+
stdio: "pipe",
|
|
436
|
+
});
|
|
437
|
+
} catch {
|
|
438
|
+
throw new CoreDownloadError('EXTRACT_FAILED', zipPath);
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Download a core from the buildbot, or build from source if required.
|
|
444
|
+
*
|
|
445
|
+
* On ARM macOS, certain cores (like mupen64plus_next) require building from
|
|
446
|
+
* source because the pre-built binaries need OpenGL which isn't available
|
|
447
|
+
* for terminal-based rendering.
|
|
448
|
+
*
|
|
449
|
+
* @param coreName Name of the core to download (e.g., "mgba")
|
|
450
|
+
* @param onProgress Optional callback for progress updates
|
|
451
|
+
* @returns Path to the installed core file
|
|
452
|
+
*/
|
|
453
|
+
export const downloadCore = async (
|
|
454
|
+
coreName: string,
|
|
455
|
+
onProgress?: (progress: DownloadProgress) => void
|
|
456
|
+
): Promise<string> => {
|
|
457
|
+
// Check if this core needs to be built from source on this platform
|
|
458
|
+
if (requiresBuildFromSource(coreName)) {
|
|
459
|
+
const reason = getBuildReason(coreName);
|
|
460
|
+
onProgress?.({
|
|
461
|
+
bytesDownloaded: 0,
|
|
462
|
+
totalBytes: null,
|
|
463
|
+
phase: "building",
|
|
464
|
+
buildMessage: reason ?? "Building from source...",
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Wrap build progress into download progress format
|
|
468
|
+
const buildProgressHandler = (buildProgress: BuildProgress): void => {
|
|
469
|
+
onProgress?.({
|
|
470
|
+
bytesDownloaded: 0,
|
|
471
|
+
totalBytes: null,
|
|
472
|
+
phase: buildProgress.phase === "complete" ? "complete" : "building",
|
|
473
|
+
buildMessage: buildProgress.message,
|
|
474
|
+
buildProgressPercent: buildProgress.progressPercent,
|
|
475
|
+
buildProgressText: buildProgress.progressText,
|
|
476
|
+
});
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
return buildCore(coreName, buildProgressHandler);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const { path: buildPath, ext } = getBuildPath();
|
|
483
|
+
const coresDir = getCoresDirectory();
|
|
484
|
+
|
|
485
|
+
// Ensure cores directory exists
|
|
486
|
+
if (!existsSync(coresDir)) {
|
|
487
|
+
await mkdir(coresDir, { recursive: true });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const coreFileName = `${coreName}_libretro${ext}`;
|
|
491
|
+
const destPath = join(coresDir, coreFileName);
|
|
492
|
+
|
|
493
|
+
// Check if already exists
|
|
494
|
+
if (existsSync(destPath)) {
|
|
495
|
+
onProgress?.({ bytesDownloaded: 0, totalBytes: 0, phase: "complete" });
|
|
496
|
+
return destPath;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Download the zip file
|
|
500
|
+
const compressedFileName = `${coreFileName}.zip`;
|
|
501
|
+
const zipUrl = `${BUILDBOT_BASE_URL}/${buildPath}/latest/${compressedFileName}`;
|
|
502
|
+
const tempPath = `${destPath}.zip`;
|
|
503
|
+
|
|
504
|
+
logger.info(`Downloading core ${coreName} from ${zipUrl}`, "CoreDownloader");
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
const response = await fetch(zipUrl);
|
|
508
|
+
if (!response.ok) {
|
|
509
|
+
throw new CoreDownloadError('DOWNLOAD_FAILED', `HTTP ${response.status}`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const body = response.body;
|
|
513
|
+
if (!body) {
|
|
514
|
+
throw new CoreDownloadError('NO_RESPONSE_BODY');
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const totalBytes = response.headers.get("content-length");
|
|
518
|
+
const totalSize = totalBytes ? parseInt(totalBytes, 10) : null;
|
|
519
|
+
|
|
520
|
+
// Track download progress
|
|
521
|
+
let bytesDownloaded = 0;
|
|
522
|
+
const progressStream = new TransformStream<Uint8Array, Uint8Array>({
|
|
523
|
+
transform: (chunk, controller) => {
|
|
524
|
+
bytesDownloaded += chunk.length;
|
|
525
|
+
onProgress?.({
|
|
526
|
+
bytesDownloaded,
|
|
527
|
+
totalBytes: totalSize,
|
|
528
|
+
phase: "downloading",
|
|
529
|
+
});
|
|
530
|
+
controller.enqueue(chunk);
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// Convert web ReadableStream to Node.js Readable through transform
|
|
535
|
+
const webStream = body.pipeThrough(progressStream);
|
|
536
|
+
const nodeStream = Readable.fromWeb(webStream as import("stream/web").ReadableStream);
|
|
537
|
+
const fileStream = createWriteStream(tempPath);
|
|
538
|
+
|
|
539
|
+
await pipeline(nodeStream, fileStream);
|
|
540
|
+
|
|
541
|
+
// Extract the zip file
|
|
542
|
+
onProgress?.({
|
|
543
|
+
bytesDownloaded,
|
|
544
|
+
totalBytes: totalSize,
|
|
545
|
+
phase: "extracting",
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
await extractZip(tempPath, coresDir);
|
|
549
|
+
|
|
550
|
+
// Clean up the zip file
|
|
551
|
+
await unlink(tempPath);
|
|
552
|
+
|
|
553
|
+
// Make the core executable (important for macOS/Linux)
|
|
554
|
+
if (platform() !== "win32") {
|
|
555
|
+
await chmod(destPath, EXECUTABLE_PERMISSIONS);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
logger.info(`Successfully installed core ${coreName} to ${destPath}`, "CoreDownloader");
|
|
559
|
+
|
|
560
|
+
onProgress?.({
|
|
561
|
+
bytesDownloaded,
|
|
562
|
+
totalBytes: totalSize,
|
|
563
|
+
phase: "complete",
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
return destPath;
|
|
567
|
+
} catch (error) {
|
|
568
|
+
const errorMessage = getErrorMessage(error);
|
|
569
|
+
logger.error(`Failed to download core ${coreName}: ${errorMessage}`, "CoreDownloader");
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Check if a core is installed
|
|
576
|
+
*/
|
|
577
|
+
export const isCoreInstalled = (coreName: string): boolean => {
|
|
578
|
+
const { ext } = getBuildPath();
|
|
579
|
+
const coresDir = getCoresDirectory();
|
|
580
|
+
const coreFileName = `${coreName}_libretro${ext}`;
|
|
581
|
+
return existsSync(join(coresDir, coreFileName));
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Get the path to an installed core
|
|
586
|
+
*/
|
|
587
|
+
export const getCorePath = (coreName: string): string | null => {
|
|
588
|
+
const { ext } = getBuildPath();
|
|
589
|
+
const coresDir = getCoresDirectory();
|
|
590
|
+
const coreFileName = `${coreName}_libretro${ext}`;
|
|
591
|
+
const fullPath = join(coresDir, coreFileName);
|
|
592
|
+
return existsSync(fullPath) ? fullPath : null;
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Remove an installed core
|
|
597
|
+
* @param coreName Name of the core to remove (e.g., "mgba", "mupen64plus_next")
|
|
598
|
+
* @returns true if the core was removed, false if it wasn't installed
|
|
599
|
+
*/
|
|
600
|
+
export const removeCore = async (coreName: string): Promise<boolean> => {
|
|
601
|
+
const corePath = getCorePath(coreName);
|
|
602
|
+
|
|
603
|
+
if (!corePath) {
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
try {
|
|
608
|
+
await unlink(corePath);
|
|
609
|
+
logger.info(`Removed core ${coreName} from ${corePath}`, "CoreDownloader");
|
|
610
|
+
return true;
|
|
611
|
+
} catch (error) {
|
|
612
|
+
const errorMessage = getErrorMessage(error);
|
|
613
|
+
logger.error(`Failed to remove core ${coreName}: ${errorMessage}`, "CoreDownloader");
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
// Re-export build-related functions for UI use
|
|
619
|
+
export { requiresBuildFromSource, getBuildReason } from "../coreBuilder";
|
|
620
|
+
export * from "./types";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core downloader error types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createTypedError } from '../../utils/typedError';
|
|
6
|
+
|
|
7
|
+
export type CoreDownloadErrorCode =
|
|
8
|
+
| 'UNSUPPORTED_PLATFORM'
|
|
9
|
+
| 'FETCH_INDEX_FAILED'
|
|
10
|
+
| 'EXTRACT_FAILED'
|
|
11
|
+
| 'DOWNLOAD_FAILED'
|
|
12
|
+
| 'NO_RESPONSE_BODY';
|
|
13
|
+
|
|
14
|
+
const { TypedError, isTypedError } = createTypedError<CoreDownloadErrorCode>('CoreDownloadError');
|
|
15
|
+
export const CoreDownloadError = TypedError;
|
|
16
|
+
export type CoreDownloadError = InstanceType<typeof TypedError>;
|
|
17
|
+
export const isCoreDownloadError = isTypedError;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Preferences
|
|
3
|
+
*
|
|
4
|
+
* Persists user's preferred core for each file extension.
|
|
5
|
+
* When multiple cores support the same ROM format, the user can choose
|
|
6
|
+
* to remember their selection so they won't be prompted again.
|
|
7
|
+
*
|
|
8
|
+
* Stored as JSON in the config directory: <config>/core-preferences.json
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { writeFileSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { isPlainObject, isString } from 'remeda';
|
|
14
|
+
import { getConfigDirectory } from '../../utils/paths';
|
|
15
|
+
import { ensureDirectory } from '../../utils/ensureDirectory';
|
|
16
|
+
import { readJsonFile } from '../../utils/readJsonFile';
|
|
17
|
+
import { logger } from '../../utils/logger';
|
|
18
|
+
|
|
19
|
+
const PREFERENCES_FILENAME = 'core-preferences.json';
|
|
20
|
+
|
|
21
|
+
const getPreferencesPath = (): string =>
|
|
22
|
+
join(getConfigDirectory(), PREFERENCES_FILENAME);
|
|
23
|
+
|
|
24
|
+
/** Load the preferences map from disk */
|
|
25
|
+
const loadPreferences = (): Record<string, string> => {
|
|
26
|
+
const parsed = readJsonFile(getPreferencesPath());
|
|
27
|
+
if (!isPlainObject(parsed)) {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
// Filter to only valid string→string entries
|
|
31
|
+
const result: Record<string, string> = {};
|
|
32
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
33
|
+
if (isString(value)) {
|
|
34
|
+
result[key] = value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Save the preferences map to disk */
|
|
41
|
+
const savePreferences = (prefs: Record<string, string>): void => {
|
|
42
|
+
const path = getPreferencesPath();
|
|
43
|
+
ensureDirectory(getConfigDirectory());
|
|
44
|
+
writeFileSync(path, JSON.stringify(prefs, null, 2) + '\n', 'utf-8');
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the preferred core ID for a file extension.
|
|
49
|
+
*
|
|
50
|
+
* @param ext File extension (lowercase, with dot, e.g., ".z64")
|
|
51
|
+
* @returns Core ID or null if no preference is saved
|
|
52
|
+
*/
|
|
53
|
+
export const getPreferredCoreId = (ext: string): string | null => {
|
|
54
|
+
const prefs = loadPreferences();
|
|
55
|
+
return prefs[ext.toLowerCase()] ?? null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Save a core preference for a file extension.
|
|
60
|
+
*
|
|
61
|
+
* @param ext File extension (lowercase, with dot, e.g., ".z64")
|
|
62
|
+
* @param coreId The core ID to prefer for this extension
|
|
63
|
+
*/
|
|
64
|
+
export const setPreferredCoreId = (ext: string, coreId: string): void => {
|
|
65
|
+
const prefs = loadPreferences();
|
|
66
|
+
prefs[ext.toLowerCase()] = coreId;
|
|
67
|
+
savePreferences(prefs);
|
|
68
|
+
logger.info(`Saved core preference: ${ext} -> ${coreId}`, 'CorePreferences');
|
|
69
|
+
};
|