|
| 1 | +import { app } from 'electron'; |
| 2 | +import fs from 'fs'; |
| 3 | +import path from 'path'; |
| 4 | +import https from 'https'; |
| 5 | +import { GITHUB_CONFIG } from './shared/config/GitHub.config'; |
| 6 | +import { BUILTIN_THEME, THEME_GITHUB_PATH } from './shared/config/Theme.config'; |
| 7 | +import { LANGUAGE_GITHUB_PATH } from './shared/config/Language.config'; |
| 8 | +import { ENGLISH } from './shared/config/DefaultLanguage.config'; |
| 9 | +import type { ThemeDefinition, LocalThemeState } from './shared/types/Theme.types'; |
| 10 | +import type { LanguageDefinition, LocalLanguageState } from './shared/types/Language.types'; |
| 11 | + |
| 12 | +function dataDir(): string { |
| 13 | + return app.getPath('userData'); |
| 14 | +} |
| 15 | + |
| 16 | +function httpsGetJson(url: string): Promise<unknown> { |
| 17 | + return new Promise((resolve, reject) => { |
| 18 | + const options = { headers: { 'User-Agent': 'java-runner-client' } }; |
| 19 | + const req = https.get(url, options, (res) => { |
| 20 | + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { |
| 21 | + resolve(httpsGetJson(res.headers.location)); |
| 22 | + return; |
| 23 | + } |
| 24 | + let data = ''; |
| 25 | + res.on('data', (c) => (data += c)); |
| 26 | + res.on('end', () => { |
| 27 | + try { resolve(JSON.parse(data)); } |
| 28 | + catch { reject(new Error('JSON parse error')); } |
| 29 | + }); |
| 30 | + }); |
| 31 | + req.on('error', reject); |
| 32 | + req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); }); |
| 33 | + }); |
| 34 | +} |
| 35 | + |
| 36 | +function rawUrl(ghPath: string, filename: string): string { |
| 37 | + return `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/main/${ghPath}/${filename}`; |
| 38 | +} |
| 39 | + |
| 40 | +function contentsUrl(ghPath: string): string { |
| 41 | + return `${GITHUB_CONFIG.apiBase}/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/contents/${ghPath}`; |
| 42 | +} |
| 43 | + |
| 44 | +// ─── Themes ─────────────────────────────────────────────────────────────────── |
| 45 | + |
| 46 | +const THEME_FILE = 'theme-state.json'; |
| 47 | + |
| 48 | +function themeFilePath(): string { |
| 49 | + return path.join(dataDir(), THEME_FILE); |
| 50 | +} |
| 51 | + |
| 52 | +export function loadThemeState(): LocalThemeState { |
| 53 | + try { |
| 54 | + const raw = fs.readFileSync(themeFilePath(), 'utf8'); |
| 55 | + const state = JSON.parse(raw) as LocalThemeState; |
| 56 | + // Ensure builtin is always present |
| 57 | + if (!state.themes.find((t) => t.id === BUILTIN_THEME.id)) { |
| 58 | + state.themes.unshift(BUILTIN_THEME); |
| 59 | + } |
| 60 | + return state; |
| 61 | + } catch { |
| 62 | + return { activeThemeId: BUILTIN_THEME.id, themes: [BUILTIN_THEME] }; |
| 63 | + } |
| 64 | +} |
| 65 | + |
| 66 | +export function saveThemeState(state: LocalThemeState): void { |
| 67 | + fs.writeFileSync(themeFilePath(), JSON.stringify(state, null, 2), 'utf8'); |
| 68 | +} |
| 69 | + |
| 70 | +export function getActiveTheme(): ThemeDefinition { |
| 71 | + const state = loadThemeState(); |
| 72 | + return state.themes.find((t) => t.id === state.activeThemeId) ?? BUILTIN_THEME; |
| 73 | +} |
| 74 | + |
| 75 | +export function setActiveTheme(themeId: string): ThemeDefinition { |
| 76 | + const state = loadThemeState(); |
| 77 | + if (state.themes.find((t) => t.id === themeId)) { |
| 78 | + state.activeThemeId = themeId; |
| 79 | + saveThemeState(state); |
| 80 | + } |
| 81 | + return getActiveTheme(); |
| 82 | +} |
| 83 | + |
| 84 | +export async function fetchRemoteThemes(): Promise<{ ok: boolean; themes?: ThemeDefinition[]; error?: string }> { |
| 85 | + try { |
| 86 | + const listing = await httpsGetJson(contentsUrl(THEME_GITHUB_PATH)); |
| 87 | + if (!Array.isArray(listing)) return { ok: false, error: 'Themes folder not found' }; |
| 88 | + const themes: ThemeDefinition[] = []; |
| 89 | + for (const f of (listing as Array<{ name: string }>).filter((f) => f.name.endsWith('.json'))) { |
| 90 | + try { |
| 91 | + const theme = (await httpsGetJson(rawUrl(THEME_GITHUB_PATH, f.name))) as ThemeDefinition; |
| 92 | + if (theme.id && theme.name && theme.colors) themes.push(theme); |
| 93 | + } catch { /* skip */ } |
| 94 | + } |
| 95 | + return { ok: true, themes }; |
| 96 | + } catch (e) { |
| 97 | + return { ok: false, error: String(e) }; |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +export async function checkThemeUpdate(themeId: string): Promise<{ hasUpdate: boolean; remoteVersion: number; localVersion: number }> { |
| 102 | + const state = loadThemeState(); |
| 103 | + const local = state.themes.find((t) => t.id === themeId); |
| 104 | + if (!local) return { hasUpdate: false, remoteVersion: 0, localVersion: 0 }; |
| 105 | + |
| 106 | + const result = await fetchRemoteThemes(); |
| 107 | + if (!result.ok || !result.themes) return { hasUpdate: false, remoteVersion: local.version, localVersion: local.version }; |
| 108 | + |
| 109 | + const remote = result.themes.find((t) => t.id === themeId); |
| 110 | + if (!remote) return { hasUpdate: false, remoteVersion: local.version, localVersion: local.version }; |
| 111 | + |
| 112 | + return { |
| 113 | + hasUpdate: remote.version > local.version, |
| 114 | + remoteVersion: remote.version, |
| 115 | + localVersion: local.version, |
| 116 | + }; |
| 117 | +} |
| 118 | + |
| 119 | +export async function applyThemeUpdate(themeId: string): Promise<{ ok: boolean; error?: string }> { |
| 120 | + const result = await fetchRemoteThemes(); |
| 121 | + if (!result.ok || !result.themes) return { ok: false, error: result.error ?? 'Fetch failed' }; |
| 122 | + |
| 123 | + const remote = result.themes.find((t) => t.id === themeId); |
| 124 | + if (!remote) return { ok: false, error: 'Theme not found on remote' }; |
| 125 | + |
| 126 | + const state = loadThemeState(); |
| 127 | + const idx = state.themes.findIndex((t) => t.id === themeId); |
| 128 | + if (idx >= 0) state.themes[idx] = remote; |
| 129 | + else state.themes.push(remote); |
| 130 | + saveThemeState(state); |
| 131 | + return { ok: true }; |
| 132 | +} |
| 133 | + |
| 134 | +export function installTheme(theme: ThemeDefinition): void { |
| 135 | + const state = loadThemeState(); |
| 136 | + const idx = state.themes.findIndex((t) => t.id === theme.id); |
| 137 | + if (idx >= 0) state.themes[idx] = theme; |
| 138 | + else state.themes.push(theme); |
| 139 | + saveThemeState(state); |
| 140 | +} |
| 141 | + |
| 142 | +// ─── Languages ──────────────────────────────────────────────────────────────── |
| 143 | + |
| 144 | +const LANG_FILE = 'language-state.json'; |
| 145 | + |
| 146 | +function langFilePath(): string { |
| 147 | + return path.join(dataDir(), LANG_FILE); |
| 148 | +} |
| 149 | + |
| 150 | +export function loadLanguageState(): LocalLanguageState { |
| 151 | + try { |
| 152 | + const raw = fs.readFileSync(langFilePath(), 'utf8'); |
| 153 | + const state = JSON.parse(raw) as LocalLanguageState; |
| 154 | + if (!state.languages.find((l) => l.id === ENGLISH.id)) { |
| 155 | + state.languages.unshift(ENGLISH); |
| 156 | + } |
| 157 | + return state; |
| 158 | + } catch { |
| 159 | + return { activeLanguageId: ENGLISH.id, languages: [ENGLISH] }; |
| 160 | + } |
| 161 | +} |
| 162 | + |
| 163 | +export function saveLanguageState(state: LocalLanguageState): void { |
| 164 | + fs.writeFileSync(langFilePath(), JSON.stringify(state, null, 2), 'utf8'); |
| 165 | +} |
| 166 | + |
| 167 | +export function getActiveLanguage(): LanguageDefinition { |
| 168 | + const state = loadLanguageState(); |
| 169 | + return state.languages.find((l) => l.id === state.activeLanguageId) ?? ENGLISH; |
| 170 | +} |
| 171 | + |
| 172 | +export function setActiveLanguage(langId: string): LanguageDefinition { |
| 173 | + const state = loadLanguageState(); |
| 174 | + if (state.languages.find((l) => l.id === langId)) { |
| 175 | + state.activeLanguageId = langId; |
| 176 | + saveLanguageState(state); |
| 177 | + } |
| 178 | + return getActiveLanguage(); |
| 179 | +} |
| 180 | + |
| 181 | +export async function fetchRemoteLanguages(): Promise<{ ok: boolean; languages?: LanguageDefinition[]; error?: string }> { |
| 182 | + try { |
| 183 | + const listing = await httpsGetJson(contentsUrl(LANGUAGE_GITHUB_PATH)); |
| 184 | + if (!Array.isArray(listing)) return { ok: false, error: 'Languages folder not found' }; |
| 185 | + const languages: LanguageDefinition[] = []; |
| 186 | + for (const f of (listing as Array<{ name: string }>).filter((f) => f.name.endsWith('.json'))) { |
| 187 | + try { |
| 188 | + const lang = (await httpsGetJson(rawUrl(LANGUAGE_GITHUB_PATH, f.name))) as LanguageDefinition; |
| 189 | + if (lang.id && lang.name && lang.strings) languages.push(lang); |
| 190 | + } catch { /* skip */ } |
| 191 | + } |
| 192 | + return { ok: true, languages }; |
| 193 | + } catch (e) { |
| 194 | + return { ok: false, error: String(e) }; |
| 195 | + } |
| 196 | +} |
| 197 | + |
| 198 | +export async function checkLanguageUpdate(langId: string): Promise<{ hasUpdate: boolean; remoteVersion: number; localVersion: number }> { |
| 199 | + const state = loadLanguageState(); |
| 200 | + const local = state.languages.find((l) => l.id === langId); |
| 201 | + if (!local) return { hasUpdate: false, remoteVersion: 0, localVersion: 0 }; |
| 202 | + |
| 203 | + const result = await fetchRemoteLanguages(); |
| 204 | + if (!result.ok || !result.languages) return { hasUpdate: false, remoteVersion: local.version, localVersion: local.version }; |
| 205 | + |
| 206 | + const remote = result.languages.find((l) => l.id === langId); |
| 207 | + if (!remote) return { hasUpdate: false, remoteVersion: local.version, localVersion: local.version }; |
| 208 | + |
| 209 | + return { |
| 210 | + hasUpdate: remote.version > local.version, |
| 211 | + remoteVersion: remote.version, |
| 212 | + localVersion: local.version, |
| 213 | + }; |
| 214 | +} |
| 215 | + |
| 216 | +export async function applyLanguageUpdate(langId: string): Promise<{ ok: boolean; error?: string }> { |
| 217 | + const result = await fetchRemoteLanguages(); |
| 218 | + if (!result.ok || !result.languages) return { ok: false, error: result.error ?? 'Fetch failed' }; |
| 219 | + |
| 220 | + const remote = result.languages.find((l) => l.id === langId); |
| 221 | + if (!remote) return { ok: false, error: 'Language not found on remote' }; |
| 222 | + |
| 223 | + const state = loadLanguageState(); |
| 224 | + const idx = state.languages.findIndex((l) => l.id === langId); |
| 225 | + if (idx >= 0) state.languages[idx] = remote; |
| 226 | + else state.languages.push(remote); |
| 227 | + saveLanguageState(state); |
| 228 | + return { ok: true }; |
| 229 | +} |
| 230 | + |
| 231 | +export function installLanguage(lang: LanguageDefinition): void { |
| 232 | + const state = loadLanguageState(); |
| 233 | + const idx = state.languages.findIndex((l) => l.id === lang.id); |
| 234 | + if (idx >= 0) state.languages[idx] = lang; |
| 235 | + else state.languages.push(lang); |
| 236 | + saveLanguageState(state); |
| 237 | +} |
| 238 | + |
| 239 | +// ─── Dev mode: load from local project directories ──────────────────────────── |
| 240 | + |
| 241 | +function projectRoot(): string { |
| 242 | + return path.join(__dirname, '..', '..'); |
| 243 | +} |
| 244 | + |
| 245 | +export function loadLocalDevThemes(): ThemeDefinition[] { |
| 246 | + const dir = path.join(projectRoot(), 'themes'); |
| 247 | + if (!fs.existsSync(dir)) return []; |
| 248 | + return fs.readdirSync(dir) |
| 249 | + .filter((f) => f.endsWith('.json')) |
| 250 | + .map((f) => { |
| 251 | + try { |
| 252 | + const raw = fs.readFileSync(path.join(dir, f), 'utf8'); |
| 253 | + const theme = JSON.parse(raw) as ThemeDefinition; |
| 254 | + if (theme.id && theme.name && theme.colors) return theme; |
| 255 | + } catch { /* skip */ } |
| 256 | + return null; |
| 257 | + }) |
| 258 | + .filter((t): t is ThemeDefinition => t !== null); |
| 259 | +} |
| 260 | + |
| 261 | +export function loadLocalDevLanguages(): LanguageDefinition[] { |
| 262 | + const dir = path.join(projectRoot(), 'languages'); |
| 263 | + if (!fs.existsSync(dir)) return []; |
| 264 | + return fs.readdirSync(dir) |
| 265 | + .filter((f) => f.endsWith('.json')) |
| 266 | + .map((f) => { |
| 267 | + try { |
| 268 | + const raw = fs.readFileSync(path.join(dir, f), 'utf8'); |
| 269 | + const lang = JSON.parse(raw) as LanguageDefinition; |
| 270 | + if (lang.id && lang.name && lang.strings) return lang; |
| 271 | + } catch { /* skip */ } |
| 272 | + return null; |
| 273 | + }) |
| 274 | + .filter((l): l is LanguageDefinition => l !== null); |
| 275 | +} |
| 276 | + |
| 277 | +export function syncLocalDevAssets(): { themes: number; languages: number } { |
| 278 | + let tc = 0; |
| 279 | + let lc = 0; |
| 280 | + for (const theme of loadLocalDevThemes()) { |
| 281 | + installTheme(theme); |
| 282 | + tc++; |
| 283 | + } |
| 284 | + for (const lang of loadLocalDevLanguages()) { |
| 285 | + installLanguage(lang); |
| 286 | + lc++; |
| 287 | + } |
| 288 | + return { themes: tc, languages: lc }; |
| 289 | +} |
| 290 | + |
0 commit comments