feat(security): encrypt secrets and centralize app state
Store Twitch and Discord secrets as versioned safeStorage ciphertext behind explicit trusted IPC. Migrate legacy JSON transactionally into authoritative SQLite config and ordered queue repositories with rollback-safe markers and sanitized backups. Redact config exports and update renderer and release-harness contracts for secret-free config responses.
This commit is contained in:
@@ -7,7 +7,6 @@ const OFFLINE_PROXY = 'http://127.0.0.1:1';
|
|||||||
function buildSafeConfig(downloadsDir, overrides = {}) {
|
function buildSafeConfig(downloadsDir, overrides = {}) {
|
||||||
return {
|
return {
|
||||||
client_id: '',
|
client_id: '',
|
||||||
client_secret: '',
|
|
||||||
download_path: downloadsDir,
|
download_path: downloadsDir,
|
||||||
streamers: [],
|
streamers: [],
|
||||||
theme: 'twitch',
|
theme: 'twitch',
|
||||||
@@ -32,7 +31,6 @@ function buildSafeConfig(downloadsDir, overrides = {}) {
|
|||||||
auto_record_poll_seconds: 90,
|
auto_record_poll_seconds: 90,
|
||||||
download_chat_replay: false,
|
download_chat_replay: false,
|
||||||
capture_live_chat: false,
|
capture_live_chat: false,
|
||||||
discord_webhook_url: '',
|
|
||||||
discord_notify_live_start: false,
|
discord_notify_live_start: false,
|
||||||
discord_notify_live_end: false,
|
discord_notify_live_end: false,
|
||||||
discord_notify_vod_complete: false,
|
discord_notify_vod_complete: false,
|
||||||
@@ -50,14 +48,12 @@ function buildSafeConfig(downloadsDir, overrides = {}) {
|
|||||||
delete_parts_after_merge: false,
|
delete_parts_after_merge: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
client_id: '',
|
client_id: '',
|
||||||
client_secret: '',
|
|
||||||
download_path: downloadsDir,
|
download_path: downloadsDir,
|
||||||
streamers: [],
|
streamers: [],
|
||||||
auto_resume_queue_on_startup: false,
|
auto_resume_queue_on_startup: false,
|
||||||
auto_record_streamers: [],
|
auto_record_streamers: [],
|
||||||
download_chat_replay: false,
|
download_chat_replay: false,
|
||||||
capture_live_chat: false,
|
capture_live_chat: false,
|
||||||
discord_webhook_url: '',
|
|
||||||
discord_notify_live_start: false,
|
discord_notify_live_start: false,
|
||||||
discord_notify_live_end: false,
|
discord_notify_live_end: false,
|
||||||
discord_notify_vod_complete: false,
|
discord_notify_vod_complete: false,
|
||||||
@@ -112,6 +108,17 @@ function writeE2eConfig(environment, overrides = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readE2eConfig(environment) {
|
function readE2eConfig(environment) {
|
||||||
|
const databasePath = path.join(environment.appDataDir, 'app.db');
|
||||||
|
if (fs.existsSync(databasePath)) {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const database = new Database(databasePath, { readonly: true });
|
||||||
|
try {
|
||||||
|
const rows = database.prepare('SELECT key, value FROM config_kv').all();
|
||||||
|
return Object.fromEntries(rows.map((row) => [row.key, JSON.parse(row.value)]));
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
return JSON.parse(fs.readFileSync(environment.configFile, 'utf8'));
|
return JSON.parse(fs.readFileSync(environment.configFile, 'utf8'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function inspectHelper() {
|
|||||||
const {
|
const {
|
||||||
createE2eEnvironment,
|
createE2eEnvironment,
|
||||||
getElectronLaunchOptions,
|
getElectronLaunchOptions,
|
||||||
|
readE2eConfig,
|
||||||
cleanupE2eEnvironment
|
cleanupE2eEnvironment
|
||||||
} = require(HELPER_FILE);
|
} = require(HELPER_FILE);
|
||||||
const environment = createE2eEnvironment('isolation-contract');
|
const environment = createE2eEnvironment('isolation-contract');
|
||||||
@@ -111,8 +112,16 @@ function inspectHelper() {
|
|||||||
if (config.auto_cleanup_enabled !== false) {
|
if (config.auto_cleanup_enabled !== false) {
|
||||||
failures.push('Seed config enables automatic cleanup');
|
failures.push('Seed config enables automatic cleanup');
|
||||||
}
|
}
|
||||||
if (config.discord_webhook_url !== '') {
|
if ('client_secret' in config || 'discord_webhook_url' in config) {
|
||||||
failures.push('Seed config contains a webhook');
|
failures.push('Seed config contains secret fields');
|
||||||
|
}
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const database = new Database(path.join(environment.appDataDir, 'app.db'));
|
||||||
|
database.exec('CREATE TABLE config_kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL)');
|
||||||
|
database.prepare('INSERT INTO config_kv(key, value, updated_at) VALUES (?, ?, ?)').run('language', JSON.stringify('sqlite'), 1);
|
||||||
|
database.close();
|
||||||
|
if (readE2eConfig(environment).language !== 'sqlite') {
|
||||||
|
failures.push('Helper did not read authoritative SQLite config');
|
||||||
}
|
}
|
||||||
if (!Array.isArray(queue) || queue.length !== 0) {
|
if (!Array.isArray(queue) || queue.length !== 0) {
|
||||||
failures.push('Seed queue is not empty');
|
failures.push('Seed queue is not empty');
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ async function run() {
|
|||||||
assert(deState.deActive, 'German language button did not activate');
|
assert(deState.deActive, 'German language button did not activate');
|
||||||
assert(enState.enActive, 'English language button did not activate');
|
assert(enState.enActive, 'English language button did not activate');
|
||||||
|
|
||||||
await window.api.saveConfig({ client_id: '', client_secret: '' });
|
await window.api.saveConfig({ client_id: '' });
|
||||||
window.showTab('vods');
|
window.showTab('vods');
|
||||||
await window.selectStreamer('fixture_streamer');
|
await window.selectStreamer('fixture_streamer');
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
const { _electron: electron } = require('playwright');
|
const { _electron: electron } = require('playwright');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const {
|
const {
|
||||||
createE2eEnvironment,
|
createE2eEnvironment,
|
||||||
writeE2eConfig,
|
writeE2eConfig,
|
||||||
@@ -86,6 +88,9 @@ async function run() {
|
|||||||
await app.close();
|
await app.close();
|
||||||
app = null;
|
app = null;
|
||||||
|
|
||||||
|
for (const filename of ['app.db', 'app.db-wal', 'app.db-shm']) {
|
||||||
|
fs.rmSync(path.join(environment.appDataDir, filename), { force: true });
|
||||||
|
}
|
||||||
writeE2eConfig(environment, {
|
writeE2eConfig(environment, {
|
||||||
download_mode: 'full',
|
download_mode: 'full',
|
||||||
part_minutes: 120
|
part_minutes: 120
|
||||||
|
|||||||
+101
-57
@@ -50,6 +50,11 @@ import {
|
|||||||
} from './main/domain/file-capability';
|
} from './main/domain/file-capability';
|
||||||
import { registerTrustedIpcHandler } from './main/domain/privileged-ipc';
|
import { registerTrustedIpcHandler } from './main/domain/privileged-ipc';
|
||||||
import { createRendererQueueItem, getMergeGroupCleanupPaths } from './main/domain/renderer-queue-input';
|
import { createRendererQueueItem, getMergeGroupCleanupPaths } from './main/domain/renderer-queue-input';
|
||||||
|
import { createAppStateStore, type AppStateStore } from './main/domain/app-state-store';
|
||||||
|
import { createExportableConfig } from './main/domain/config-export';
|
||||||
|
import { resolveSecretInputUpdate } from './main/domain/secret-input';
|
||||||
|
import { createSecretStore, type SecretStore } from './main/domain/secret-store';
|
||||||
|
import { createElectronSecureStorage } from './main/infra/secure-storage';
|
||||||
import {
|
import {
|
||||||
setDebugLogFn, initToolDirs,
|
setDebugLogFn, initToolDirs,
|
||||||
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
|
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
|
||||||
@@ -74,8 +79,6 @@ const GITHUB_RELEASES_DOWNLOAD_BASE_URL = 'https://github.com/Sucukdeluxe/Twitch
|
|||||||
|
|
||||||
// Paths
|
// Paths
|
||||||
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager');
|
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager');
|
||||||
const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json');
|
|
||||||
const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json');
|
|
||||||
const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log');
|
const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log');
|
||||||
const PARTIAL_DOWNLOADS_FILE = path.join(APPDATA_DIR, 'partial-downloads.json');
|
const PARTIAL_DOWNLOADS_FILE = path.join(APPDATA_DIR, 'partial-downloads.json');
|
||||||
const TOOLS_DIR = path.join(APPDATA_DIR, 'tools');
|
const TOOLS_DIR = path.join(APPDATA_DIR, 'tools');
|
||||||
@@ -140,7 +143,6 @@ const partialDownloadRegistry = new PartialDownloadRegistry(PARTIAL_DOWNLOADS_FI
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
interface Config {
|
interface Config {
|
||||||
client_id: string;
|
client_id: string;
|
||||||
client_secret: string;
|
|
||||||
download_path: string;
|
download_path: string;
|
||||||
streamers: string[];
|
streamers: string[];
|
||||||
streamer_display_names: Record<string, string>;
|
streamer_display_names: Record<string, string>;
|
||||||
@@ -167,7 +169,6 @@ interface Config {
|
|||||||
auto_record_poll_seconds: number;
|
auto_record_poll_seconds: number;
|
||||||
download_chat_replay: boolean;
|
download_chat_replay: boolean;
|
||||||
capture_live_chat: boolean;
|
capture_live_chat: boolean;
|
||||||
discord_webhook_url: string;
|
|
||||||
discord_notify_live_start: boolean;
|
discord_notify_live_start: boolean;
|
||||||
discord_notify_live_end: boolean;
|
discord_notify_live_end: boolean;
|
||||||
discord_notify_vod_complete: boolean;
|
discord_notify_vod_complete: boolean;
|
||||||
@@ -330,7 +331,6 @@ interface ReleaseUpdateInfo {
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
const defaultConfig: Config = {
|
const defaultConfig: Config = {
|
||||||
client_id: '',
|
client_id: '',
|
||||||
client_secret: '',
|
|
||||||
download_path: DEFAULT_DOWNLOAD_PATH,
|
download_path: DEFAULT_DOWNLOAD_PATH,
|
||||||
streamers: [],
|
streamers: [],
|
||||||
streamer_display_names: {},
|
streamer_display_names: {},
|
||||||
@@ -357,7 +357,6 @@ const defaultConfig: Config = {
|
|||||||
auto_record_poll_seconds: 90,
|
auto_record_poll_seconds: 90,
|
||||||
download_chat_replay: false,
|
download_chat_replay: false,
|
||||||
capture_live_chat: false,
|
capture_live_chat: false,
|
||||||
discord_webhook_url: '',
|
|
||||||
discord_notify_live_start: false,
|
discord_notify_live_start: false,
|
||||||
discord_notify_live_end: false,
|
discord_notify_live_end: false,
|
||||||
discord_notify_vod_complete: false,
|
discord_notify_vod_complete: false,
|
||||||
@@ -425,10 +424,6 @@ function normalizeConfigTemplates(input: Config): Config {
|
|||||||
auto_record_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds),
|
auto_record_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds),
|
||||||
download_chat_replay: input.download_chat_replay === true,
|
download_chat_replay: input.download_chat_replay === true,
|
||||||
capture_live_chat: input.capture_live_chat === true,
|
capture_live_chat: input.capture_live_chat === true,
|
||||||
// Webhook URL is stored but never validated server-side — invalid
|
|
||||||
// URLs just cause the post to fail (logged, non-fatal). Users with
|
|
||||||
// accidental whitespace are saved by the .trim().
|
|
||||||
discord_webhook_url: typeof input.discord_webhook_url === 'string' ? input.discord_webhook_url.trim() : '',
|
|
||||||
discord_notify_live_start: input.discord_notify_live_start === true,
|
discord_notify_live_start: input.discord_notify_live_start === true,
|
||||||
discord_notify_live_end: input.discord_notify_live_end === true,
|
discord_notify_live_end: input.discord_notify_live_end === true,
|
||||||
discord_notify_vod_complete: input.discord_notify_vod_complete === true,
|
discord_notify_vod_complete: input.discord_notify_vod_complete === true,
|
||||||
@@ -476,14 +471,9 @@ function recordDownloadedVodId(vodId: string): void {
|
|||||||
|
|
||||||
function loadConfig(): Config {
|
function loadConfig(): Config {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(CONFIG_FILE)) {
|
const persisted = appStateStore?.loadConfig();
|
||||||
const data = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
if (persisted && isPlainObject(persisted)) {
|
||||||
const parsed = JSON.parse(data);
|
return normalizeConfigTemplates({ ...defaultConfig, ...persisted } as Config);
|
||||||
if (!isPlainObject(parsed)) {
|
|
||||||
console.error('Config file is not a JSON object — using defaults');
|
|
||||||
return normalizeConfigTemplates(defaultConfig);
|
|
||||||
}
|
|
||||||
return normalizeConfigTemplates({ ...defaultConfig, ...parsed });
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error loading config:', e);
|
console.error('Error loading config:', e);
|
||||||
@@ -493,7 +483,8 @@ function loadConfig(): Config {
|
|||||||
|
|
||||||
function saveConfig(config: Config): void {
|
function saveConfig(config: Config): void {
|
||||||
try {
|
try {
|
||||||
writeFileAtomicSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
if (!appStateStore) throw new Error('Application state store is unavailable');
|
||||||
|
appStateStore.saveConfig(config);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error saving config:', e);
|
console.error('Error saving config:', e);
|
||||||
}
|
}
|
||||||
@@ -623,14 +614,7 @@ function loadQueue(): QueueItem[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(QUEUE_FILE)) {
|
const parsed = appStateStore?.loadQueue<QueueItem>() ?? [];
|
||||||
const data = fs.readFileSync(QUEUE_FILE, 'utf-8');
|
|
||||||
const parsed = JSON.parse(data);
|
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
console.error('Queue file is not a JSON array — ignoring');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const items: QueueItem[] = [];
|
const items: QueueItem[] = [];
|
||||||
let droppedCount = 0;
|
let droppedCount = 0;
|
||||||
for (const raw of parsed) {
|
for (const raw of parsed) {
|
||||||
@@ -642,7 +626,6 @@ function loadQueue(): QueueItem[] {
|
|||||||
console.error(`loadQueue: dropped ${droppedCount} invalid queue item(s)`);
|
console.error(`loadQueue: dropped ${droppedCount} invalid queue item(s)`);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error loading queue:', e);
|
console.error('Error loading queue:', e);
|
||||||
}
|
}
|
||||||
@@ -654,9 +637,7 @@ let pendingQueueSnapshot: QueueItem[] | null = null;
|
|||||||
|
|
||||||
function clearQueueFileFromDisk(): void {
|
function clearQueueFileFromDisk(): void {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(QUEUE_FILE)) {
|
appStateStore?.saveQueue([]);
|
||||||
fs.unlinkSync(QUEUE_FILE);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error clearing queue file:', e);
|
console.error('Error clearing queue file:', e);
|
||||||
}
|
}
|
||||||
@@ -669,7 +650,8 @@ function writeQueueToDisk(queue: QueueItem[]): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
writeFileAtomicSync(QUEUE_FILE, JSON.stringify(queue, null, 2));
|
if (!appStateStore) throw new Error('Application state store is unavailable');
|
||||||
|
appStateStore.saveQueue(queue);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error saving queue:', e);
|
console.error('Error saving queue:', e);
|
||||||
}
|
}
|
||||||
@@ -746,9 +728,13 @@ function startDevelopmentReload(): void {
|
|||||||
stopDevelopmentReload = null;
|
stopDevelopmentReload = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let config = loadConfig();
|
let appStateStore: AppStateStore | null = null;
|
||||||
|
let appSecretStore: SecretStore | null = null;
|
||||||
|
let config = normalizeConfigTemplates(defaultConfig);
|
||||||
|
let twitchClientSecret = '';
|
||||||
|
let discordWebhookUrl = '';
|
||||||
let accessToken: string | null = null;
|
let accessToken: string | null = null;
|
||||||
let downloadQueue: QueueItem[] = loadQueue();
|
let downloadQueue: QueueItem[] = [];
|
||||||
let queueIdCounter = 0;
|
let queueIdCounter = 0;
|
||||||
let lastQueueBroadcastFingerprint = '';
|
let lastQueueBroadcastFingerprint = '';
|
||||||
let isDownloading = false;
|
let isDownloading = false;
|
||||||
@@ -1807,7 +1793,7 @@ function validateDownloadedFileIntegrity(filePath: string, expectedDurationSecon
|
|||||||
// TWITCH API
|
// TWITCH API
|
||||||
// ==========================================
|
// ==========================================
|
||||||
async function twitchLogin(): Promise<boolean> {
|
async function twitchLogin(): Promise<boolean> {
|
||||||
if (!config.client_id || !config.client_secret) {
|
if (!config.client_id || !twitchClientSecret) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1815,7 +1801,7 @@ async function twitchLogin(): Promise<boolean> {
|
|||||||
const response = await axios.post('https://id.twitch.tv/oauth2/token', null, {
|
const response = await axios.post('https://id.twitch.tv/oauth2/token', null, {
|
||||||
params: {
|
params: {
|
||||||
client_id: config.client_id,
|
client_id: config.client_id,
|
||||||
client_secret: config.client_secret,
|
client_secret: twitchClientSecret,
|
||||||
grant_type: 'client_credentials'
|
grant_type: 'client_credentials'
|
||||||
},
|
},
|
||||||
timeout: API_TIMEOUT
|
timeout: API_TIMEOUT
|
||||||
@@ -1844,7 +1830,7 @@ function requestTwitchLogin(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
|
async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
|
||||||
if (!config.client_id || !config.client_secret) {
|
if (!config.client_id || !twitchClientSecret) {
|
||||||
accessToken = null;
|
accessToken = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -5281,7 +5267,7 @@ async function sendDiscordWebhook(payload: {
|
|||||||
color: DiscordEmbedColor;
|
color: DiscordEmbedColor;
|
||||||
fields?: Array<{ name: string; value: string; inline?: boolean }>;
|
fields?: Array<{ name: string; value: string; inline?: boolean }>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const url = (config.discord_webhook_url || '').trim();
|
const url = discordWebhookUrl.trim();
|
||||||
if (!isAcceptableDiscordWebhook(url)) return;
|
if (!isAcceptableDiscordWebhook(url)) return;
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
@@ -7208,6 +7194,49 @@ function setupAutoUpdater() {
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
ipcMain.handle('get-config', () => config);
|
ipcMain.handle('get-config', () => config);
|
||||||
|
|
||||||
|
ipcMain.handle('get-secret-status', (event) => {
|
||||||
|
if (!isTrustedRendererEvent(event) || !appSecretStore) {
|
||||||
|
return { encryptionAvailable: false, clientSecretConfigured: false, discordWebhookConfigured: false };
|
||||||
|
}
|
||||||
|
return appSecretStore.status();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('set-client-secret', (event, value: string) => {
|
||||||
|
if (!isTrustedRendererEvent(event) || !appSecretStore || !appSecretStore.status().encryptionAvailable) return appSecretStore?.status() ?? null;
|
||||||
|
const update = resolveSecretInputUpdate(typeof value === 'string' ? value : '', false);
|
||||||
|
if (update.action !== 'set') return appSecretStore.status();
|
||||||
|
appSecretStore.set('twitch_client_secret', update.value);
|
||||||
|
twitchClientSecret = update.value;
|
||||||
|
accessToken = null;
|
||||||
|
twitchLoginInFlight = null;
|
||||||
|
return appSecretStore.status();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('clear-client-secret', (event) => {
|
||||||
|
if (!isTrustedRendererEvent(event) || !appSecretStore) return appSecretStore?.status() ?? null;
|
||||||
|
appSecretStore.clear('twitch_client_secret');
|
||||||
|
twitchClientSecret = '';
|
||||||
|
accessToken = null;
|
||||||
|
twitchLoginInFlight = null;
|
||||||
|
return appSecretStore.status();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('set-discord-webhook', (event, value: string) => {
|
||||||
|
if (!isTrustedRendererEvent(event) || !appSecretStore || !appSecretStore.status().encryptionAvailable) return appSecretStore?.status() ?? null;
|
||||||
|
const update = resolveSecretInputUpdate(typeof value === 'string' ? value : '', false);
|
||||||
|
if (update.action !== 'set') return appSecretStore.status();
|
||||||
|
appSecretStore.set('discord_webhook_url', update.value);
|
||||||
|
discordWebhookUrl = update.value;
|
||||||
|
return appSecretStore.status();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('clear-discord-webhook', (event) => {
|
||||||
|
if (!isTrustedRendererEvent(event) || !appSecretStore) return appSecretStore?.status() ?? null;
|
||||||
|
appSecretStore.clear('discord_webhook_url');
|
||||||
|
discordWebhookUrl = '';
|
||||||
|
return appSecretStore.status();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-automation-status', () => ({
|
ipcMain.handle('get-automation-status', () => ({
|
||||||
autoRecord: {
|
autoRecord: {
|
||||||
watching: Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers.length : 0,
|
watching: Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers.length : 0,
|
||||||
@@ -7240,7 +7269,6 @@ ipcMain.handle('trigger-auto-vod-scan', async (event) => {
|
|||||||
ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability?: string) => {
|
ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability?: string) => {
|
||||||
if (!isTrustedRendererEvent(event)) return config;
|
if (!isTrustedRendererEvent(event)) return config;
|
||||||
const previousClientId = config.client_id;
|
const previousClientId = config.client_id;
|
||||||
const previousClientSecret = config.client_secret;
|
|
||||||
const previousCacheMinutes = config.metadata_cache_minutes;
|
const previousCacheMinutes = config.metadata_cache_minutes;
|
||||||
const previousPersistQueueOnRestart = config.persist_queue_on_restart;
|
const previousPersistQueueOnRestart = config.persist_queue_on_restart;
|
||||||
const previousTheme = config.theme;
|
const previousTheme = config.theme;
|
||||||
@@ -7251,6 +7279,8 @@ ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability
|
|||||||
const previousStreamerList = JSON.stringify(config.streamers || []);
|
const previousStreamerList = JSON.stringify(config.streamers || []);
|
||||||
|
|
||||||
const acceptedConfig = { ...newConfig };
|
const acceptedConfig = { ...newConfig };
|
||||||
|
delete (acceptedConfig as Record<string, unknown>).client_secret;
|
||||||
|
delete (acceptedConfig as Record<string, unknown>).discord_webhook_url;
|
||||||
if (typeof acceptedConfig.download_path === 'string' && acceptedConfig.download_path !== config.download_path) {
|
if (typeof acceptedConfig.download_path === 'string' && acceptedConfig.download_path !== config.download_path) {
|
||||||
const selectedPath = typeof fileCapability === 'string'
|
const selectedPath = typeof fileCapability === 'string'
|
||||||
? resolveFileCapability(event, fileCapability, 'selected-folder')
|
? resolveFileCapability(event, fileCapability, 'selected-folder')
|
||||||
@@ -7261,7 +7291,7 @@ ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability
|
|||||||
}
|
}
|
||||||
config = normalizeConfigTemplates({ ...config, ...acceptedConfig });
|
config = normalizeConfigTemplates({ ...config, ...acceptedConfig });
|
||||||
|
|
||||||
if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) {
|
if (config.client_id !== previousClientId) {
|
||||||
accessToken = null;
|
accessToken = null;
|
||||||
twitchLoginInFlight = null;
|
twitchLoginInFlight = null;
|
||||||
}
|
}
|
||||||
@@ -8185,12 +8215,7 @@ ipcMain.handle('export-config', async (event) => {
|
|||||||
const outputCapability = issueFileCapability(event, 'config-export', dialogResult.filePath, 'output-file', ['json']);
|
const outputCapability = issueFileCapability(event, 'config-export', dialogResult.filePath, 'output-file', ['json']);
|
||||||
const outputFile = resolveFileCapability(event, outputCapability.token, 'config-export', true);
|
const outputFile = resolveFileCapability(event, outputCapability.token, 'config-export', true);
|
||||||
if (!outputFile) return { success: false, error: 'File access denied' };
|
if (!outputFile) return { success: false, error: 'File access denied' };
|
||||||
const exportable = {
|
const exportable = createExportableConfig(config as unknown as Record<string, unknown>);
|
||||||
...config,
|
|
||||||
client_secret: '',
|
|
||||||
__exportVersion: 1,
|
|
||||||
__exportedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
writeFileAtomicSync(outputFile, JSON.stringify(exportable, null, 2));
|
writeFileAtomicSync(outputFile, JSON.stringify(exportable, null, 2));
|
||||||
return { success: true, filePath: outputFile };
|
return { success: true, filePath: outputFile };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -8222,13 +8247,12 @@ ipcMain.handle('import-config', async (event) => {
|
|||||||
// Merge over current config so unknown / missing keys keep their
|
// Merge over current config so unknown / missing keys keep their
|
||||||
// existing values. Then run normalizeConfigTemplates so any
|
// existing values. Then run normalizeConfigTemplates so any
|
||||||
// out-of-range field falls back to defaults.
|
// out-of-range field falls back to defaults.
|
||||||
const merged = normalizeConfigTemplates({ ...config, ...parsed } as Config);
|
const imported = { ...parsed } as Record<string, unknown>;
|
||||||
|
delete imported.client_secret;
|
||||||
// Preserve the existing client_secret if the import stripped it
|
delete imported.discord_webhook_url;
|
||||||
// (export does this on purpose) — the user shouldn't lose creds.
|
delete imported.__exportVersion;
|
||||||
if (!merged.client_secret && config.client_secret) {
|
delete imported.__exportedAt;
|
||||||
merged.client_secret = config.client_secret;
|
const merged = normalizeConfigTemplates({ ...config, ...imported } as Config);
|
||||||
}
|
|
||||||
|
|
||||||
config = merged;
|
config = merged;
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
@@ -8439,20 +8463,40 @@ app.whenReady().then(() => {
|
|||||||
startMetadataCacheCleanup();
|
startMetadataCacheCleanup();
|
||||||
startDebugLogFlushTimer();
|
startDebugLogFlushTimer();
|
||||||
|
|
||||||
// SQLite-Open + Shadow-Migration. Long-lived handle in appDb (siehe oben).
|
|
||||||
// Lazy require, damit Native-Build-Fehler den App-Start nicht verhindern.
|
|
||||||
try {
|
try {
|
||||||
const { openDatabase } = require('./main/infra/db');
|
const { openDatabase } = require('./main/infra/db');
|
||||||
const { migrateJsonToSqlite } = require('./main/domain/migrator');
|
const { migrateJsonToSqlite } = require('./main/domain/migrator');
|
||||||
const dbPath = path.join(APPDATA_DIR, 'app.db');
|
const dbPath = path.join(APPDATA_DIR, 'app.db');
|
||||||
appDb = openDatabase(dbPath);
|
const database: DbHandle = openDatabase(dbPath);
|
||||||
const result = migrateJsonToSqlite({ db: appDb, appDataDir: APPDATA_DIR });
|
appDb = database;
|
||||||
|
const secureStorage = createElectronSecureStorage();
|
||||||
|
appSecretStore = createSecretStore(database, secureStorage);
|
||||||
|
const result = migrateJsonToSqlite({
|
||||||
|
db: database,
|
||||||
|
appDataDir: APPDATA_DIR,
|
||||||
|
secrets: appSecretStore,
|
||||||
|
requireEncryption: true,
|
||||||
|
});
|
||||||
appendDebugLog('sqlite-migrator', result);
|
appendDebugLog('sqlite-migrator', result);
|
||||||
|
if (result.errors.length > 0) throw new Error(result.errors.map((entry: { source: string; message: string }) => `${entry.source}: ${entry.message}`).join('; '));
|
||||||
|
appStateStore = createAppStateStore(database);
|
||||||
|
config = loadConfig();
|
||||||
|
downloadQueue = config.persist_queue_on_restart === false ? [] : loadQueue();
|
||||||
|
if (config.persist_queue_on_restart === false) appStateStore.saveQueue([]);
|
||||||
|
twitchClientSecret = appSecretStore.get('twitch_client_secret') ?? '';
|
||||||
|
discordWebhookUrl = appSecretStore.get('discord_webhook_url') ?? '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appendDebugLog('sqlite-open-failed', {
|
appendDebugLog('sqlite-open-failed', {
|
||||||
error: e instanceof Error ? e.message : String(e),
|
error: e instanceof Error ? e.message : String(e),
|
||||||
});
|
});
|
||||||
|
try { appDb?.close(); } catch { }
|
||||||
appDb = null;
|
appDb = null;
|
||||||
|
appStateStore = null;
|
||||||
|
appSecretStore = null;
|
||||||
|
config = normalizeConfigTemplates(defaultConfig);
|
||||||
|
downloadQueue = [];
|
||||||
|
twitchClientSecret = '';
|
||||||
|
discordWebhookUrl = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
restartAutoRecordPoller();
|
restartAutoRecordPoller();
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { openDatabase, type DbHandle } from '../infra/db';
|
||||||
|
import { createAppStateStore } from './app-state-store';
|
||||||
|
|
||||||
|
let directory: string;
|
||||||
|
let databasePath: string;
|
||||||
|
let db: DbHandle;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'app-state-'));
|
||||||
|
databasePath = path.join(directory, 'app.db');
|
||||||
|
db = openDatabase(databasePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close();
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAppStateStore', () => {
|
||||||
|
it('recovers configuration from SQLite after restart without secret fields', () => {
|
||||||
|
const store = createAppStateStore(db);
|
||||||
|
store.saveConfig({
|
||||||
|
language: 'de',
|
||||||
|
client_id: 'client-id',
|
||||||
|
client_secret: 'plain-client-secret',
|
||||||
|
discord_webhook_url: 'https://discord.com/api/webhooks/plain',
|
||||||
|
downloaded_vod_ids: ['v2', 'v1'],
|
||||||
|
auto_record_streamers: ['Alice'],
|
||||||
|
auto_vod_download_streamers: ['Bob'],
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
db = openDatabase(databasePath);
|
||||||
|
const recovered = createAppStateStore(db).loadConfig();
|
||||||
|
|
||||||
|
expect(recovered).toMatchObject({
|
||||||
|
language: 'de',
|
||||||
|
client_id: 'client-id',
|
||||||
|
downloaded_vod_ids: ['v2', 'v1'],
|
||||||
|
auto_record_streamers: ['alice'],
|
||||||
|
auto_vod_download_streamers: ['bob'],
|
||||||
|
});
|
||||||
|
expect(recovered).not.toHaveProperty('client_secret');
|
||||||
|
expect(recovered).not.toHaveProperty('discord_webhook_url');
|
||||||
|
const persisted = JSON.stringify(db.all('SELECT key, value FROM config_kv'));
|
||||||
|
expect(persisted).not.toContain('plain-client-secret');
|
||||||
|
expect(persisted).not.toContain('/webhooks/plain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists queue snapshots atomically and preserves order across restart', () => {
|
||||||
|
const first = { id: 'q2', status: 'pending', title: 'second', streamer: 'Beta' };
|
||||||
|
const second = { id: 'q1', status: 'completed', title: 'first', streamer: 'Alpha' };
|
||||||
|
createAppStateStore(db).saveQueue([first, second]);
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
db = openDatabase(databasePath);
|
||||||
|
|
||||||
|
expect(createAppStateStore(db).loadQueue()).toEqual([first, second]);
|
||||||
|
expect(db.all<{ id: string; queue_position: number }>('SELECT id, queue_position FROM queue_items ORDER BY queue_position')).toEqual([
|
||||||
|
{ id: 'q2', queue_position: 0 },
|
||||||
|
{ id: 'q1', queue_position: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { DbHandle } from '../infra/db';
|
||||||
|
import { normalizeLogin } from './config-normalize';
|
||||||
|
|
||||||
|
export interface AppStateStore {
|
||||||
|
loadConfig(): Record<string, unknown>;
|
||||||
|
saveConfig<T extends object>(config: T): void;
|
||||||
|
loadQueue<T extends object = Record<string, unknown>>(): T[];
|
||||||
|
saveQueue<T extends object>(queue: T[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECRET_CONFIG_KEYS = new Set(['client_secret', 'discord_webhook_url']);
|
||||||
|
|
||||||
|
function normalizedLogins(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return [...new Set(value
|
||||||
|
.filter((entry): entry is string => typeof entry === 'string')
|
||||||
|
.map(normalizeLogin)
|
||||||
|
.filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArray(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return [...new Set(value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfig(config: object): Record<string, unknown> {
|
||||||
|
const source = config as Record<string, unknown>;
|
||||||
|
const normalized = Object.fromEntries(
|
||||||
|
Object.entries(source).filter(([key]) => !SECRET_CONFIG_KEYS.has(key))
|
||||||
|
);
|
||||||
|
normalized.downloaded_vod_ids = stringArray(source.downloaded_vod_ids);
|
||||||
|
normalized.auto_record_streamers = normalizedLogins(source.auto_record_streamers);
|
||||||
|
normalized.auto_vod_download_streamers = normalizedLogins(source.auto_vod_download_streamers);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppStateStore(db: DbHandle): AppStateStore {
|
||||||
|
return {
|
||||||
|
loadConfig() {
|
||||||
|
return Object.fromEntries(
|
||||||
|
db.all<{ key: string; value: string }>('SELECT key, value FROM config_kv')
|
||||||
|
.map((row) => [row.key, JSON.parse(row.value)])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
saveConfig(config) {
|
||||||
|
const normalized = normalizeConfig(config);
|
||||||
|
db.transaction(() => {
|
||||||
|
db.run('DELETE FROM config_kv');
|
||||||
|
for (const [key, value] of Object.entries(normalized)) {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO config_kv(key, value, updated_at)
|
||||||
|
VALUES (?, ?, strftime('%s','now'))`,
|
||||||
|
[key, JSON.stringify(value)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
db.run('DELETE FROM downloaded_vods');
|
||||||
|
for (const vodId of normalized.downloaded_vod_ids as string[]) {
|
||||||
|
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', [vodId]);
|
||||||
|
}
|
||||||
|
db.run('DELETE FROM streamers');
|
||||||
|
for (const login of normalized.auto_record_streamers as string[]) {
|
||||||
|
db.run('INSERT INTO streamers(login, auto_record) VALUES (?, 1)', [login]);
|
||||||
|
}
|
||||||
|
for (const login of normalized.auto_vod_download_streamers as string[]) {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1)
|
||||||
|
ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1`,
|
||||||
|
[login]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
loadQueue<T extends object>() {
|
||||||
|
return db.all<{ payload_json: string }>(
|
||||||
|
'SELECT payload_json FROM queue_items ORDER BY queue_position, created_at, id'
|
||||||
|
).map((row) => JSON.parse(row.payload_json) as T);
|
||||||
|
},
|
||||||
|
saveQueue<T extends object>(queue: T[]) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
db.transaction(() => {
|
||||||
|
db.run('DELETE FROM queue_items');
|
||||||
|
queue.forEach((rawItem, index) => {
|
||||||
|
const item = rawItem as Record<string, unknown>;
|
||||||
|
const id = typeof item.id === 'string' && item.id ? item.id : null;
|
||||||
|
if (!id) throw new Error('Queue item id must not be empty');
|
||||||
|
db.run(
|
||||||
|
`INSERT OR REPLACE INTO queue_items
|
||||||
|
(id, queue_position, streamer_login, vod_id, clip_id, title, output_path, status,
|
||||||
|
progress_pct, error_message, created_at, updated_at, completed_at, payload_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
typeof item.streamer === 'string' ? normalizeLogin(item.streamer) : null,
|
||||||
|
typeof item.vod_id === 'string' ? item.vod_id : null,
|
||||||
|
typeof item.clip_id === 'string' ? item.clip_id : null,
|
||||||
|
typeof item.title === 'string' ? item.title : null,
|
||||||
|
typeof item.output_path === 'string' ? item.output_path : null,
|
||||||
|
typeof item.status === 'string' ? item.status : 'pending',
|
||||||
|
typeof item.progress_pct === 'number' ? item.progress_pct : null,
|
||||||
|
typeof item.error_message === 'string' ? item.error_message : null,
|
||||||
|
typeof item.created_at === 'number' ? item.created_at : now,
|
||||||
|
typeof item.updated_at === 'number' ? item.updated_at : now,
|
||||||
|
typeof item.completed_at === 'number' ? item.completed_at : null,
|
||||||
|
JSON.stringify(item),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { createExportableConfig } from './config-export';
|
||||||
|
|
||||||
|
describe('createExportableConfig', () => {
|
||||||
|
it('recursively removes every secret-bearing field and value', () => {
|
||||||
|
const exported = createExportableConfig({
|
||||||
|
language: 'de',
|
||||||
|
client_secret: 'client-secret-value',
|
||||||
|
discord_webhook_url: 'https://discord.com/api/webhooks/value',
|
||||||
|
access_token: 'access-token-value',
|
||||||
|
nested: {
|
||||||
|
refresh_token: 'refresh-token-value',
|
||||||
|
password: 'password-value',
|
||||||
|
cookie: 'cookie-value',
|
||||||
|
},
|
||||||
|
}, new Date('2026-08-11T20:00:00.000Z'));
|
||||||
|
const serialized = JSON.stringify(exported);
|
||||||
|
|
||||||
|
expect(exported).toMatchObject({ language: 'de', __exportVersion: 2, __exportedAt: '2026-08-11T20:00:00.000Z' });
|
||||||
|
for (const forbidden of ['client_secret', 'discord_webhook_url', 'access_token', 'refresh_token', 'password', 'cookie', 'client-secret-value', '/webhooks/value']) {
|
||||||
|
expect(serialized).not.toContain(forbidden);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const SECRET_KEYS = /(^|_)(authorization|cookie|password|secret|token)($|_)/i;
|
||||||
|
|
||||||
|
function redact(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map(redact);
|
||||||
|
if (!value || typeof value !== 'object') return value;
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
|
if (SECRET_KEYS.test(key) || key.toLowerCase() === 'discord_webhook_url') continue;
|
||||||
|
result[key] = redact(entry);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExportableConfig(config: Record<string, unknown>, exportedAt = new Date()): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
...(redact(config) as Record<string, unknown>),
|
||||||
|
__exportVersion: 2,
|
||||||
|
__exportedAt: exportedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import * as os from 'os';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { openDatabase, type DbHandle } from '../infra/db';
|
import { openDatabase, type DbHandle } from '../infra/db';
|
||||||
import { migrateJsonToSqlite } from './migrator';
|
import { migrateJsonToSqlite } from './migrator';
|
||||||
|
import { MemorySecureStorage } from '../infra/secure-storage';
|
||||||
|
import { createSecretStore } from './secret-store';
|
||||||
|
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let appDataDir: string;
|
let appDataDir: string;
|
||||||
@@ -33,8 +35,8 @@ describe('migrateJsonToSqlite', () => {
|
|||||||
expect(result.downloadedVodsCount).toBe(0);
|
expect(result.downloadedVodsCount).toBe(0);
|
||||||
expect(result.streamersCount).toBe(0);
|
expect(result.streamersCount).toBe(0);
|
||||||
|
|
||||||
const marker = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['v4-to-v5-jsons']);
|
const marker = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1']);
|
||||||
expect(marker?.name).toBe('v4-to-v5-jsons');
|
expect(marker?.name).toBe('authoritative-state-v1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('migrates config.json keys into config_kv', () => {
|
test('migrates config.json keys into config_kv', () => {
|
||||||
@@ -118,5 +120,121 @@ describe('migrateJsonToSqlite', () => {
|
|||||||
expect(result.configMigrated).toBe(false);
|
expect(result.configMigrated).toBe(false);
|
||||||
expect(result.errors.length).toBeGreaterThan(0);
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
expect(result.errors[0].source).toBe('config.json');
|
expect(result.errors[0].source).toBe('config.json');
|
||||||
|
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rolls back all records and marker when migration is interrupted', () => {
|
||||||
|
writeJson('config.json', { language: 'de', metadata_cache_minutes: 30 });
|
||||||
|
writeJson('download_queue.json', [{ id: 'q1', status: 'pending', title: 'Queue item' }]);
|
||||||
|
let queueInsertReached = false;
|
||||||
|
const interruptedDb: DbHandle = {
|
||||||
|
...db,
|
||||||
|
run(sql, params) {
|
||||||
|
if (sql.includes('INSERT OR REPLACE INTO queue_items')) {
|
||||||
|
queueInsertReached = true;
|
||||||
|
throw new Error('simulated interruption');
|
||||||
|
}
|
||||||
|
db.run(sql, params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = migrateJsonToSqlite({ db: interruptedDb, appDataDir });
|
||||||
|
|
||||||
|
expect(queueInsertReached).toBe(true);
|
||||||
|
expect(result.errors).toEqual([{ source: 'migration', message: 'simulated interruption' }]);
|
||||||
|
expect(db.all('SELECT * FROM config_kv')).toEqual([]);
|
||||||
|
expect(db.all('SELECT * FROM queue_items')).toEqual([]);
|
||||||
|
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rolls back config queue and secrets when the migration marker cannot be written', () => {
|
||||||
|
const configPath = writeJson('config.json', { language: 'de', client_secret: 'must-survive' });
|
||||||
|
writeJson('download_queue.json', [{ id: 'q1', status: 'pending' }]);
|
||||||
|
const interruptedDb: DbHandle = {
|
||||||
|
...db,
|
||||||
|
run(sql, params) {
|
||||||
|
if (sql.includes('INSERT INTO migrations_applied')) throw new Error('marker unavailable');
|
||||||
|
db.run(sql, params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const secrets = createSecretStore(interruptedDb, new MemorySecureStorage());
|
||||||
|
|
||||||
|
const result = migrateJsonToSqlite({ db: interruptedDb, appDataDir, secrets });
|
||||||
|
|
||||||
|
expect(result.errors).toEqual([{ source: 'migration', message: 'marker unavailable' }]);
|
||||||
|
expect(db.all('SELECT * FROM config_kv')).toEqual([]);
|
||||||
|
expect(db.all('SELECT * FROM queue_items')).toEqual([]);
|
||||||
|
expect(db.all('SELECT * FROM app_secrets')).toEqual([]);
|
||||||
|
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
|
||||||
|
expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('imports legacy JSON once and ignores later JSON changes after restart', () => {
|
||||||
|
const configPath = writeJson('config.json', { language: 'de' });
|
||||||
|
migrateJsonToSqlite({ db, appDataDir });
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({ language: 'en' }), 'utf-8');
|
||||||
|
|
||||||
|
const second = migrateJsonToSqlite({ db, appDataDir });
|
||||||
|
const language = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language']);
|
||||||
|
|
||||||
|
expect(second.alreadyApplied).toBe(true);
|
||||||
|
expect(JSON.parse(language!.value)).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not let the former shadow-migration marker skip authoritative secret import', () => {
|
||||||
|
const configPath = writeJson('config.json', { language: 'de', client_secret: 'legacy-secret' });
|
||||||
|
db.run('INSERT INTO migrations_applied(name, payload) VALUES (?, ?)', ['v4-to-v5-jsons', '{}']);
|
||||||
|
const secrets = createSecretStore(db, new MemorySecureStorage());
|
||||||
|
|
||||||
|
const result = migrateJsonToSqlite({ db, appDataDir, secrets });
|
||||||
|
|
||||||
|
expect(result.alreadyApplied).toBe(false);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
expect(secrets.get('twitch_client_secret')).toBe('legacy-secret');
|
||||||
|
expect(fs.readFileSync(configPath, 'utf-8')).not.toContain('legacy-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migrates plaintext secrets into encrypted versioned records and scrubs JSON', () => {
|
||||||
|
const configPath = writeJson('config.json', {
|
||||||
|
client_secret: 'legacy-client-secret',
|
||||||
|
discord_webhook_url: 'https://discord.com/api/webhooks/legacy',
|
||||||
|
language: 'de',
|
||||||
|
});
|
||||||
|
const secrets = createSecretStore(db, new MemorySecureStorage());
|
||||||
|
|
||||||
|
const result = migrateJsonToSqlite({ db, appDataDir, secrets });
|
||||||
|
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
expect(secrets.get('twitch_client_secret')).toBe('legacy-client-secret');
|
||||||
|
expect(secrets.get('discord_webhook_url')).toBe('https://discord.com/api/webhooks/legacy');
|
||||||
|
expect(fs.readFileSync(configPath, 'utf-8')).not.toContain('legacy-client-secret');
|
||||||
|
expect(fs.readFileSync(configPath + '.v4-backup', 'utf-8')).not.toContain('/webhooks/legacy');
|
||||||
|
expect(db.get('SELECT key FROM config_kv WHERE key = ?', ['discord_webhook_url'])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps plaintext legacy secrets untouched when production encryption is unavailable', () => {
|
||||||
|
const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' });
|
||||||
|
const secrets = createSecretStore(db, new MemorySecureStorage());
|
||||||
|
|
||||||
|
const result = migrateJsonToSqlite({ db, appDataDir, secrets, requireEncryption: true });
|
||||||
|
|
||||||
|
expect(result.errors).toEqual([{ source: 'migration', message: 'OS secret encryption is unavailable' }]);
|
||||||
|
expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive');
|
||||||
|
expect(secrets.get('twitch_client_secret')).toBeNull();
|
||||||
|
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps plaintext legacy secrets when the sanitized backup cannot be published', () => {
|
||||||
|
const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' });
|
||||||
|
fs.mkdirSync(`${configPath}.v4-backup`);
|
||||||
|
const secrets = createSecretStore(db, new MemorySecureStorage());
|
||||||
|
|
||||||
|
const result = migrateJsonToSqlite({ db, appDataDir, secrets });
|
||||||
|
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive');
|
||||||
|
expect(db.all('SELECT * FROM config_kv')).toEqual([]);
|
||||||
|
expect(db.all('SELECT * FROM app_secrets')).toEqual([]);
|
||||||
|
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+120
-162
@@ -1,11 +1,14 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { DbHandle } from '../infra/db';
|
import type { DbHandle } from '../infra/db';
|
||||||
import { normalizeLogin } from './config-normalize';
|
import { createAppStateStore } from './app-state-store';
|
||||||
|
import type { SecretStore } from './secret-store';
|
||||||
|
|
||||||
export interface MigratorOptions {
|
export interface MigratorOptions {
|
||||||
db: DbHandle;
|
db: DbHandle;
|
||||||
appDataDir: string;
|
appDataDir: string;
|
||||||
|
secrets?: SecretStore;
|
||||||
|
requireEncryption?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MigrationError {
|
export interface MigrationError {
|
||||||
@@ -22,180 +25,135 @@ export interface MigrationResult {
|
|||||||
errors: MigrationError[];
|
errors: MigrationError[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIGRATION_NAME = 'v4-to-v5-jsons';
|
const MIGRATION_NAME = 'authoritative-state-v1';
|
||||||
|
const SECRET_KEYS = new Set(['client_secret', 'discord_webhook_url']);
|
||||||
|
|
||||||
const CONFIG_KV_KEYS = [
|
function readJson<T>(filePath: string, source: string, errors: MigrationError[]): T | undefined {
|
||||||
'language', 'performance_mode', 'metadata_cache_minutes', 'streamlink_quality',
|
if (!fs.existsSync(filePath)) return undefined;
|
||||||
'streamlink_disable_ads', 'download_chat_replay', 'capture_live_chat',
|
|
||||||
'discord_webhook_url', 'discord_notify_live_start', 'discord_notify_live_end',
|
|
||||||
'discord_notify_vod_complete', 'discord_notify_vod_auto_queued',
|
|
||||||
'auto_cleanup_enabled', 'auto_cleanup_days', 'auto_cleanup_target',
|
|
||||||
'auto_cleanup_action', 'log_stream_events', 'auto_vod_download_poll_minutes',
|
|
||||||
'auto_vod_max_age_hours', 'auto_resume_live_recording',
|
|
||||||
'auto_merge_resumed_parts', 'delete_parts_after_merge',
|
|
||||||
'auto_record_poll_seconds', 'filename_template_vod', 'filename_template_parts',
|
|
||||||
'filename_template_clip', 'smart_queue_scheduler', 'prevent_duplicate_downloads',
|
|
||||||
'persist_queue_on_restart', 'auto_resume_queue_on_startup',
|
|
||||||
'notify_on_each_completion', 'sidebar_split_view',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function backupOnce(srcPath: string): void {
|
|
||||||
const backupPath = srcPath + '.v4-backup';
|
|
||||||
if (!fs.existsSync(backupPath)) {
|
|
||||||
fs.copyFileSync(srcPath, backupPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateConfig(db: DbHandle, configPath: string, errors: MigrationError[]): { ok: boolean; vodCount: number } {
|
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T;
|
||||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
} catch (error) {
|
||||||
|
errors.push({ source, message: error instanceof Error ? error.message : String(error) });
|
||||||
let vodCount = 0;
|
return undefined;
|
||||||
db.transaction(() => {
|
|
||||||
for (const key of CONFIG_KV_KEYS) {
|
|
||||||
if (key in config) {
|
|
||||||
db.run(
|
|
||||||
"INSERT OR REPLACE INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))",
|
|
||||||
[key, JSON.stringify(config[key])]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const vodIds = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : [];
|
|
||||||
for (const id of vodIds) {
|
|
||||||
if (typeof id !== 'string' || !id) continue;
|
|
||||||
db.run('INSERT OR IGNORE INTO downloaded_vods(vod_id) VALUES (?)', [id]);
|
|
||||||
vodCount += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoRec = Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers : [];
|
|
||||||
for (const s of autoRec) {
|
|
||||||
if (typeof s !== 'string' || !s) continue;
|
|
||||||
const login = normalizeLogin(s);
|
|
||||||
if (!login) continue;
|
|
||||||
db.run(
|
|
||||||
'INSERT INTO streamers(login, auto_record) VALUES (?, 1) ON CONFLICT(login) DO UPDATE SET auto_record = 1',
|
|
||||||
[login]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoDl = Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers : [];
|
|
||||||
for (const s of autoDl) {
|
|
||||||
if (typeof s !== 'string' || !s) continue;
|
|
||||||
const login = normalizeLogin(s);
|
|
||||||
if (!login) continue;
|
|
||||||
db.run(
|
|
||||||
'INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1) ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1',
|
|
||||||
[login]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
backupOnce(configPath);
|
|
||||||
return { ok: true, vodCount };
|
|
||||||
} catch (e) {
|
|
||||||
errors.push({ source: 'config.json', message: e instanceof Error ? e.message : String(e) });
|
|
||||||
return { ok: false, vodCount: 0 };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrateQueue(db: DbHandle, queuePath: string, errors: MigrationError[]): boolean {
|
function withoutSecrets(config: Record<string, unknown>): Record<string, unknown> {
|
||||||
try {
|
return Object.fromEntries(Object.entries(config).filter(([key]) => !SECRET_KEYS.has(key)));
|
||||||
const raw = fs.readFileSync(queuePath, 'utf-8');
|
|
||||||
const queue = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(queue)) return false;
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
db.transaction(() => {
|
|
||||||
for (const rawItem of queue) {
|
|
||||||
if (!rawItem || typeof rawItem !== 'object') continue;
|
|
||||||
const item = rawItem as Record<string, unknown>;
|
|
||||||
const id = typeof item.id === 'string' ? item.id : null;
|
|
||||||
if (!id) continue;
|
|
||||||
db.run(
|
|
||||||
`INSERT OR REPLACE INTO queue_items
|
|
||||||
(id, streamer_login, vod_id, clip_id, title, output_path, status,
|
|
||||||
progress_pct, error_message, created_at, updated_at, completed_at, payload_json)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[
|
|
||||||
id,
|
|
||||||
typeof item.streamer === 'string' ? normalizeLogin(item.streamer) : null,
|
|
||||||
typeof item.vod_id === 'string' ? item.vod_id : null,
|
|
||||||
typeof item.clip_id === 'string' ? item.clip_id : null,
|
|
||||||
typeof item.title === 'string' ? item.title : null,
|
|
||||||
typeof item.output_path === 'string' ? item.output_path : null,
|
|
||||||
typeof item.status === 'string' ? item.status : 'pending',
|
|
||||||
typeof item.progress_pct === 'number' ? item.progress_pct : null,
|
|
||||||
typeof item.error_message === 'string' ? item.error_message : null,
|
|
||||||
typeof item.created_at === 'number' ? item.created_at : now,
|
|
||||||
typeof item.updated_at === 'number' ? item.updated_at : now,
|
|
||||||
typeof item.completed_at === 'number' ? item.completed_at : null,
|
|
||||||
JSON.stringify(item),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
backupOnce(queuePath);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
errors.push({ source: 'download_queue.json', message: e instanceof Error ? e.message : String(e) });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
|
function writeJsonAtomic(filePath: string, value: unknown): void {
|
||||||
const { db, appDataDir } = opts;
|
const temporaryPath = `${filePath}.${process.pid}.tmp`;
|
||||||
const errors: MigrationError[] = [];
|
fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2), 'utf-8');
|
||||||
|
fs.renameSync(temporaryPath, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
const existing = db.get<{ name: string }>(
|
function backupJson(filePath: string, value: unknown): void {
|
||||||
'SELECT name FROM migrations_applied WHERE name = ?',
|
const backupPath = `${filePath}.v4-backup`;
|
||||||
[MIGRATION_NAME]
|
if (!fs.existsSync(backupPath)) writeJsonAtomic(backupPath, value);
|
||||||
);
|
}
|
||||||
if (existing) {
|
|
||||||
|
function scrubConfigFiles(configPath: string, config: Record<string, unknown>): void {
|
||||||
|
const sanitized = withoutSecrets(config);
|
||||||
|
writeJsonAtomic(`${configPath}.v4-backup`, sanitized);
|
||||||
|
writeJsonAtomic(configPath, sanitized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrubExistingConfig(configPath: string): void {
|
||||||
|
if (!fs.existsSync(configPath)) return;
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
||||||
|
scrubConfigFiles(configPath, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyResult(alreadyApplied: boolean, errors: MigrationError[] = []): MigrationResult {
|
||||||
return {
|
return {
|
||||||
alreadyApplied: true,
|
alreadyApplied,
|
||||||
configMigrated: false,
|
configMigrated: false,
|
||||||
queueMigrated: false,
|
queueMigrated: false,
|
||||||
downloadedVodsCount: 0,
|
downloadedVodsCount: 0,
|
||||||
streamersCount: 0,
|
streamersCount: 0,
|
||||||
errors: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let configMigrated = false;
|
|
||||||
let queueMigrated = false;
|
|
||||||
let downloadedVodsCount = 0;
|
|
||||||
|
|
||||||
const configPath = path.join(appDataDir, 'config.json');
|
|
||||||
if (fs.existsSync(configPath)) {
|
|
||||||
const r = migrateConfig(db, configPath, errors);
|
|
||||||
configMigrated = r.ok;
|
|
||||||
downloadedVodsCount = r.vodCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queuePath = path.join(appDataDir, 'download_queue.json');
|
|
||||||
if (fs.existsSync(queuePath)) {
|
|
||||||
queueMigrated = migrateQueue(db, queuePath, errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamersCount = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM streamers')?.c ?? 0;
|
|
||||||
|
|
||||||
db.run(
|
|
||||||
'INSERT INTO migrations_applied(name, payload) VALUES (?, ?)',
|
|
||||||
[
|
|
||||||
MIGRATION_NAME,
|
|
||||||
JSON.stringify({ configMigrated, queueMigrated, downloadedVodsCount, streamersCount, errorCount: errors.length }),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
alreadyApplied: false,
|
|
||||||
configMigrated,
|
|
||||||
queueMigrated,
|
|
||||||
downloadedVodsCount,
|
|
||||||
streamersCount,
|
|
||||||
errors,
|
errors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
|
||||||
|
const { db, appDataDir, secrets, requireEncryption = false } = opts;
|
||||||
|
const configPath = path.join(appDataDir, 'config.json');
|
||||||
|
const queuePath = path.join(appDataDir, 'download_queue.json');
|
||||||
|
const existing = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', [MIGRATION_NAME]);
|
||||||
|
if (existing) {
|
||||||
|
try {
|
||||||
|
scrubExistingConfig(configPath);
|
||||||
|
} catch (error) {
|
||||||
|
return emptyResult(true, [{ source: 'config.json', message: error instanceof Error ? error.message : String(error) }]);
|
||||||
|
}
|
||||||
|
return emptyResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: MigrationError[] = [];
|
||||||
|
const configExists = fs.existsSync(configPath);
|
||||||
|
const queueExists = fs.existsSync(queuePath);
|
||||||
|
const config = readJson<Record<string, unknown>>(configPath, 'config.json', errors);
|
||||||
|
const queue = readJson<unknown>(queuePath, 'download_queue.json', errors);
|
||||||
|
if (queueExists && !Array.isArray(queue)) {
|
||||||
|
errors.push({ source: 'download_queue.json', message: 'Queue JSON must be an array' });
|
||||||
|
}
|
||||||
|
if (errors.length > 0) return emptyResult(false, errors);
|
||||||
|
if (config && (typeof config !== 'object' || Array.isArray(config))) {
|
||||||
|
return emptyResult(false, [{ source: 'config.json', message: 'Config JSON must be an object' }]);
|
||||||
|
}
|
||||||
|
if (config && !secrets && [...SECRET_KEYS].some((key) => typeof config[key] === 'string' && config[key])) {
|
||||||
|
return emptyResult(false, [{ source: 'migration', message: 'Secure secret storage is required for plaintext secret migration' }]);
|
||||||
|
}
|
||||||
|
if (config && requireEncryption && [...SECRET_KEYS].some((key) => typeof config[key] === 'string' && config[key]) && !secrets?.status().encryptionAvailable) {
|
||||||
|
return emptyResult(false, [{ source: 'migration', message: 'OS secret encryption is unavailable' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = createAppStateStore(db);
|
||||||
|
let downloadedVodsCount = 0;
|
||||||
|
let streamersCount = 0;
|
||||||
|
let configScrubbed = false;
|
||||||
|
try {
|
||||||
|
db.transaction(() => {
|
||||||
|
if (configExists && config) {
|
||||||
|
state.saveConfig(config);
|
||||||
|
downloadedVodsCount = Array.isArray(config.downloaded_vod_ids)
|
||||||
|
? config.downloaded_vod_ids.filter((value) => typeof value === 'string' && value).length
|
||||||
|
: 0;
|
||||||
|
if (typeof config.client_secret === 'string' && config.client_secret) {
|
||||||
|
secrets!.set('twitch_client_secret', config.client_secret);
|
||||||
|
}
|
||||||
|
if (typeof config.discord_webhook_url === 'string' && config.discord_webhook_url) {
|
||||||
|
secrets!.set('discord_webhook_url', config.discord_webhook_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queueExists) state.saveQueue(queue as Array<Record<string, unknown>>);
|
||||||
|
streamersCount = db.get<{ count: number }>('SELECT COUNT(*) AS count FROM streamers')?.count ?? 0;
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO migrations_applied(name, payload) VALUES (?, ?)',
|
||||||
|
[MIGRATION_NAME, JSON.stringify({ configMigrated: configExists, queueMigrated: queueExists, downloadedVodsCount, streamersCount })]
|
||||||
|
);
|
||||||
|
if (queueExists) backupJson(queuePath, queue);
|
||||||
|
if (configExists && config) {
|
||||||
|
scrubConfigFiles(configPath, config);
|
||||||
|
configScrubbed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (configScrubbed && config) {
|
||||||
|
try {
|
||||||
|
writeJsonAtomic(configPath, config);
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
return emptyResult(false, [{ source: 'migration', message: error instanceof Error ? error.message : String(error) }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
alreadyApplied: false,
|
||||||
|
configMigrated: configExists,
|
||||||
|
queueMigrated: queueExists,
|
||||||
|
downloadedVodsCount,
|
||||||
|
streamersCount,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveSecretInputUpdate } from './secret-input';
|
||||||
|
|
||||||
|
describe('resolveSecretInputUpdate', () => {
|
||||||
|
it('keeps a configured secret when the masked value is unchanged', () => {
|
||||||
|
expect(resolveSecretInputUpdate('••••••••', true)).toEqual({ action: 'unchanged' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a configured secret when the field is emptied', () => {
|
||||||
|
expect(resolveSecretInputUpdate(' ', true)).toEqual({ action: 'clear' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets a trimmed replacement without exposing the stored value', () => {
|
||||||
|
expect(resolveSecretInputUpdate(' replacement ', true)).toEqual({ action: 'set', value: 'replacement' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an empty field when no secret is configured', () => {
|
||||||
|
expect(resolveSecretInputUpdate('', false)).toEqual({ action: 'unchanged' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export type SecretInputUpdate =
|
||||||
|
| { action: 'unchanged' }
|
||||||
|
| { action: 'clear' }
|
||||||
|
| { action: 'set'; value: string };
|
||||||
|
|
||||||
|
export const SECRET_INPUT_MASK = '••••••••';
|
||||||
|
|
||||||
|
export function resolveSecretInputUpdate(value: string, configured: boolean): SecretInputUpdate {
|
||||||
|
if (configured && value === SECRET_INPUT_MASK) return { action: 'unchanged' };
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (normalized) return { action: 'set', value: normalized };
|
||||||
|
return configured ? { action: 'clear' } : { action: 'unchanged' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { openDatabase, type DbHandle } from '../infra/db';
|
||||||
|
import { MemorySecureStorage } from '../infra/secure-storage';
|
||||||
|
import { createSecretStore } from './secret-store';
|
||||||
|
|
||||||
|
let directory: string;
|
||||||
|
let db: DbHandle;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'app-secrets-'));
|
||||||
|
db = openDatabase(path.join(directory, 'app.db'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close();
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createSecretStore', () => {
|
||||||
|
it('stores versioned ciphertext and exposes only configured status', () => {
|
||||||
|
const store = createSecretStore(db, new MemorySecureStorage());
|
||||||
|
store.set('twitch_client_secret', 'client-secret-value');
|
||||||
|
store.set('discord_webhook_url', 'https://discord.com/api/webhooks/value');
|
||||||
|
|
||||||
|
expect(store.get('twitch_client_secret')).toBe('client-secret-value');
|
||||||
|
expect(store.status()).toEqual({
|
||||||
|
encryptionAvailable: false,
|
||||||
|
clientSecretConfigured: true,
|
||||||
|
discordWebhookConfigured: true,
|
||||||
|
});
|
||||||
|
const rows = db.all<{ key: string; version: number; encrypted_value: string }>('SELECT key, version, encrypted_value FROM app_secrets ORDER BY key');
|
||||||
|
expect(rows.map((row) => row.version)).toEqual([1, 1]);
|
||||||
|
expect(JSON.stringify(rows)).not.toContain('client-secret-value');
|
||||||
|
expect(JSON.stringify(rows)).not.toContain('/webhooks/value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears one secret without changing the other', () => {
|
||||||
|
const store = createSecretStore(db, new MemorySecureStorage());
|
||||||
|
store.set('twitch_client_secret', 'client');
|
||||||
|
store.set('discord_webhook_url', 'webhook');
|
||||||
|
store.clear('twitch_client_secret');
|
||||||
|
|
||||||
|
expect(store.get('twitch_client_secret')).toBeNull();
|
||||||
|
expect(store.get('discord_webhook_url')).toBe('webhook');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { DbHandle } from '../infra/db';
|
||||||
|
import type { SecureStorage } from '../infra/secure-storage';
|
||||||
|
|
||||||
|
export type AppSecretKey = 'twitch_client_secret' | 'discord_webhook_url';
|
||||||
|
|
||||||
|
export interface SecretStatus {
|
||||||
|
encryptionAvailable: boolean;
|
||||||
|
clientSecretConfigured: boolean;
|
||||||
|
discordWebhookConfigured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretStore {
|
||||||
|
get(key: AppSecretKey): string | null;
|
||||||
|
set(key: AppSecretKey, value: string): void;
|
||||||
|
clear(key: AppSecretKey): void;
|
||||||
|
status(): SecretStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECRET_VERSION = 1;
|
||||||
|
|
||||||
|
export function createSecretStore(db: DbHandle, storage: SecureStorage): SecretStore {
|
||||||
|
return {
|
||||||
|
get(key) {
|
||||||
|
const row = db.get<{ version: number; encrypted_value: string }>(
|
||||||
|
'SELECT version, encrypted_value FROM app_secrets WHERE key = ?',
|
||||||
|
[key]
|
||||||
|
);
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.version !== SECRET_VERSION) {
|
||||||
|
throw new Error(`Unsupported secret record version: ${row.version}`);
|
||||||
|
}
|
||||||
|
return storage.decrypt(row.encrypted_value);
|
||||||
|
},
|
||||||
|
set(key, value) {
|
||||||
|
if (!value) throw new Error('Secret value must not be empty');
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO app_secrets(key, version, encrypted_value, updated_at)
|
||||||
|
VALUES (?, ?, ?, strftime('%s','now'))
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
version = excluded.version,
|
||||||
|
encrypted_value = excluded.encrypted_value,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
[key, SECRET_VERSION, storage.encrypt(value)]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
clear(key) {
|
||||||
|
db.run('DELETE FROM app_secrets WHERE key = ?', [key]);
|
||||||
|
},
|
||||||
|
status() {
|
||||||
|
const keys = new Set(db.all<{ key: AppSecretKey }>('SELECT key FROM app_secrets').map((row) => row.key));
|
||||||
|
return {
|
||||||
|
encryptionAvailable: storage.isEncryptionAvailable(),
|
||||||
|
clientSecretConfigured: keys.has('twitch_client_secret'),
|
||||||
|
discordWebhookConfigured: keys.has('discord_webhook_url'),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -34,6 +34,10 @@ export function openDatabase(filePath: string): DbHandle {
|
|||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
runMultiStatement(db, SCHEMA_V5_SQL);
|
runMultiStatement(db, SCHEMA_V5_SQL);
|
||||||
|
const queueColumns = db.prepare('PRAGMA table_info(queue_items)').all() as Array<{ name: string }>;
|
||||||
|
if (!queueColumns.some((column) => column.name === 'queue_position')) {
|
||||||
|
db.prepare('ALTER TABLE queue_items ADD COLUMN queue_position INTEGER NOT NULL DEFAULT 0').run();
|
||||||
|
}
|
||||||
|
|
||||||
const handle: DbHandle = {
|
const handle: DbHandle = {
|
||||||
run(sql, params) {
|
run(sql, params) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS config_kv (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS queue_items (
|
CREATE TABLE IF NOT EXISTS queue_items (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
queue_position INTEGER NOT NULL DEFAULT 0,
|
||||||
streamer_login TEXT,
|
streamer_login TEXT,
|
||||||
vod_id TEXT,
|
vod_id TEXT,
|
||||||
clip_id TEXT,
|
clip_id TEXT,
|
||||||
@@ -38,6 +39,13 @@ CREATE INDEX IF NOT EXISTS idx_queue_status ON queue_items(status);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_queue_streamer ON queue_items(streamer_login);
|
CREATE INDEX IF NOT EXISTS idx_queue_streamer ON queue_items(streamer_login);
|
||||||
CREATE INDEX IF NOT EXISTS idx_queue_created ON queue_items(created_at);
|
CREATE INDEX IF NOT EXISTS idx_queue_created ON queue_items(created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_secrets (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
encrypted_value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS downloaded_vods (
|
CREATE TABLE IF NOT EXISTS downloaded_vods (
|
||||||
vod_id TEXT PRIMARY KEY,
|
vod_id TEXT PRIMARY KEY,
|
||||||
downloaded_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
downloaded_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
// Config
|
// Config
|
||||||
getConfig: () => ipcRenderer.invoke('get-config'),
|
getConfig: () => ipcRenderer.invoke('get-config'),
|
||||||
saveConfig: (config: any, fileCapability?: string) => ipcRenderer.invoke('save-config', config, fileCapability),
|
saveConfig: (config: any, fileCapability?: string) => ipcRenderer.invoke('save-config', config, fileCapability),
|
||||||
|
getSecretStatus: () => ipcRenderer.invoke('get-secret-status'),
|
||||||
|
setClientSecret: (value: string) => ipcRenderer.invoke('set-client-secret', value),
|
||||||
|
clearClientSecret: () => ipcRenderer.invoke('clear-client-secret'),
|
||||||
|
setDiscordWebhook: (value: string) => ipcRenderer.invoke('set-discord-webhook', value),
|
||||||
|
clearDiscordWebhook: () => ipcRenderer.invoke('clear-discord-webhook'),
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
login: () => ipcRenderer.invoke('login'),
|
login: () => ipcRenderer.invoke('login'),
|
||||||
|
|||||||
Vendored
+11
-2
@@ -1,6 +1,5 @@
|
|||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
client_id?: string;
|
client_id?: string;
|
||||||
client_secret?: string;
|
|
||||||
download_path?: string;
|
download_path?: string;
|
||||||
streamers?: string[];
|
streamers?: string[];
|
||||||
streamer_display_names?: Record<string, string>;
|
streamer_display_names?: Record<string, string>;
|
||||||
@@ -27,7 +26,6 @@ interface AppConfig {
|
|||||||
auto_record_poll_seconds?: number;
|
auto_record_poll_seconds?: number;
|
||||||
download_chat_replay?: boolean;
|
download_chat_replay?: boolean;
|
||||||
capture_live_chat?: boolean;
|
capture_live_chat?: boolean;
|
||||||
discord_webhook_url?: string;
|
|
||||||
discord_notify_live_start?: boolean;
|
discord_notify_live_start?: boolean;
|
||||||
discord_notify_live_end?: boolean;
|
discord_notify_live_end?: boolean;
|
||||||
discord_notify_vod_complete?: boolean;
|
discord_notify_vod_complete?: boolean;
|
||||||
@@ -172,6 +170,12 @@ interface VideoInfo {
|
|||||||
variableFrameRate: boolean;
|
variableFrameRate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SecretStatus {
|
||||||
|
encryptionAvailable: boolean;
|
||||||
|
clientSecretConfigured: boolean;
|
||||||
|
discordWebhookConfigured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface VideoEditorMedia {
|
interface VideoEditorMedia {
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
info: VideoInfo;
|
info: VideoInfo;
|
||||||
@@ -370,6 +374,11 @@ interface ArchiveStats {
|
|||||||
interface ApiBridge {
|
interface ApiBridge {
|
||||||
getConfig(): Promise<AppConfig>;
|
getConfig(): Promise<AppConfig>;
|
||||||
saveConfig(config: Partial<AppConfig>, fileCapability?: string): Promise<AppConfig>;
|
saveConfig(config: Partial<AppConfig>, fileCapability?: string): Promise<AppConfig>;
|
||||||
|
getSecretStatus(): Promise<SecretStatus>;
|
||||||
|
setClientSecret(value: string): Promise<SecretStatus>;
|
||||||
|
clearClientSecret(): Promise<SecretStatus>;
|
||||||
|
setDiscordWebhook(value: string): Promise<SecretStatus>;
|
||||||
|
clearDiscordWebhook(): Promise<SecretStatus>;
|
||||||
login(): Promise<boolean>;
|
login(): Promise<boolean>;
|
||||||
getUserId(username: string): Promise<string | null>;
|
getUserId(username: string): Promise<string | null>;
|
||||||
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
|
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ let pendingSettingsAutoSave = false;
|
|||||||
let settingsAutoSaveTimer: number | null = null;
|
let settingsAutoSaveTimer: number | null = null;
|
||||||
let pendingCredentialsReconnect = false;
|
let pendingCredentialsReconnect = false;
|
||||||
let lastPersistedSettingsFingerprint = '';
|
let lastPersistedSettingsFingerprint = '';
|
||||||
|
const SECRET_INPUT_MASK = '••••••••';
|
||||||
|
let secretStatus: SecretStatus = {
|
||||||
|
encryptionAvailable: false,
|
||||||
|
clientSecretConfigured: false,
|
||||||
|
discordWebhookConfigured: false
|
||||||
|
};
|
||||||
|
|
||||||
function canRunSettingsAutoRefresh(): boolean {
|
function canRunSettingsAutoRefresh(): boolean {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
@@ -16,7 +22,7 @@ function canRunSettingsAutoRefresh(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function connect(): Promise<void> {
|
async function connect(): Promise<void> {
|
||||||
const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim());
|
const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && secretStatus.clientSecretConfigured);
|
||||||
if (!hasCredentials) {
|
if (!hasCredentials) {
|
||||||
isConnected = false;
|
isConnected = false;
|
||||||
updateStatus(UI_TEXT.status.noLogin, false, 'public');
|
updateStatus(UI_TEXT.status.noLogin, false, 'public');
|
||||||
@@ -576,11 +582,36 @@ function toggleDebugAutoRefresh(enabled: boolean): void {
|
|||||||
|
|
||||||
function collectCredentialsPayload(): Partial<AppConfig> {
|
function collectCredentialsPayload(): Partial<AppConfig> {
|
||||||
return {
|
return {
|
||||||
client_id: byId<HTMLInputElement>('clientId').value.trim(),
|
client_id: byId<HTMLInputElement>('clientId').value.trim()
|
||||||
client_secret: byId<HTMLInputElement>('clientSecret').value.trim()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncSecretFields(): void {
|
||||||
|
byId<HTMLInputElement>('clientSecret').value = secretStatus.clientSecretConfigured ? SECRET_INPUT_MASK : '';
|
||||||
|
byId<HTMLInputElement>('discordWebhookUrl').value = secretStatus.discordWebhookConfigured ? SECRET_INPUT_MASK : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistSecretInputs(): Promise<void> {
|
||||||
|
const clientValue = byId<HTMLInputElement>('clientSecret').value;
|
||||||
|
if (clientValue !== SECRET_INPUT_MASK) {
|
||||||
|
secretStatus = clientValue.trim()
|
||||||
|
? await window.api.setClientSecret(clientValue.trim())
|
||||||
|
: secretStatus.clientSecretConfigured
|
||||||
|
? await window.api.clearClientSecret()
|
||||||
|
: secretStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookValue = byId<HTMLInputElement>('discordWebhookUrl').value;
|
||||||
|
if (webhookValue !== SECRET_INPUT_MASK) {
|
||||||
|
secretStatus = webhookValue.trim()
|
||||||
|
? await window.api.setDiscordWebhook(webhookValue.trim())
|
||||||
|
: secretStatus.discordWebhookConfigured
|
||||||
|
? await window.api.clearDiscordWebhook()
|
||||||
|
: secretStatus;
|
||||||
|
}
|
||||||
|
syncSecretFields();
|
||||||
|
}
|
||||||
|
|
||||||
function syncPartMinutesFieldState(): void {
|
function syncPartMinutesFieldState(): void {
|
||||||
const downloadMode = byId<HTMLSelectElement>('downloadMode').value;
|
const downloadMode = byId<HTMLSelectElement>('downloadMode').value;
|
||||||
const partMinutes = byId<HTMLInputElement>('partMinutes');
|
const partMinutes = byId<HTMLInputElement>('partMinutes');
|
||||||
@@ -611,7 +642,6 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
|||||||
auto_resume_live_recording: byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked,
|
auto_resume_live_recording: byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked,
|
||||||
auto_merge_resumed_parts: byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked,
|
auto_merge_resumed_parts: byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked,
|
||||||
delete_parts_after_merge: byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked,
|
delete_parts_after_merge: byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked,
|
||||||
discord_webhook_url: byId<HTMLInputElement>('discordWebhookUrl').value.trim(),
|
|
||||||
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
|
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
|
||||||
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
|
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
|
||||||
discord_notify_vod_complete: byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked,
|
discord_notify_vod_complete: byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked,
|
||||||
@@ -658,7 +688,7 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
|||||||
const effective = { ...config, ...payload };
|
const effective = { ...config, ...payload };
|
||||||
return JSON.stringify([
|
return JSON.stringify([
|
||||||
effective.client_id ?? '',
|
effective.client_id ?? '',
|
||||||
effective.client_secret ?? '',
|
byId<HTMLInputElement>('clientSecret').value,
|
||||||
effective.sidebar_split_view !== false,
|
effective.sidebar_split_view !== false,
|
||||||
effective.download_mode ?? 'full',
|
effective.download_mode ?? 'full',
|
||||||
effective.part_minutes ?? 120,
|
effective.part_minutes ?? 120,
|
||||||
@@ -676,7 +706,7 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
|||||||
effective.auto_resume_live_recording !== false,
|
effective.auto_resume_live_recording !== false,
|
||||||
effective.auto_merge_resumed_parts === true,
|
effective.auto_merge_resumed_parts === true,
|
||||||
effective.delete_parts_after_merge === true,
|
effective.delete_parts_after_merge === true,
|
||||||
effective.discord_webhook_url ?? '',
|
byId<HTMLInputElement>('discordWebhookUrl').value,
|
||||||
effective.discord_notify_live_start === true,
|
effective.discord_notify_live_start === true,
|
||||||
effective.discord_notify_live_end === true,
|
effective.discord_notify_live_end === true,
|
||||||
effective.discord_notify_vod_complete === true,
|
effective.discord_notify_vod_complete === true,
|
||||||
@@ -697,7 +727,7 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
|||||||
|
|
||||||
function syncSettingsFormFromConfig(): void {
|
function syncSettingsFormFromConfig(): void {
|
||||||
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
|
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
|
||||||
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
|
syncSecretFields();
|
||||||
byId<HTMLInputElement>('sidebarSplitViewToggle').checked = config.sidebar_split_view !== false;
|
byId<HTMLInputElement>('sidebarSplitViewToggle').checked = config.sidebar_split_view !== false;
|
||||||
applySidebarLayoutPreference(config.sidebar_split_view !== false);
|
applySidebarLayoutPreference(config.sidebar_split_view !== false);
|
||||||
byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full';
|
byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full';
|
||||||
@@ -716,7 +746,6 @@ function syncSettingsFormFromConfig(): void {
|
|||||||
byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked = (config.auto_resume_live_recording as boolean) !== false;
|
byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked = (config.auto_resume_live_recording as boolean) !== false;
|
||||||
byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked = (config.auto_merge_resumed_parts as boolean) === true;
|
byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked = (config.auto_merge_resumed_parts as boolean) === true;
|
||||||
byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked = (config.delete_parts_after_merge as boolean) === true;
|
byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked = (config.delete_parts_after_merge as boolean) === true;
|
||||||
byId<HTMLInputElement>('discordWebhookUrl').value = (config.discord_webhook_url as string) || '';
|
|
||||||
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
|
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
|
||||||
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
|
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
|
||||||
byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true;
|
byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true;
|
||||||
@@ -759,6 +788,7 @@ async function persistSettings(options: {
|
|||||||
Object.assign(payload, templatePayload);
|
Object.assign(payload, templatePayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await persistSecretInputs();
|
||||||
config = await window.api.saveConfig(payload);
|
config = await window.api.saveConfig(payload);
|
||||||
syncSettingsFormFromConfig();
|
syncSettingsFormFromConfig();
|
||||||
pendingCredentialsReconnect = false;
|
pendingCredentialsReconnect = false;
|
||||||
@@ -798,7 +828,9 @@ async function flushSettingsAutoSave(reconnectAfterSave = false): Promise<void>
|
|||||||
|
|
||||||
settingsAutoSaveInFlight = true;
|
settingsAutoSaveInFlight = true;
|
||||||
try {
|
try {
|
||||||
|
await persistSecretInputs();
|
||||||
config = await window.api.saveConfig(payload);
|
config = await window.api.saveConfig(payload);
|
||||||
|
syncSecretFields();
|
||||||
lastPersistedSettingsFingerprint = getSettingsFingerprint({});
|
lastPersistedSettingsFingerprint = getSettingsFingerprint({});
|
||||||
if (reconnectAfterSave && pendingCredentialsReconnect) {
|
if (reconnectAfterSave && pendingCredentialsReconnect) {
|
||||||
pendingCredentialsReconnect = false;
|
pendingCredentialsReconnect = false;
|
||||||
@@ -904,6 +936,13 @@ function initSettingsAutoSave(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const id of ['clientSecret', 'discordWebhookUrl'] as const) {
|
||||||
|
byId<HTMLInputElement>(id).addEventListener('focus', (event) => {
|
||||||
|
const input = event.currentTarget as HTMLInputElement;
|
||||||
|
if (input.value === SECRET_INPUT_MASK) input.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('blur', () => {
|
window.addEventListener('blur', () => {
|
||||||
if (settingsAutoSaveTimer || pendingCredentialsReconnect) {
|
if (settingsAutoSaveTimer || pendingCredentialsReconnect) {
|
||||||
void flushSettingsAutoSave(pendingCredentialsReconnect);
|
void flushSettingsAutoSave(pendingCredentialsReconnect);
|
||||||
|
|||||||
+5
-3
@@ -5,13 +5,15 @@ const QUEUE_SYNC_HIDDEN_MS = 9000;
|
|||||||
const QUEUE_SYNC_RECENT_ACTIVITY_WINDOW_MS = 15000;
|
const QUEUE_SYNC_RECENT_ACTIVITY_WINDOW_MS = 15000;
|
||||||
|
|
||||||
async function init(): Promise<void> {
|
async function init(): Promise<void> {
|
||||||
const [loadedConfig, initialQueue, isDown, version] = await Promise.all([
|
const [loadedConfig, loadedSecretStatus, initialQueue, isDown, version] = await Promise.all([
|
||||||
window.api.getConfig(),
|
window.api.getConfig(),
|
||||||
|
window.api.getSecretStatus(),
|
||||||
window.api.getQueue(),
|
window.api.getQueue(),
|
||||||
window.api.isDownloading(),
|
window.api.isDownloading(),
|
||||||
window.api.getVersion()
|
window.api.getVersion()
|
||||||
]);
|
]);
|
||||||
config = loadedConfig;
|
config = loadedConfig;
|
||||||
|
secretStatus = loadedSecretStatus;
|
||||||
const language = setLanguage((config.language as string) || 'en');
|
const language = setLanguage((config.language as string) || 'en');
|
||||||
config.language = language;
|
config.language = language;
|
||||||
queue = Array.isArray(initialQueue) ? initialQueue : [];
|
queue = Array.isArray(initialQueue) ? initialQueue : [];
|
||||||
@@ -24,7 +26,7 @@ async function init(): Promise<void> {
|
|||||||
document.title = `${UI_TEXT.appName} v${version}`;
|
document.title = `${UI_TEXT.appName} v${version}`;
|
||||||
|
|
||||||
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
|
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
|
||||||
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
|
byId<HTMLInputElement>('clientSecret').value = secretStatus.clientSecretConfigured ? SECRET_INPUT_MASK : '';
|
||||||
byId<HTMLInputElement>('downloadPath').value = config.download_path ?? '';
|
byId<HTMLInputElement>('downloadPath').value = config.download_path ?? '';
|
||||||
byId<HTMLSelectElement>('themeSelect').value = config.theme ?? 'twitch';
|
byId<HTMLSelectElement>('themeSelect').value = config.theme ?? 'twitch';
|
||||||
byId<HTMLSelectElement>('languageSelect').value = config.language ?? 'en';
|
byId<HTMLSelectElement>('languageSelect').value = config.language ?? 'en';
|
||||||
@@ -182,7 +184,7 @@ async function init(): Promise<void> {
|
|||||||
else startStatsBarPolling();
|
else startStatsBarPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (config.client_id && config.client_secret) {
|
if (config.client_id && secretStatus.clientSecretConfigured) {
|
||||||
await connect();
|
await connect();
|
||||||
} else {
|
} else {
|
||||||
updateStatus(UI_TEXT.status.noLogin, false);
|
updateStatus(UI_TEXT.status.noLogin, false);
|
||||||
|
|||||||
Reference in New Issue
Block a user