fix(persistence): preserve authoritative state on write failures
Keep SQLite authoritative after completed migration even when legacy JSON is later invalid, reject non-object config documents before any migration state is written, and guard async secret masking by input generation. Persist renderer-facing config and queue mutations before updating memory so SQLite errors reject IPC calls and retain the last durable queue snapshot.
This commit is contained in:
+74
-99
@@ -52,6 +52,7 @@ 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 { createAppStateStore, type AppStateStore } from './main/domain/app-state-store';
|
||||||
import { createExportableConfig } from './main/domain/config-export';
|
import { createExportableConfig } from './main/domain/config-export';
|
||||||
|
import { persistStateChange } from './main/domain/persistence-commit';
|
||||||
import { resolveSecretInputUpdate } from './main/domain/secret-input';
|
import { resolveSecretInputUpdate } from './main/domain/secret-input';
|
||||||
import { createSecretStore, type SecretStore } from './main/domain/secret-store';
|
import { createSecretStore, type SecretStore } from './main/domain/secret-store';
|
||||||
import { createElectronSecureStorage } from './main/infra/secure-storage';
|
import { createElectronSecureStorage } from './main/infra/secure-storage';
|
||||||
@@ -456,17 +457,15 @@ function normalizeConfigTemplates(input: Config): Config {
|
|||||||
|
|
||||||
function recordDownloadedVodId(vodId: string): void {
|
function recordDownloadedVodId(vodId: string): void {
|
||||||
if (!vodId) return;
|
if (!vodId) return;
|
||||||
if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = [];
|
const downloadedVodIds = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : [];
|
||||||
if (config.downloaded_vod_ids.includes(vodId)) return;
|
if (downloadedVodIds.includes(vodId)) return;
|
||||||
config.downloaded_vod_ids.push(vodId);
|
|
||||||
// Cap to keep config size bounded — drop oldest first.
|
|
||||||
const DOWNLOADED_IDS_MAX = 4096;
|
const DOWNLOADED_IDS_MAX = 4096;
|
||||||
if (config.downloaded_vod_ids.length > DOWNLOADED_IDS_MAX) {
|
const nextDownloadedVodIds = [...downloadedVodIds, vodId].slice(-DOWNLOADED_IDS_MAX);
|
||||||
config.downloaded_vod_ids = config.downloaded_vod_ids.slice(
|
config = persistStateChange(config, (current) => ({ ...current, downloaded_vod_ids: nextDownloadedVodIds }), saveConfig);
|
||||||
config.downloaded_vod_ids.length - DOWNLOADED_IDS_MAX
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
saveConfig(config);
|
|
||||||
|
function cloneConfig(value: Config): Config {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadConfig(): Config {
|
function loadConfig(): Config {
|
||||||
@@ -481,12 +480,14 @@ function loadConfig(): Config {
|
|||||||
return normalizeConfigTemplates(defaultConfig);
|
return normalizeConfigTemplates(defaultConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveConfig(config: Config): void {
|
function saveConfig(nextConfig: Config): void {
|
||||||
try {
|
|
||||||
if (!appStateStore) throw new Error('Application state store is unavailable');
|
if (!appStateStore) throw new Error('Application state store is unavailable');
|
||||||
appStateStore.saveConfig(config);
|
try {
|
||||||
} catch (e) {
|
appStateStore.saveConfig(nextConfig);
|
||||||
console.error('Error saving config:', e);
|
lastPersistedConfig = cloneConfig(nextConfig);
|
||||||
|
} catch (error) {
|
||||||
|
if (nextConfig === config) config = cloneConfig(lastPersistedConfig);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,12 +636,14 @@ function loadQueue(): QueueItem[] {
|
|||||||
let queueSaveTimer: NodeJS.Timeout | null = null;
|
let queueSaveTimer: NodeJS.Timeout | null = null;
|
||||||
let pendingQueueSnapshot: QueueItem[] | null = null;
|
let pendingQueueSnapshot: QueueItem[] | null = null;
|
||||||
|
|
||||||
function clearQueueFileFromDisk(): void {
|
function cloneQueue(queue: QueueItem[]): QueueItem[] {
|
||||||
try {
|
return JSON.parse(JSON.stringify(queue)) as QueueItem[];
|
||||||
appStateStore?.saveQueue([]);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error clearing queue file:', e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearQueueFileFromDisk(): void {
|
||||||
|
if (!appStateStore) throw new Error('Application state store is unavailable');
|
||||||
|
appStateStore.saveQueue([]);
|
||||||
|
lastPersistedQueueSnapshot = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeQueueToDisk(queue: QueueItem[]): void {
|
function writeQueueToDisk(queue: QueueItem[]): void {
|
||||||
@@ -649,26 +652,14 @@ function writeQueueToDisk(queue: QueueItem[]): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
if (!appStateStore) throw new Error('Application state store is unavailable');
|
if (!appStateStore) throw new Error('Application state store is unavailable');
|
||||||
appStateStore.saveQueue(queue);
|
appStateStore.saveQueue(queue);
|
||||||
} catch (e) {
|
lastPersistedQueueSnapshot = cloneQueue(queue);
|
||||||
console.error('Error saving queue:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveQueue(queue: QueueItem[], force = false): void {
|
function saveQueue(queue: QueueItem[], force = false): void {
|
||||||
if (config.persist_queue_on_restart === false) {
|
const snapshot = cloneQueue(queue);
|
||||||
pendingQueueSnapshot = null;
|
pendingQueueSnapshot = snapshot;
|
||||||
if (queueSaveTimer) {
|
|
||||||
clearTimeout(queueSaveTimer);
|
|
||||||
queueSaveTimer = null;
|
|
||||||
}
|
|
||||||
clearQueueFileFromDisk();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingQueueSnapshot = queue;
|
|
||||||
|
|
||||||
if (appShutdownStarted && !force) {
|
if (appShutdownStarted && !force) {
|
||||||
if (queueSaveTimer) {
|
if (queueSaveTimer) {
|
||||||
@@ -678,28 +669,19 @@ function saveQueue(queue: QueueItem[], force = false): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (force) {
|
|
||||||
if (queueSaveTimer) {
|
if (queueSaveTimer) {
|
||||||
clearTimeout(queueSaveTimer);
|
clearTimeout(queueSaveTimer);
|
||||||
queueSaveTimer = null;
|
queueSaveTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
writeQueueToDisk(pendingQueueSnapshot);
|
try {
|
||||||
|
writeQueueToDisk(snapshot);
|
||||||
pendingQueueSnapshot = null;
|
pendingQueueSnapshot = null;
|
||||||
return;
|
} catch (error) {
|
||||||
}
|
|
||||||
|
|
||||||
if (queueSaveTimer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
queueSaveTimer = setTimeout(() => {
|
|
||||||
queueSaveTimer = null;
|
|
||||||
if (pendingQueueSnapshot) {
|
|
||||||
writeQueueToDisk(pendingQueueSnapshot);
|
|
||||||
pendingQueueSnapshot = null;
|
pendingQueueSnapshot = null;
|
||||||
|
if (queue === downloadQueue) downloadQueue = cloneQueue(lastPersistedQueueSnapshot);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}, QUEUE_SAVE_DEBOUNCE_MS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushQueueSave(): void {
|
function flushQueueSave(): void {
|
||||||
@@ -731,10 +713,12 @@ function startDevelopmentReload(): void {
|
|||||||
let appStateStore: AppStateStore | null = null;
|
let appStateStore: AppStateStore | null = null;
|
||||||
let appSecretStore: SecretStore | null = null;
|
let appSecretStore: SecretStore | null = null;
|
||||||
let config = normalizeConfigTemplates(defaultConfig);
|
let config = normalizeConfigTemplates(defaultConfig);
|
||||||
|
let lastPersistedConfig = cloneConfig(config);
|
||||||
let twitchClientSecret = '';
|
let twitchClientSecret = '';
|
||||||
let discordWebhookUrl = '';
|
let discordWebhookUrl = '';
|
||||||
let accessToken: string | null = null;
|
let accessToken: string | null = null;
|
||||||
let downloadQueue: QueueItem[] = [];
|
let downloadQueue: QueueItem[] = [];
|
||||||
|
let lastPersistedQueueSnapshot: QueueItem[] = [];
|
||||||
let queueIdCounter = 0;
|
let queueIdCounter = 0;
|
||||||
let lastQueueBroadcastFingerprint = '';
|
let lastQueueBroadcastFingerprint = '';
|
||||||
let isDownloading = false;
|
let isDownloading = false;
|
||||||
@@ -7289,7 +7273,8 @@ ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability
|
|||||||
delete acceptedConfig.download_path;
|
delete acceptedConfig.download_path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
config = normalizeConfigTemplates({ ...config, ...acceptedConfig });
|
const nextConfig = normalizeConfigTemplates({ ...config, ...acceptedConfig });
|
||||||
|
config = persistStateChange(config, () => nextConfig, saveConfig);
|
||||||
|
|
||||||
if (config.client_id !== previousClientId) {
|
if (config.client_id !== previousClientId) {
|
||||||
accessToken = null;
|
accessToken = null;
|
||||||
@@ -7304,8 +7289,6 @@ ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability
|
|||||||
nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark';
|
nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
saveConfig(config);
|
|
||||||
|
|
||||||
if (config.persist_queue_on_restart === false) {
|
if (config.persist_queue_on_restart === false) {
|
||||||
pendingQueueSnapshot = null;
|
pendingQueueSnapshot = null;
|
||||||
if (queueSaveTimer) {
|
if (queueSaveTimer) {
|
||||||
@@ -7408,8 +7391,7 @@ ipcMain.handle('start-live-recording', async (event, streamerName: string) => {
|
|||||||
return { success: false, error: 'ALREADY_RECORDING', streamer: login };
|
return { success: false, error: 'ALREADY_RECORDING', streamer: login };
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadQueue.push(liveItem);
|
downloadQueue = persistStateChange(downloadQueue, (current) => [...current, liveItem], saveQueue);
|
||||||
saveQueue(downloadQueue);
|
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
if (!isDownloading) scheduleQueueProcessing();
|
if (!isDownloading) scheduleQueueProcessing();
|
||||||
appendDebugLog('live-recording-queued', { streamer: login, title: liveItem.title });
|
appendDebugLog('live-recording-queued', { streamer: login, title: liveItem.title });
|
||||||
@@ -7434,8 +7416,7 @@ registerTrustedIpcHandler(ipcMain, 'add-to-queue', isTrustedRendererEvent, () =>
|
|||||||
return downloadQueue;
|
return downloadQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadQueue.push(item);
|
downloadQueue = persistStateChange(downloadQueue, (current) => [...current, item], saveQueue);
|
||||||
saveQueue(downloadQueue);
|
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
return downloadQueue;
|
return downloadQueue;
|
||||||
});
|
});
|
||||||
@@ -7461,16 +7442,14 @@ registerTrustedIpcHandler(ipcMain, 'remove-from-queue', isTrustedRendererEvent,
|
|||||||
try { if (fs.existsSync(cleanupPath)) fs.unlinkSync(cleanupPath); } catch { }
|
try { if (fs.existsSync(cleanupPath)) fs.unlinkSync(cleanupPath); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadQueue = downloadQueue.filter(item => item.id !== id);
|
downloadQueue = persistStateChange(downloadQueue, (current) => current.filter((item) => item.id !== id), saveQueue);
|
||||||
saveQueue(downloadQueue);
|
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
return downloadQueue;
|
return downloadQueue;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('clear-completed', (event) => {
|
ipcMain.handle('clear-completed', (event) => {
|
||||||
if (!isTrustedRendererEvent(event)) return downloadQueue;
|
if (!isTrustedRendererEvent(event)) return downloadQueue;
|
||||||
downloadQueue = downloadQueue.filter(item => item.status !== 'completed');
|
downloadQueue = persistStateChange(downloadQueue, (current) => current.filter((item) => item.status !== 'completed'), saveQueue);
|
||||||
saveQueue(downloadQueue);
|
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
return downloadQueue;
|
return downloadQueue;
|
||||||
});
|
});
|
||||||
@@ -7484,8 +7463,7 @@ ipcMain.handle('reorder-queue', (event, orderIds: string[]) => {
|
|||||||
return ai - bi;
|
return ai - bi;
|
||||||
});
|
});
|
||||||
|
|
||||||
downloadQueue = withOrder;
|
downloadQueue = persistStateChange(downloadQueue, () => withOrder, saveQueue);
|
||||||
saveQueue(downloadQueue);
|
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
return downloadQueue;
|
return downloadQueue;
|
||||||
});
|
});
|
||||||
@@ -7495,18 +7473,18 @@ ipcMain.handle('retry-failed-downloads', async (event) => {
|
|||||||
const failedIds = downloadQueue.filter((item) => item.status === 'error').map((item) => item.id);
|
const failedIds = downloadQueue.filter((item) => item.status === 'error').map((item) => item.id);
|
||||||
await Promise.all(failedIds.map((id) => queueProcessRegistry.cancelItem(id)));
|
await Promise.all(failedIds.map((id) => queueProcessRegistry.cancelItem(id)));
|
||||||
for (const id of failedIds) queueProcessRegistry.resetItem(id);
|
for (const id of failedIds) queueProcessRegistry.resetItem(id);
|
||||||
downloadQueue = downloadQueue.map((item) => {
|
const nextQueue: QueueItem[] = downloadQueue.map((item) => {
|
||||||
if (item.status !== 'error') return item;
|
if (item.status !== 'error') return item;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
status: 'pending',
|
status: 'pending' as const,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
last_error: ''
|
last_error: ''
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
saveQueue(downloadQueue);
|
downloadQueue = persistStateChange(downloadQueue, () => nextQueue, saveQueue);
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
|
|
||||||
if (!isDownloading) {
|
if (!isDownloading) {
|
||||||
@@ -7527,14 +7505,12 @@ ipcMain.handle('retry-queue-item', async (event, id: string) => {
|
|||||||
await queueProcessRegistry.cancelItem(id);
|
await queueProcessRegistry.cancelItem(id);
|
||||||
queueProcessRegistry.resetItem(id);
|
queueProcessRegistry.resetItem(id);
|
||||||
|
|
||||||
downloadQueue[idx] = {
|
downloadQueue = persistStateChange(downloadQueue, (current) => current.map((candidate) => candidate.id === id ? {
|
||||||
...item,
|
...candidate,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
last_error: ''
|
last_error: ''
|
||||||
};
|
} : candidate), saveQueue);
|
||||||
|
|
||||||
saveQueue(downloadQueue);
|
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
appendDebugLog('queue-item-retry-single', { id, title: item.title });
|
appendDebugLog('queue-item-retry-single', { id, title: item.title });
|
||||||
|
|
||||||
@@ -7615,10 +7591,9 @@ ipcMain.handle('create-merge-group', (event, itemIds: string[]) => {
|
|||||||
const firstIndex = downloadQueue.findIndex(item => itemIds.includes(item.id));
|
const firstIndex = downloadQueue.findIndex(item => itemIds.includes(item.id));
|
||||||
|
|
||||||
// Remove selected items and insert merged item at first position
|
// Remove selected items and insert merged item at first position
|
||||||
downloadQueue = downloadQueue.filter(item => !itemIds.includes(item.id));
|
const nextQueue = downloadQueue.filter((item) => !itemIds.includes(item.id));
|
||||||
downloadQueue.splice(firstIndex >= 0 ? Math.min(firstIndex, downloadQueue.length) : downloadQueue.length, 0, mergedItem);
|
nextQueue.splice(firstIndex >= 0 ? Math.min(firstIndex, nextQueue.length) : nextQueue.length, 0, mergedItem);
|
||||||
|
downloadQueue = persistStateChange(downloadQueue, () => nextQueue, saveQueue);
|
||||||
saveQueue(downloadQueue);
|
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
return downloadQueue;
|
return downloadQueue;
|
||||||
});
|
});
|
||||||
@@ -7626,26 +7601,24 @@ ipcMain.handle('create-merge-group', (event, itemIds: string[]) => {
|
|||||||
ipcMain.handle('start-download', async (event) => {
|
ipcMain.handle('start-download', async (event) => {
|
||||||
if (!isTrustedRendererEvent(event)) return false;
|
if (!isTrustedRendererEvent(event)) return false;
|
||||||
if (isDownloading && queuePaused) {
|
if (isDownloading && queuePaused) {
|
||||||
|
const nextQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'downloading' as const } : item);
|
||||||
|
downloadQueue = persistStateChange(downloadQueue, () => nextQueue, saveQueue);
|
||||||
queuePaused = false;
|
queuePaused = false;
|
||||||
for (const item of downloadQueue) {
|
|
||||||
if (item.status === 'paused') item.status = 'downloading';
|
|
||||||
}
|
|
||||||
await Promise.all(queueProcessRegistry.activeItemIds().map((id) => queueProcessRegistry.resumeItem(id)));
|
await Promise.all(queueProcessRegistry.activeItemIds().map((id) => queueProcessRegistry.resumeItem(id)));
|
||||||
saveQueue(downloadQueue);
|
|
||||||
emitQueueUpdated(true);
|
emitQueueUpdated(true);
|
||||||
mainWindow?.webContents.send('download-started');
|
mainWindow?.webContents.send('download-started');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'pending' } : item);
|
const nextQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'pending' as const } : item);
|
||||||
|
|
||||||
const hasPendingItems = downloadQueue.some(item => item.status === 'pending');
|
const hasPendingItems = nextQueue.some(item => item.status === 'pending');
|
||||||
if (!hasPendingItems) {
|
if (!hasPendingItems) {
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveQueue(downloadQueue);
|
downloadQueue = persistStateChange(downloadQueue, () => nextQueue, saveQueue);
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
|
|
||||||
if (!isDownloading) {
|
if (!isDownloading) {
|
||||||
@@ -7658,17 +7631,16 @@ ipcMain.handle('pause-download', async (event) => {
|
|||||||
if (!isTrustedRendererEvent(event)) return false;
|
if (!isTrustedRendererEvent(event)) return false;
|
||||||
if (!isDownloading || queuePaused) return false;
|
if (!isDownloading || queuePaused) return false;
|
||||||
|
|
||||||
queuePaused = true;
|
|
||||||
await Promise.all(queueProcessRegistry.activeItemIds().map((id) => queueProcessRegistry.pauseItem(id)));
|
await Promise.all(queueProcessRegistry.activeItemIds().map((id) => queueProcessRegistry.pauseItem(id)));
|
||||||
for (const item of downloadQueue) {
|
const nextQueue = downloadQueue.map((item) => item.status === 'downloading' ? {
|
||||||
if (item.status === 'downloading') {
|
...item,
|
||||||
item.status = 'paused';
|
status: 'paused' as const,
|
||||||
item.speed = '';
|
speed: '',
|
||||||
item.eta = '';
|
eta: '',
|
||||||
item.progressStatus = tBackend('downloadPaused');
|
progressStatus: tBackend('downloadPaused')
|
||||||
}
|
} : item);
|
||||||
}
|
downloadQueue = persistStateChange(downloadQueue, () => nextQueue, saveQueue);
|
||||||
saveQueue(downloadQueue);
|
queuePaused = true;
|
||||||
emitQueueUpdated(true);
|
emitQueueUpdated(true);
|
||||||
mainWindow?.webContents.send('download-paused');
|
mainWindow?.webContents.send('download-paused');
|
||||||
return true;
|
return true;
|
||||||
@@ -8170,16 +8142,17 @@ ipcMain.handle('export-runtime-metrics', async (event) => {
|
|||||||
ipcMain.handle('mark-vod-downloaded', (event, vodId: string, mark: boolean): { success: boolean } => {
|
ipcMain.handle('mark-vod-downloaded', (event, vodId: string, mark: boolean): { success: boolean } => {
|
||||||
if (!isTrustedRendererEvent(event)) return { success: false };
|
if (!isTrustedRendererEvent(event)) return { success: false };
|
||||||
if (typeof vodId !== 'string' || !vodId) return { success: false };
|
if (typeof vodId !== 'string' || !vodId) return { success: false };
|
||||||
if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = [];
|
const downloadedVodIds = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : [];
|
||||||
const has = config.downloaded_vod_ids.includes(vodId);
|
const has = downloadedVodIds.includes(vodId);
|
||||||
|
let nextDownloadedVodIds: string[];
|
||||||
if (mark && !has) {
|
if (mark && !has) {
|
||||||
config.downloaded_vod_ids.push(vodId);
|
nextDownloadedVodIds = [...downloadedVodIds, vodId];
|
||||||
} else if (!mark && has) {
|
} else if (!mark && has) {
|
||||||
config.downloaded_vod_ids = config.downloaded_vod_ids.filter((id) => id !== vodId);
|
nextDownloadedVodIds = downloadedVodIds.filter((id) => id !== vodId);
|
||||||
} else {
|
} else {
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
saveConfig(config);
|
config = persistStateChange(config, (current) => ({ ...current, downloaded_vod_ids: nextDownloadedVodIds }), saveConfig);
|
||||||
appendDebugLog('mark-vod-downloaded', { vodId, mark });
|
appendDebugLog('mark-vod-downloaded', { vodId, mark });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
@@ -8187,8 +8160,7 @@ ipcMain.handle('mark-vod-downloaded', (event, vodId: string, mark: boolean): { s
|
|||||||
ipcMain.handle('reset-downloaded-vod-ids', (event) => {
|
ipcMain.handle('reset-downloaded-vod-ids', (event) => {
|
||||||
if (!isTrustedRendererEvent(event)) return { success: false, removedCount: 0 };
|
if (!isTrustedRendererEvent(event)) return { success: false, removedCount: 0 };
|
||||||
const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0;
|
const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0;
|
||||||
config.downloaded_vod_ids = [];
|
config = persistStateChange(config, (current) => ({ ...current, downloaded_vod_ids: [] }), saveConfig);
|
||||||
saveConfig(config);
|
|
||||||
appendDebugLog('reset-downloaded-vod-ids', { previousCount: count });
|
appendDebugLog('reset-downloaded-vod-ids', { previousCount: count });
|
||||||
return { success: true, removedCount: count };
|
return { success: true, removedCount: count };
|
||||||
});
|
});
|
||||||
@@ -8254,8 +8226,7 @@ ipcMain.handle('import-config', async (event) => {
|
|||||||
delete imported.__exportedAt;
|
delete imported.__exportedAt;
|
||||||
const merged = normalizeConfigTemplates({ ...config, ...imported } as Config);
|
const merged = normalizeConfigTemplates({ ...config, ...imported } as Config);
|
||||||
|
|
||||||
config = merged;
|
config = persistStateChange(config, () => merged, saveConfig);
|
||||||
saveConfig(config);
|
|
||||||
appendDebugLog('config-import-applied', { source: importPath });
|
appendDebugLog('config-import-applied', { source: importPath });
|
||||||
return { success: true, filePath: importPath };
|
return { success: true, filePath: importPath };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -8481,8 +8452,10 @@ app.whenReady().then(() => {
|
|||||||
if (result.errors.length > 0) throw new Error(result.errors.map((entry: { source: string; message: string }) => `${entry.source}: ${entry.message}`).join('; '));
|
if (result.errors.length > 0) throw new Error(result.errors.map((entry: { source: string; message: string }) => `${entry.source}: ${entry.message}`).join('; '));
|
||||||
appStateStore = createAppStateStore(database);
|
appStateStore = createAppStateStore(database);
|
||||||
config = loadConfig();
|
config = loadConfig();
|
||||||
|
lastPersistedConfig = cloneConfig(config);
|
||||||
downloadQueue = config.persist_queue_on_restart === false ? [] : loadQueue();
|
downloadQueue = config.persist_queue_on_restart === false ? [] : loadQueue();
|
||||||
if (config.persist_queue_on_restart === false) appStateStore.saveQueue([]);
|
if (config.persist_queue_on_restart === false) appStateStore.saveQueue([]);
|
||||||
|
lastPersistedQueueSnapshot = cloneQueue(downloadQueue);
|
||||||
twitchClientSecret = appSecretStore.get('twitch_client_secret') ?? '';
|
twitchClientSecret = appSecretStore.get('twitch_client_secret') ?? '';
|
||||||
discordWebhookUrl = appSecretStore.get('discord_webhook_url') ?? '';
|
discordWebhookUrl = appSecretStore.get('discord_webhook_url') ?? '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -8494,7 +8467,9 @@ app.whenReady().then(() => {
|
|||||||
appStateStore = null;
|
appStateStore = null;
|
||||||
appSecretStore = null;
|
appSecretStore = null;
|
||||||
config = normalizeConfigTemplates(defaultConfig);
|
config = normalizeConfigTemplates(defaultConfig);
|
||||||
|
lastPersistedConfig = cloneConfig(config);
|
||||||
downloadQueue = [];
|
downloadQueue = [];
|
||||||
|
lastPersistedQueueSnapshot = [];
|
||||||
twitchClientSecret = '';
|
twitchClientSecret = '';
|
||||||
discordWebhookUrl = '';
|
discordWebhookUrl = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,42 @@ describe('migrateJsonToSqlite', () => {
|
|||||||
expect(JSON.parse(language!.value)).toBe('de');
|
expect(JSON.parse(language!.value)).toBe('de');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps SQLite config queue and secrets authoritative after a corrupted legacy config restart', () => {
|
||||||
|
const configPath = writeJson('config.json', { language: 'de', client_secret: 'persisted-secret' });
|
||||||
|
writeJson('download_queue.json', [{ id: 'q1', status: 'pending', title: 'Persisted queue item' }]);
|
||||||
|
let secrets = createSecretStore(db, new MemorySecureStorage());
|
||||||
|
|
||||||
|
migrateJsonToSqlite({ db, appDataDir, secrets });
|
||||||
|
db.close();
|
||||||
|
db = openDatabase(path.join(tmpDir, 'app.db'));
|
||||||
|
secrets = createSecretStore(db, new MemorySecureStorage());
|
||||||
|
fs.writeFileSync(configPath, '{ no longer valid JSON', 'utf-8');
|
||||||
|
|
||||||
|
const restart = migrateJsonToSqlite({ db, appDataDir, secrets });
|
||||||
|
|
||||||
|
expect(restart).toMatchObject({ alreadyApplied: true, errors: [] });
|
||||||
|
expect(JSON.parse(db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language'])!.value)).toBe('de');
|
||||||
|
expect(db.all<{ id: string }>('SELECT id FROM queue_items ORDER BY queue_position')).toEqual([{ id: 'q1' }]);
|
||||||
|
expect(secrets.get('twitch_client_secret')).toBe('persisted-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['null', null],
|
||||||
|
['false', false],
|
||||||
|
['zero', 0],
|
||||||
|
['empty string', ''],
|
||||||
|
])('does not mark or partially migrate a syntactically valid but invalid %s config', (_, invalidConfig) => {
|
||||||
|
writeJson('config.json', invalidConfig);
|
||||||
|
writeJson('download_queue.json', [{ id: 'q1', status: 'pending' }]);
|
||||||
|
|
||||||
|
const result = migrateJsonToSqlite({ db, appDataDir });
|
||||||
|
|
||||||
|
expect(result.errors).toEqual([{ source: 'config.json', message: 'Config JSON must be an object' }]);
|
||||||
|
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('does not let the former shadow-migration marker skip authoritative secret import', () => {
|
test('does not let the former shadow-migration marker skip authoritative secret import', () => {
|
||||||
const configPath = writeJson('config.json', { language: 'de', client_secret: 'legacy-secret' });
|
const configPath = writeJson('config.json', { language: 'de', client_secret: 'legacy-secret' });
|
||||||
db.run('INSERT INTO migrations_applied(name, payload) VALUES (?, ?)', ['v4-to-v5-jsons', '{}']);
|
db.run('INSERT INTO migrations_applied(name, payload) VALUES (?, ?)', ['v4-to-v5-jsons', '{}']);
|
||||||
|
|||||||
@@ -82,11 +82,6 @@ export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
|
|||||||
const queuePath = path.join(appDataDir, 'download_queue.json');
|
const queuePath = path.join(appDataDir, 'download_queue.json');
|
||||||
const existing = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', [MIGRATION_NAME]);
|
const existing = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', [MIGRATION_NAME]);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
try {
|
|
||||||
scrubExistingConfig(configPath);
|
|
||||||
} catch (error) {
|
|
||||||
return emptyResult(true, [{ source: 'config.json', message: error instanceof Error ? error.message : String(error) }]);
|
|
||||||
}
|
|
||||||
return emptyResult(true);
|
return emptyResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +94,7 @@ export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
|
|||||||
errors.push({ source: 'download_queue.json', message: 'Queue JSON must be an array' });
|
errors.push({ source: 'download_queue.json', message: 'Queue JSON must be an array' });
|
||||||
}
|
}
|
||||||
if (errors.length > 0) return emptyResult(false, errors);
|
if (errors.length > 0) return emptyResult(false, errors);
|
||||||
if (config && (typeof config !== 'object' || Array.isArray(config))) {
|
if (configExists && (!config || typeof config !== 'object' || Array.isArray(config))) {
|
||||||
return emptyResult(false, [{ source: 'config.json', message: 'Config JSON must be an object' }]);
|
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])) {
|
if (config && !secrets && [...SECRET_KEYS].some((key) => typeof config[key] === 'string' && config[key])) {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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';
|
||||||
|
import { persistStateChange } from './persistence-commit';
|
||||||
|
|
||||||
|
let directory: string;
|
||||||
|
let db: DbHandle;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'persistence-commit-'));
|
||||||
|
db = openDatabase(path.join(directory, 'app.db'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close();
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('persistStateChange', () => {
|
||||||
|
it('keeps runtime configuration at the persisted value when a SQLite write fails', () => {
|
||||||
|
const previous = { language: 'de' };
|
||||||
|
const next = { language: 'en' };
|
||||||
|
createAppStateStore(db).saveConfig(previous);
|
||||||
|
const failingDb: DbHandle = {
|
||||||
|
...db,
|
||||||
|
run(sql, params) {
|
||||||
|
if (sql.includes('INSERT INTO config_kv')) throw new Error('SQLITE_IOERR config');
|
||||||
|
db.run(sql, params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let runtime = previous;
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
runtime = persistStateChange(runtime, () => next, (candidate) => createAppStateStore(failingDb).saveConfig(candidate));
|
||||||
|
}).toThrow('SQLITE_IOERR config');
|
||||||
|
expect(runtime).toEqual(previous);
|
||||||
|
expect(createAppStateStore(db).loadConfig()).toMatchObject(previous);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps runtime queue at the persisted snapshot when a SQLite write fails', () => {
|
||||||
|
const previous = [{ id: 'q1', status: 'pending' }];
|
||||||
|
const next = [{ id: 'q2', status: 'pending' }];
|
||||||
|
createAppStateStore(db).saveQueue(previous);
|
||||||
|
const failingDb: DbHandle = {
|
||||||
|
...db,
|
||||||
|
run(sql, params) {
|
||||||
|
if (sql.includes('INSERT OR REPLACE INTO queue_items')) throw new Error('SQLITE_IOERR queue');
|
||||||
|
db.run(sql, params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let runtime = previous;
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
runtime = persistStateChange(runtime, () => next, (candidate) => createAppStateStore(failingDb).saveQueue(candidate));
|
||||||
|
}).toThrow('SQLITE_IOERR queue');
|
||||||
|
expect(runtime).toEqual(previous);
|
||||||
|
expect(createAppStateStore(db).loadQueue()).toEqual(previous);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function persistStateChange<T>(current: T, createNext: (current: T) => T, persist: (next: T) => void): T {
|
||||||
|
const next = createNext(current);
|
||||||
|
persist(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { resolveSecretInputUpdate } from './secret-input';
|
import { createSecretInputRevision, isSecretInputRevisionCurrent, resolveSecretInputUpdate } from './secret-input';
|
||||||
|
|
||||||
describe('resolveSecretInputUpdate', () => {
|
describe('resolveSecretInputUpdate', () => {
|
||||||
it('keeps a configured secret when the masked value is unchanged', () => {
|
it('keeps a configured secret when the masked value is unchanged', () => {
|
||||||
@@ -17,4 +17,23 @@ describe('resolveSecretInputUpdate', () => {
|
|||||||
it('ignores an empty field when no secret is configured', () => {
|
it('ignores an empty field when no secret is configured', () => {
|
||||||
expect(resolveSecretInputUpdate('', false)).toEqual({ action: 'unchanged' });
|
expect(resolveSecretInputUpdate('', false)).toEqual({ action: 'unchanged' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not apply a completed save mask after a newer secret input arrives', async () => {
|
||||||
|
const revision = createSecretInputRevision();
|
||||||
|
const requestRevision = revision.current();
|
||||||
|
let resolveSave: (() => void) | undefined;
|
||||||
|
let visibleValue = 'first-secret';
|
||||||
|
const save = new Promise<void>((resolve) => {
|
||||||
|
resolveSave = resolve;
|
||||||
|
}).then(() => {
|
||||||
|
if (isSecretInputRevisionCurrent(revision, requestRevision)) visibleValue = '••••••••';
|
||||||
|
});
|
||||||
|
|
||||||
|
revision.advance();
|
||||||
|
visibleValue = 'second-secret';
|
||||||
|
resolveSave?.();
|
||||||
|
await save;
|
||||||
|
|
||||||
|
expect(visibleValue).toBe('second-secret');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,27 @@ export type SecretInputUpdate =
|
|||||||
|
|
||||||
export const SECRET_INPUT_MASK = '••••••••';
|
export const SECRET_INPUT_MASK = '••••••••';
|
||||||
|
|
||||||
|
export interface SecretInputRevision {
|
||||||
|
current(): number;
|
||||||
|
advance(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSecretInputRevision(): SecretInputRevision {
|
||||||
|
let value = 0;
|
||||||
|
return {
|
||||||
|
current() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
advance() {
|
||||||
|
value += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSecretInputRevisionCurrent(revision: SecretInputRevision, value: number): boolean {
|
||||||
|
return revision.current() === value;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveSecretInputUpdate(value: string, configured: boolean): SecretInputUpdate {
|
export function resolveSecretInputUpdate(value: string, configured: boolean): SecretInputUpdate {
|
||||||
if (configured && value === SECRET_INPUT_MASK) return { action: 'unchanged' };
|
if (configured && value === SECRET_INPUT_MASK) return { action: 'unchanged' };
|
||||||
const normalized = value.trim();
|
const normalized = value.trim();
|
||||||
|
|||||||
+48
-24
@@ -12,6 +12,11 @@ let secretStatus: SecretStatus = {
|
|||||||
clientSecretConfigured: false,
|
clientSecretConfigured: false,
|
||||||
discordWebhookConfigured: false
|
discordWebhookConfigured: false
|
||||||
};
|
};
|
||||||
|
type SecretInputId = 'clientSecret' | 'discordWebhookUrl';
|
||||||
|
const secretInputGenerations: Record<SecretInputId, number> = {
|
||||||
|
clientSecret: 0,
|
||||||
|
discordWebhookUrl: 0
|
||||||
|
};
|
||||||
|
|
||||||
function canRunSettingsAutoRefresh(): boolean {
|
function canRunSettingsAutoRefresh(): boolean {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
@@ -586,30 +591,44 @@ function collectCredentialsPayload(): Partial<AppConfig> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function secretConfigured(inputId: SecretInputId): boolean {
|
||||||
|
return inputId === 'clientSecret' ? secretStatus.clientSecretConfigured : secretStatus.discordWebhookConfigured;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSecretField(inputId: SecretInputId): void {
|
||||||
|
byId<HTMLInputElement>(inputId).value = secretConfigured(inputId) ? SECRET_INPUT_MASK : '';
|
||||||
|
}
|
||||||
|
|
||||||
function syncSecretFields(): void {
|
function syncSecretFields(): void {
|
||||||
byId<HTMLInputElement>('clientSecret').value = secretStatus.clientSecretConfigured ? SECRET_INPUT_MASK : '';
|
syncSecretField('clientSecret');
|
||||||
byId<HTMLInputElement>('discordWebhookUrl').value = secretStatus.discordWebhookConfigured ? SECRET_INPUT_MASK : '';
|
syncSecretField('discordWebhookUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistSecretInput(inputId: SecretInputId): Promise<void> {
|
||||||
|
const requestGeneration = secretInputGenerations[inputId];
|
||||||
|
const value = byId<HTMLInputElement>(inputId).value;
|
||||||
|
if (value === SECRET_INPUT_MASK) return;
|
||||||
|
|
||||||
|
const configured = secretConfigured(inputId);
|
||||||
|
let nextStatus = secretStatus;
|
||||||
|
if (value.trim()) {
|
||||||
|
nextStatus = inputId === 'clientSecret'
|
||||||
|
? await window.api.setClientSecret(value.trim())
|
||||||
|
: await window.api.setDiscordWebhook(value.trim());
|
||||||
|
} else if (configured) {
|
||||||
|
nextStatus = inputId === 'clientSecret'
|
||||||
|
? await window.api.clearClientSecret()
|
||||||
|
: await window.api.clearDiscordWebhook();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secretInputGenerations[inputId] !== requestGeneration) return;
|
||||||
|
secretStatus = nextStatus;
|
||||||
|
syncSecretField(inputId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistSecretInputs(): Promise<void> {
|
async function persistSecretInputs(): Promise<void> {
|
||||||
const clientValue = byId<HTMLInputElement>('clientSecret').value;
|
await persistSecretInput('clientSecret');
|
||||||
if (clientValue !== SECRET_INPUT_MASK) {
|
await persistSecretInput('discordWebhookUrl');
|
||||||
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 {
|
||||||
@@ -725,9 +744,9 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncSettingsFormFromConfig(): void {
|
function syncSettingsFormFromConfig(syncSecrets = true): void {
|
||||||
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
|
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
|
||||||
syncSecretFields();
|
if (syncSecrets) 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';
|
||||||
@@ -790,7 +809,7 @@ async function persistSettings(options: {
|
|||||||
|
|
||||||
await persistSecretInputs();
|
await persistSecretInputs();
|
||||||
config = await window.api.saveConfig(payload);
|
config = await window.api.saveConfig(payload);
|
||||||
syncSettingsFormFromConfig();
|
syncSettingsFormFromConfig(false);
|
||||||
pendingCredentialsReconnect = false;
|
pendingCredentialsReconnect = false;
|
||||||
|
|
||||||
if (options.reconnectAfterSave) {
|
if (options.reconnectAfterSave) {
|
||||||
@@ -830,7 +849,6 @@ async function flushSettingsAutoSave(reconnectAfterSave = false): Promise<void>
|
|||||||
try {
|
try {
|
||||||
await persistSecretInputs();
|
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;
|
||||||
@@ -936,6 +954,12 @@ function initSettingsAutoSave(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const id of ['clientSecret', 'discordWebhookUrl'] as const) {
|
||||||
|
byId<HTMLInputElement>(id).addEventListener('input', () => {
|
||||||
|
secretInputGenerations[id] += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const id of ['clientSecret', 'discordWebhookUrl'] as const) {
|
for (const id of ['clientSecret', 'discordWebhookUrl'] as const) {
|
||||||
byId<HTMLInputElement>(id).addEventListener('focus', (event) => {
|
byId<HTMLInputElement>(id).addEventListener('focus', (event) => {
|
||||||
const input = event.currentTarget as HTMLInputElement;
|
const input = event.currentTarget as HTMLInputElement;
|
||||||
|
|||||||
Reference in New Issue
Block a user