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.
Files changed (213) hide show
  1. package/.claude/settings.local.json +77 -0
  2. package/.node-version +1 -0
  3. package/CLAUDE.md +435 -0
  4. package/README.md +404 -0
  5. package/TODO.md +655 -0
  6. package/dist/index.cjs +25108 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +25085 -0
  9. package/docs/building-libretro-cores-arm-mac.md +237 -0
  10. package/docs/config-file-format.md +488 -0
  11. package/docs/cores-trd.md +425 -0
  12. package/docs/headless-hardware-rendering-trd.md +676 -0
  13. package/docs/libretro-cores-trd.md +997 -0
  14. package/docs/mupen64-software-rendering-trd.md +751 -0
  15. package/docs/n64-support-trd.md +306 -0
  16. package/docs/native-rendering-trd.md +540 -0
  17. package/docs/native-ui-rendering-trd.md +1195 -0
  18. package/docs/netplay-trd.md +665 -0
  19. package/docs/retroarch-netplay-docs.md +277 -0
  20. package/docs/save-state-format.md +666 -0
  21. package/eslint.config.js +111 -0
  22. package/icon/icon.png +0 -0
  23. package/icon/icon.pxd +0 -0
  24. package/package.json +63 -0
  25. package/pnpm-workspace.yaml +10 -0
  26. package/src/Emulator/consts.ts +14 -0
  27. package/src/Emulator/index.ts +2496 -0
  28. package/src/Emulator/saveState/index.ts +155 -0
  29. package/src/Emulator/screenshot/index.ts +160 -0
  30. package/src/Emulator/terminalDimensions/index.ts +79 -0
  31. package/src/Emulator/types.ts +83 -0
  32. package/src/cli/commands/consts.ts +10 -0
  33. package/src/cli/commands/index.ts +462 -0
  34. package/src/cli/parseArgs/consts.ts +17 -0
  35. package/src/cli/parseArgs/index.ts +457 -0
  36. package/src/cli/parseArgs/types.ts +61 -0
  37. package/src/cli/runEmulator/index.ts +406 -0
  38. package/src/cli/runEmulator/types.ts +7 -0
  39. package/src/consts.ts +19 -0
  40. package/src/core/button/consts.ts +35 -0
  41. package/src/core/button/index.ts +123 -0
  42. package/src/core/core.ts +300 -0
  43. package/src/core/index.ts +19 -0
  44. package/src/cores/libretro/api/index.ts +334 -0
  45. package/src/cores/libretro/api/types.ts +148 -0
  46. package/src/cores/libretro/callbacks/consts.ts +41 -0
  47. package/src/cores/libretro/callbacks/index.ts +456 -0
  48. package/src/cores/libretro/consts.ts +45 -0
  49. package/src/cores/libretro/coreOptions/consts.ts +36 -0
  50. package/src/cores/libretro/coreOptions/index.ts +222 -0
  51. package/src/cores/libretro/environment/consts.ts +118 -0
  52. package/src/cores/libretro/environment/index.ts +1095 -0
  53. package/src/cores/libretro/index.ts +937 -0
  54. package/src/cores/libretro/loader/index.ts +496 -0
  55. package/src/cores/libretro/pixelFormat/consts.ts +43 -0
  56. package/src/cores/libretro/pixelFormat/index.ts +397 -0
  57. package/src/cores/libretro/types.ts +339 -0
  58. package/src/frontend/AudioManager/index.ts +420 -0
  59. package/src/frontend/SettingsManager/index.ts +250 -0
  60. package/src/frontend/config/index.ts +608 -0
  61. package/src/frontend/config/tests.ts +354 -0
  62. package/src/frontend/config/types.ts +36 -0
  63. package/src/frontend/consts.ts +114 -0
  64. package/src/frontend/coreBuilder/index.ts +644 -0
  65. package/src/frontend/coreBuilder/types.ts +15 -0
  66. package/src/frontend/coreDownloader/index.ts +620 -0
  67. package/src/frontend/coreDownloader/types.ts +17 -0
  68. package/src/frontend/corePreferences/index.ts +69 -0
  69. package/src/frontend/coreRegistry/index.ts +276 -0
  70. package/src/frontend/directoryCache/index.ts +75 -0
  71. package/src/frontend/index.ts +79 -0
  72. package/src/frontend/notifications/consts.ts +14 -0
  73. package/src/frontend/notifications/index.ts +250 -0
  74. package/src/frontend/playlist/consts.ts +168 -0
  75. package/src/frontend/playlist/index.ts +899 -0
  76. package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
  77. package/src/frontend/playlist/labelFormatter/index.ts +155 -0
  78. package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
  79. package/src/frontend/playlist/reader/index.ts +559 -0
  80. package/src/frontend/playlist/sync/index.ts +511 -0
  81. package/src/frontend/playlist/systemLookup/index.ts +233 -0
  82. package/src/frontend/playlist/utils/index.ts +50 -0
  83. package/src/frontend/romScanner/consts.ts +348 -0
  84. package/src/frontend/romScanner/index.ts +1957 -0
  85. package/src/frontend/saveServices/consts.ts +2 -0
  86. package/src/frontend/saveServices/index.ts +313 -0
  87. package/src/frontend/serviceProvider/index.ts +108 -0
  88. package/src/frontend/serviceProvider/types.ts +13 -0
  89. package/src/index.ts +428 -0
  90. package/src/input/Controller/consts.ts +50 -0
  91. package/src/input/Controller/index.ts +81 -0
  92. package/src/input/GamepadManager/consts.ts +22 -0
  93. package/src/input/GamepadManager/index.ts +418 -0
  94. package/src/input/InputManager/consts.ts +86 -0
  95. package/src/input/InputManager/index.ts +593 -0
  96. package/src/input/InputMapper/consts.ts +33 -0
  97. package/src/input/InputMapper/index.ts +436 -0
  98. package/src/input/consts.ts +410 -0
  99. package/src/input/gamepadProfiles/index.ts +1100 -0
  100. package/src/input/index.ts +38 -0
  101. package/src/input/inputUtils/index.ts +31 -0
  102. package/src/netplay/FrameBuffer/consts.ts +2 -0
  103. package/src/netplay/FrameBuffer/index.ts +364 -0
  104. package/src/netplay/FrameBuffer/tests.ts +286 -0
  105. package/src/netplay/InputBuffer/consts.ts +7 -0
  106. package/src/netplay/InputBuffer/index.ts +347 -0
  107. package/src/netplay/InputBuffer/tests.ts +166 -0
  108. package/src/netplay/NetplayClient/index.ts +976 -0
  109. package/src/netplay/NetplayConnection/index.ts +536 -0
  110. package/src/netplay/NetplayDiscovery/consts.ts +41 -0
  111. package/src/netplay/NetplayDiscovery/index.ts +525 -0
  112. package/src/netplay/NetplayServer/index.ts +1407 -0
  113. package/src/netplay/SyncManager/index.ts +984 -0
  114. package/src/netplay/SyncManager/tests.ts +419 -0
  115. package/src/netplay/consts.ts +371 -0
  116. package/src/netplay/crc32/consts.ts +14 -0
  117. package/src/netplay/crc32/index.ts +97 -0
  118. package/src/netplay/crc32/tests.ts +40 -0
  119. package/src/netplay/index.ts +41 -0
  120. package/src/netplay/netplayLogger/consts.ts +30 -0
  121. package/src/netplay/netplayLogger/index.ts +345 -0
  122. package/src/netplay/protocol/consts.ts +86 -0
  123. package/src/netplay/protocol/index.ts +1280 -0
  124. package/src/netplay/protocol/tests.ts +606 -0
  125. package/src/netplay/protocol/types.ts +20 -0
  126. package/src/netplay/types.ts +395 -0
  127. package/src/rendering/KittyRenderer/index.ts +616 -0
  128. package/src/rendering/NativeRenderer/index.ts +279 -0
  129. package/src/rendering/NativeRenderer/tests.ts +133 -0
  130. package/src/rendering/TerminalRenderer/index.ts +770 -0
  131. package/src/rendering/consts.ts +401 -0
  132. package/src/rendering/fonts/CozetteVector.ttf +0 -0
  133. package/src/rendering/index.ts +26 -0
  134. package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
  135. package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
  136. package/src/rendering/nativeUi/consts.ts +6 -0
  137. package/src/rendering/nativeUi/index.ts +20 -0
  138. package/src/rendering/postProcessing/consts.ts +38 -0
  139. package/src/rendering/postProcessing/index.ts +923 -0
  140. package/src/rendering/shared/ansi/consts.ts +12 -0
  141. package/src/rendering/shared/ansi/index.ts +104 -0
  142. package/src/rendering/shared/consts.ts +2 -0
  143. package/src/rendering/shared/fitToTerminal/index.ts +67 -0
  144. package/src/ui/AddRomsPrompt/consts.ts +13 -0
  145. package/src/ui/AddRomsPrompt/index.tsx +781 -0
  146. package/src/ui/App/consts.ts +2 -0
  147. package/src/ui/App/index.tsx +456 -0
  148. package/src/ui/AppCapabilities/index.tsx +67 -0
  149. package/src/ui/ConfigContext/index.tsx +56 -0
  150. package/src/ui/CoreManager/consts.ts +11 -0
  151. package/src/ui/CoreManager/index.tsx +779 -0
  152. package/src/ui/CoreSelector/consts.ts +2 -0
  153. package/src/ui/CoreSelector/index.tsx +251 -0
  154. package/src/ui/DialogContainer/index.tsx +42 -0
  155. package/src/ui/DialogOptionsList/index.tsx +61 -0
  156. package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
  157. package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
  158. package/src/ui/GamepadContext/consts.ts +15 -0
  159. package/src/ui/GamepadContext/index.tsx +295 -0
  160. package/src/ui/NativeDialog/index.tsx +120 -0
  161. package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
  162. package/src/ui/NetplayPauseMenu/consts.ts +2 -0
  163. package/src/ui/NetplayPauseMenu/index.tsx +97 -0
  164. package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
  165. package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
  166. package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
  167. package/src/ui/RomBrowser/consts.ts +61 -0
  168. package/src/ui/RomBrowser/index.tsx +1164 -0
  169. package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
  170. package/src/ui/RomBrowser/types.ts +67 -0
  171. package/src/ui/SaveStateDialog/consts.ts +2 -0
  172. package/src/ui/SaveStateDialog/index.tsx +225 -0
  173. package/src/ui/WarningDialog/index.tsx +113 -0
  174. package/src/ui/consts.ts +27 -0
  175. package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
  176. package/src/ui/hooks/useClearTerminal/index.ts +37 -0
  177. package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
  178. package/src/ui/hooks/useGamepad/consts.ts +21 -0
  179. package/src/ui/hooks/useGamepad/index.ts +194 -0
  180. package/src/ui/index.ts +27 -0
  181. package/src/utils/buffer/consts.ts +17 -0
  182. package/src/utils/buffer/index.ts +129 -0
  183. package/src/utils/color/consts.ts +58 -0
  184. package/src/utils/color/index.ts +183 -0
  185. package/src/utils/compression/consts.ts +50 -0
  186. package/src/utils/compression/index.ts +101 -0
  187. package/src/utils/consts.ts +2 -0
  188. package/src/utils/crc32/consts.ts +22 -0
  189. package/src/utils/crc32/index.ts +83 -0
  190. package/src/utils/ensureDirectory/index.ts +10 -0
  191. package/src/utils/format/consts.ts +8 -0
  192. package/src/utils/format/index.ts +53 -0
  193. package/src/utils/getErrorMessage/index.ts +10 -0
  194. package/src/utils/index.ts +113 -0
  195. package/src/utils/ini/index.ts +200 -0
  196. package/src/utils/kitty/consts.ts +13 -0
  197. package/src/utils/kitty/index.ts +181 -0
  198. package/src/utils/logger/consts.ts +35 -0
  199. package/src/utils/logger/index.ts +217 -0
  200. package/src/utils/paths/consts.ts +18 -0
  201. package/src/utils/paths/index.ts +151 -0
  202. package/src/utils/png/consts.ts +34 -0
  203. package/src/utils/png/index.ts +131 -0
  204. package/src/utils/readJsonFile/index.ts +16 -0
  205. package/src/utils/rotateLogFile/index.ts +44 -0
  206. package/src/utils/safeClose/index.ts +10 -0
  207. package/src/utils/terminal/consts.ts +8 -0
  208. package/src/utils/terminal/index.ts +102 -0
  209. package/src/utils/thumbnailRenderer/consts.ts +2 -0
  210. package/src/utils/thumbnailRenderer/index.ts +147 -0
  211. package/src/utils/typedError/index.ts +26 -0
  212. package/tsconfig.json +31 -0
  213. 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
+ };