mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(migration): preserve and recover legacy desktop sources (#1550)
* fix(migration): recover legacy desktop sources without replacing current data * test(migration): cover legacy recovery IPC contracts * test(migration): use static legacy Electron bootstrap
This commit is contained in:
1 parent
002faafbd7
commit
9bcdbc0efb
26 files changed
+1505
-38
No files matched your search
@@ -82,6 +82,18 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
|
||||
channel: 'DB_UPSERT_APP_PLAYLIST',
|
||||
forwardedArgs: [playlist],
|
||||
},
|
||||
{
|
||||
method: 'dbRecoverLegacyPlaylists',
|
||||
args: [],
|
||||
channel: 'DB_RECOVER_LEGACY_PLAYLISTS',
|
||||
forwardedArgs: [],
|
||||
},
|
||||
{
|
||||
method: 'dbMigrateAppPlaylists',
|
||||
args: [playlists],
|
||||
channel: 'DB_MIGRATE_APP_PLAYLISTS',
|
||||
forwardedArgs: [playlists],
|
||||
},
|
||||
{
|
||||
method: 'dbUpsertAppPlaylists',
|
||||
args: [playlists],
|
||||
|
||||
@@ -750,6 +750,10 @@ const electronApi: ElectronBridgeApi = {
|
||||
ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId),
|
||||
dbUpsertAppPlaylist: (playlist: Playlist, _operationId?: string) =>
|
||||
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist),
|
||||
dbMigrateAppPlaylists: (playlists: Playlist[]) =>
|
||||
ipcRenderer.invoke('DB_MIGRATE_APP_PLAYLISTS', playlists),
|
||||
dbRecoverLegacyPlaylists: () =>
|
||||
ipcRenderer.invoke('DB_RECOVER_LEGACY_PLAYLISTS'),
|
||||
dbUpsertAppPlaylists: (playlists: Playlist[]) =>
|
||||
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLISTS', playlists),
|
||||
dbGetAppPlaylists: () => ipcRenderer.invoke('DB_GET_APP_PLAYLISTS'),
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { buildPlaylistRow } from './playlist.operations';
|
||||
|
||||
export const PLAYLIST_MIGRATION_KEY = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
export const LEGACY_PROFILE_MIGRATION_KEY =
|
||||
'playlists-electron-backend-profile-v1';
|
||||
|
||||
/** All rows and the receipt commit together; retries never replay a finished import. */
|
||||
export function migrateAppPlaylists(
|
||||
db: AppDatabase,
|
||||
playlists: Record<string, unknown>[],
|
||||
key = PLAYLIST_MIGRATION_KEY
|
||||
): { success: boolean; count: number } {
|
||||
try {
|
||||
return db.transaction((tx) => {
|
||||
if (
|
||||
tx
|
||||
.select()
|
||||
.from(schema.appState)
|
||||
.where(eq(schema.appState.key, key))
|
||||
.get()?.value === '1'
|
||||
) {
|
||||
return { success: true, count: 0 };
|
||||
}
|
||||
if (!Array.isArray(playlists))
|
||||
throw new Error('Invalid legacy playlists');
|
||||
const ids = new Set<string>();
|
||||
let count = 0;
|
||||
for (const playlist of playlists) {
|
||||
const row = buildPlaylistRow(playlist);
|
||||
if (!row || ids.has(row.id))
|
||||
throw new Error('Invalid legacy playlist');
|
||||
ids.add(row.id);
|
||||
const existing = tx
|
||||
.select()
|
||||
.from(schema.playlists)
|
||||
.where(eq(schema.playlists.id, row.id))
|
||||
.get();
|
||||
// A payload identifies an authoritative application source. A
|
||||
// v0.19 cache-only row can gain its legacy metadata without
|
||||
// replacing any catalog/favorite/history rows.
|
||||
if (existing?.payload) continue;
|
||||
const merged = {
|
||||
...row,
|
||||
// Cache-only credentials can be stale: v0.19 edited the
|
||||
// authoritative source in IndexedDB without updating SQLite.
|
||||
dateCreated: existing?.dateCreated ?? row.dateCreated,
|
||||
lastUpdated: row.lastUpdated ?? existing?.lastUpdated,
|
||||
};
|
||||
tx.insert(schema.playlists)
|
||||
.values(merged)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.playlists.id,
|
||||
set: merged,
|
||||
})
|
||||
.run();
|
||||
count++;
|
||||
}
|
||||
tx.insert(schema.appState)
|
||||
.values({ key, value: '1' })
|
||||
.onConflictDoUpdate({
|
||||
target: schema.appState.key,
|
||||
set: { value: '1' },
|
||||
})
|
||||
.run();
|
||||
return { success: true, count };
|
||||
});
|
||||
} catch {
|
||||
// Drizzle errors can include SQL parameter values (portal credentials).
|
||||
throw new Error(
|
||||
'Legacy playlist migration failed; original data was retained'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,7 @@ function inferPlaylistType(playlist: Record<string, unknown>): PlaylistType {
|
||||
return PLAYLIST_TYPES.M3U_TEXT;
|
||||
}
|
||||
|
||||
function buildPlaylistRow(
|
||||
export function buildPlaylistRow(
|
||||
playlist: Record<string, unknown>
|
||||
): schema.NewPlaylist | null {
|
||||
const id = getStringValue(playlist._id) ?? getStringValue(playlist.id);
|
||||
|
||||
@@ -4,12 +4,18 @@
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { recoverLegacyProfile } from '../../services/legacy-profile-recovery';
|
||||
import { databaseWorkerClient } from '../../services/database-worker-client';
|
||||
import {
|
||||
handleWorkerRequest,
|
||||
requestWorkerWithEvents,
|
||||
} from './worker-events.utils';
|
||||
|
||||
ipcMain.handle('DB_RECOVER_LEGACY_PLAYLISTS', () => recoverLegacyProfile());
|
||||
handleWorkerRequest(
|
||||
'DB_MIGRATE_APP_PLAYLISTS',
|
||||
(playlists: Record<string, unknown>[]) => ({ playlists })
|
||||
);
|
||||
handleWorkerRequest('DB_CREATE_PLAYLIST', (playlist: Record<string, unknown>) => playlist);
|
||||
handleWorkerRequest(
|
||||
'DB_UPSERT_APP_PLAYLIST',
|
||||
|
||||
@@ -73,6 +73,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
|
||||
args: [playlist],
|
||||
payload: playlist,
|
||||
},
|
||||
{
|
||||
operation: 'DB_MIGRATE_APP_PLAYLISTS',
|
||||
args: [playlists],
|
||||
payload: { playlists },
|
||||
},
|
||||
{
|
||||
operation: 'DB_UPSERT_APP_PLAYLISTS',
|
||||
args: [playlists],
|
||||
|
||||
@@ -92,11 +92,20 @@ describe('database worker IPC contract', () => {
|
||||
|
||||
it('registers an IPC handler for every worker operation', () => {
|
||||
const registeredDbChannels = [...mockRegisteredHandlers.keys()]
|
||||
.filter((channel) => channel !== 'DB_CANCEL_OPERATION')
|
||||
.filter(
|
||||
(channel) =>
|
||||
![
|
||||
'DB_CANCEL_OPERATION',
|
||||
'DB_RECOVER_LEGACY_PLAYLISTS',
|
||||
].includes(channel)
|
||||
)
|
||||
.sort();
|
||||
|
||||
expect(registeredDbChannels).toEqual([...DB_WORKER_OPERATIONS].sort());
|
||||
expect(mockRegisteredHandlers.has('DB_CANCEL_OPERATION')).toBe(true);
|
||||
expect(mockRegisteredHandlers.has('DB_RECOVER_LEGACY_PLAYLISTS')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it.each(workerIpcContractCases)(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { app } from 'electron';
|
||||
import { getElectronUserDataPath } from '@iptvnator/shared/database';
|
||||
import { selectLegacyProfile } from './legacy-profile';
|
||||
|
||||
const override = getElectronUserDataPath();
|
||||
const current = override ?? app.getPath('userData');
|
||||
const legacy = selectLegacyProfile(
|
||||
process.env.IPTVNATOR_E2E_DATA_DIR?.trim() || app.getPath('appData'),
|
||||
current
|
||||
);
|
||||
if (legacy || override) app.setPath('userData', legacy ?? current);
|
||||
@@ -0,0 +1,130 @@
|
||||
const mockRequest = jest.fn();
|
||||
const mockDialog = jest.fn();
|
||||
const mockRead = jest.fn();
|
||||
const mockDestroy = jest.fn();
|
||||
const mockAccess = jest.fn();
|
||||
const mockCopy = jest.fn();
|
||||
const mockRemove = jest.fn();
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: (key: string) =>
|
||||
key === 'appData' ? '/synthetic' : '/synthetic/current',
|
||||
commandLine: { hasSwitch: () => false },
|
||||
},
|
||||
dialog: { showMessageBox: (...args: unknown[]) => mockDialog(...args) },
|
||||
session: { fromPath: () => ({}) },
|
||||
BrowserWindow: jest.fn(() => ({
|
||||
loadFile: async () => undefined,
|
||||
destroy: mockDestroy,
|
||||
webContents: { executeJavaScript: mockRead },
|
||||
})),
|
||||
}));
|
||||
jest.mock('fs/promises', () => ({
|
||||
access: (...args: unknown[]) => mockAccess(...args),
|
||||
cp: (...args: unknown[]) => mockCopy(...args),
|
||||
mkdtemp: async () => '/synthetic/snapshot',
|
||||
writeFile: async () => undefined,
|
||||
rm: (...args: unknown[]) => mockRemove(...args),
|
||||
}));
|
||||
jest.mock('../database/connection', () => ({ getDatabase: async () => ({}) }));
|
||||
jest.mock('./database-worker-client', () => ({
|
||||
databaseWorkerClient: {
|
||||
request: (...args: unknown[]) => mockRequest(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const key = 'playlists-electron-backend-profile-v1';
|
||||
describe('optional legacy profile recovery', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
mockAccess.mockResolvedValue(undefined);
|
||||
mockCopy.mockResolvedValue(undefined);
|
||||
mockRemove.mockResolvedValue(undefined);
|
||||
mockRequest.mockResolvedValue(null);
|
||||
mockDialog.mockResolvedValue({ response: 0 });
|
||||
mockRead.mockResolvedValue([{ _id: 'synthetic-source' }]);
|
||||
});
|
||||
it('defaults to keeping current sources and never opens the legacy DB before consent', async () => {
|
||||
const { recoverLegacyProfile } =
|
||||
await import('./legacy-profile-recovery');
|
||||
await recoverLegacyProfile();
|
||||
expect(mockDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
detail: expect.stringContaining('intentionally deleted'),
|
||||
})
|
||||
);
|
||||
expect(mockCopy).not.toHaveBeenCalled();
|
||||
expect(mockRequest).toHaveBeenCalledWith('DB_SET_APP_STATE', {
|
||||
key,
|
||||
value: 'declined',
|
||||
});
|
||||
});
|
||||
it('reads a disposable copy only after consent and commits through the worker', async () => {
|
||||
mockDialog.mockResolvedValue({ response: 1 });
|
||||
const { recoverLegacyProfile } =
|
||||
await import('./legacy-profile-recovery');
|
||||
await recoverLegacyProfile();
|
||||
expect(mockDialog.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockCopy.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockCopy).toHaveBeenCalledWith(
|
||||
'/synthetic/electron-backend/IndexedDB',
|
||||
'/synthetic/snapshot/IndexedDB',
|
||||
{ recursive: true, dereference: true }
|
||||
);
|
||||
expect(mockRequest).toHaveBeenCalledWith('DB_MIGRATE_APP_PLAYLISTS', {
|
||||
key,
|
||||
playlists: [{ _id: 'synthetic-source' }],
|
||||
});
|
||||
expect(mockDestroy).toHaveBeenCalled();
|
||||
expect(mockRemove).toHaveBeenCalled();
|
||||
});
|
||||
it('keeps current sources usable on a corrupt legacy read, without a successful receipt', async () => {
|
||||
const warning = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
mockDialog.mockResolvedValue({ response: 1 });
|
||||
mockRead.mockRejectedValueOnce(new Error('synthetic corruption'));
|
||||
const { recoverLegacyProfile } =
|
||||
await import('./legacy-profile-recovery');
|
||||
await expect(recoverLegacyProfile()).resolves.toBeUndefined();
|
||||
expect(mockRequest).not.toHaveBeenCalledWith(
|
||||
'DB_MIGRATE_APP_PLAYLISTS',
|
||||
expect.anything()
|
||||
);
|
||||
expect(mockRequest).not.toHaveBeenCalledWith(
|
||||
'DB_SET_APP_STATE',
|
||||
expect.anything()
|
||||
);
|
||||
expect(mockDialog).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ type: 'error' })
|
||||
);
|
||||
warning.mockRestore();
|
||||
});
|
||||
it('never reimports completed recovery, including after source deletion', async () => {
|
||||
mockRequest.mockResolvedValue('1');
|
||||
const { recoverLegacyProfile } =
|
||||
await import('./legacy-profile-recovery');
|
||||
await recoverLegacyProfile();
|
||||
expect(mockDialog).not.toHaveBeenCalled();
|
||||
expect(mockCopy).not.toHaveBeenCalled();
|
||||
});
|
||||
it('bounds a stalled reader and destroys it without committing a receipt', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockRead.mockReturnValueOnce(new Promise(() => undefined));
|
||||
const { readLegacyProfilePlaylists } =
|
||||
await import('./legacy-profile-recovery');
|
||||
const read = readLegacyProfilePlaylists('/synthetic/electron-backend');
|
||||
const rejected = expect(read).rejects.toThrow('Legacy read timed out');
|
||||
await jest.advanceTimersByTimeAsync(15000);
|
||||
await rejected;
|
||||
expect(mockDestroy).toHaveBeenCalled();
|
||||
expect(mockRemove).toHaveBeenCalled();
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { app, BrowserWindow, dialog, session } from 'electron';
|
||||
import { mkdtemp, rm, writeFile, access } from 'fs/promises';
|
||||
import { copyLegacyIndexedDb } from './legacy-profile';
|
||||
import { tmpdir } from 'os';
|
||||
import { join, resolve } from 'path';
|
||||
import { getDatabase } from '../database/connection';
|
||||
import { databaseWorkerClient } from './database-worker-client';
|
||||
import { LEGACY_PROFILE_MIGRATION_KEY } from '../database/operations/playlist-migration.operations';
|
||||
|
||||
/** Read only a disposable copy: Chromium must never open the original LevelDB. */
|
||||
export async function readLegacyProfilePlaylists(
|
||||
profile: string
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const snapshot = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-legacy-recovery-')
|
||||
);
|
||||
let reader: BrowserWindow | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await copyLegacyIndexedDb(profile, snapshot);
|
||||
const index = join(snapshot, 'index.html');
|
||||
await writeFile(
|
||||
index,
|
||||
'<!doctype html><title>Legacy playlist reader</title>'
|
||||
);
|
||||
reader = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
session: session.fromPath(snapshot),
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
const read = reader.loadFile(index).then(() =>
|
||||
reader!.webContents
|
||||
.executeJavaScript(`new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('iptvnator', 1);
|
||||
request.onupgradeneeded = () => { request.transaction.abort(); reject(new Error('Legacy database is absent')); };
|
||||
request.onerror = () => reject(new Error('Legacy database could not be opened'));
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains('playlists')) { db.close(); reject(new Error('Legacy store is absent')); return; }
|
||||
const tx = db.transaction('playlists', 'readonly');
|
||||
const all = tx.objectStore('playlists').getAll();
|
||||
tx.oncomplete = () => { db.close(); resolve(all.result); };
|
||||
tx.onabort = tx.onerror = () => { db.close(); reject(new Error('Legacy read failed')); };
|
||||
};
|
||||
})`)
|
||||
);
|
||||
return await Promise.race([
|
||||
read,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error('Legacy read timed out')),
|
||||
15000
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
reader?.destroy();
|
||||
// Chromium can keep the disposable session's files open on Windows.
|
||||
await rm(snapshot, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 3,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
let recovery: Promise<void> | undefined;
|
||||
export function recoverLegacyProfile(): Promise<void> {
|
||||
return (recovery ??= recover().catch(() => {
|
||||
recovery = undefined;
|
||||
console.warn(
|
||||
'Legacy profile recovery failed; original data was retained'
|
||||
);
|
||||
return dialog
|
||||
.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Legacy recovery could not finish',
|
||||
message:
|
||||
'Your current sources are still available. Close the old IPTVnator version and restart to retry. The original data was retained.',
|
||||
})
|
||||
.then(() => undefined);
|
||||
}));
|
||||
}
|
||||
|
||||
async function recover(): Promise<void> {
|
||||
const root =
|
||||
process.env.IPTVNATOR_E2E_DATA_DIR?.trim() || app.getPath('appData');
|
||||
const legacy = join(root, 'electron-backend');
|
||||
if (resolve(legacy) === resolve(app.getPath('userData'))) return;
|
||||
try {
|
||||
await access(join(legacy, 'IndexedDB', 'file__0.indexeddb.leveldb'));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await getDatabase();
|
||||
const state = await databaseWorkerClient.request<string | null>(
|
||||
'DB_GET_APP_STATE',
|
||||
{ key: LEGACY_PROFILE_MIGRATION_KEY }
|
||||
);
|
||||
if (state === '1') return;
|
||||
if (
|
||||
state === 'declined' &&
|
||||
!app.commandLine.hasSwitch('recover-legacy-playlists')
|
||||
)
|
||||
return;
|
||||
const { response } = await dialog.showMessageBox({
|
||||
type: 'question',
|
||||
title: 'Recover legacy IPTVnator sources',
|
||||
message: 'An older IPTVnator profile was found.',
|
||||
detail: 'Recover all missing sources from this profile? This can also restore sources you intentionally deleted after upgrading. Existing sources and current settings will be kept. The original profile will not be erased. Close the old IPTVnator version before continuing.',
|
||||
buttons: ['Keep current sources', 'Recover all missing sources'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
noLink: true,
|
||||
});
|
||||
if (response !== 1) {
|
||||
await databaseWorkerClient.request('DB_SET_APP_STATE', {
|
||||
key: LEGACY_PROFILE_MIGRATION_KEY,
|
||||
value: 'declined',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const playlists = await readLegacyProfilePlaylists(legacy);
|
||||
await databaseWorkerClient.request('DB_MIGRATE_APP_PLAYLISTS', {
|
||||
playlists,
|
||||
key: LEGACY_PROFILE_MIGRATION_KEY,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
lstatSync,
|
||||
} from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { selectLegacyProfile, copyLegacyIndexedDb } from './legacy-profile';
|
||||
|
||||
describe('packaged legacy profile compatibility', () => {
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'iptvnator-profile-test-'));
|
||||
});
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
it('finds v0.19 storage before a fresh packaged renderer creates an empty database', () => {
|
||||
const legacy = join(root, 'electron-backend');
|
||||
mkdirSync(join(legacy, 'IndexedDB', 'file__0.indexeddb.leveldb'), {
|
||||
recursive: true,
|
||||
});
|
||||
expect(selectLegacyProfile(root, join(root, 'IPTVnator'))).toBe(legacy);
|
||||
});
|
||||
it('preserves an already used current profile, even when it contains no playlists', () => {
|
||||
mkdirSync(
|
||||
join(
|
||||
root,
|
||||
'electron-backend',
|
||||
'IndexedDB',
|
||||
'file__0.indexeddb.leveldb'
|
||||
),
|
||||
{ recursive: true }
|
||||
);
|
||||
const current = join(root, 'IPTVnator');
|
||||
mkdirSync(join(current, 'IndexedDB'), { recursive: true });
|
||||
expect(selectLegacyProfile(root, current)).toBeNull();
|
||||
});
|
||||
it('does not select an unrelated or absent profile', () => {
|
||||
expect(selectLegacyProfile(root, join(root, 'IPTVnator'))).toBeNull();
|
||||
});
|
||||
it('copies symlinked legacy storage into independent regular files', async () => {
|
||||
const legacy = join(root, 'legacy'),
|
||||
original = join(root, 'original');
|
||||
const snapshot = join(root, 'snapshot');
|
||||
mkdirSync(legacy);
|
||||
mkdirSync(original);
|
||||
writeFileSync(join(original, 'synthetic.ldb'), 'original data');
|
||||
symlinkSync(original, join(legacy, 'IndexedDB'), 'junction');
|
||||
await copyLegacyIndexedDb(legacy, snapshot);
|
||||
expect(lstatSync(join(snapshot, 'IndexedDB')).isSymbolicLink()).toBe(
|
||||
false
|
||||
);
|
||||
writeFileSync(
|
||||
join(snapshot, 'IndexedDB', 'synthetic.ldb'),
|
||||
'Chromium snapshot write'
|
||||
);
|
||||
expect(readFileSync(join(original, 'synthetic.ldb'), 'utf8')).toBe(
|
||||
'original data'
|
||||
);
|
||||
});
|
||||
it('keeps a current main-process config even before Chromium storage exists', () => {
|
||||
mkdirSync(
|
||||
join(
|
||||
root,
|
||||
'electron-backend',
|
||||
'IndexedDB',
|
||||
'file__0.indexeddb.leveldb'
|
||||
),
|
||||
{ recursive: true }
|
||||
);
|
||||
const current = join(root, 'IPTVnator');
|
||||
mkdirSync(current);
|
||||
writeFileSync(join(current, 'config.json'), '{}');
|
||||
expect(selectLegacyProfile(root, current)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { cp } from 'fs/promises';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
/** v0.19 packaged package.json used Nx's project name, electron-backend. */
|
||||
export function selectLegacyProfile(
|
||||
appData: string,
|
||||
current: string
|
||||
): string | null {
|
||||
const legacy = join(appData, 'electron-backend');
|
||||
if (resolve(legacy) === resolve(current)) return null;
|
||||
// Any current Chromium persistence is authoritative, including an empty
|
||||
// playlist store after a user deliberately deleted their sources.
|
||||
if (
|
||||
['IndexedDB', 'Local Storage', 'Preferences', 'config.json'].some(
|
||||
(entry) => existsSync(join(current, entry))
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return existsSync(join(legacy, 'IndexedDB', 'file__0.indexeddb.leveldb'))
|
||||
? legacy
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Materialize symlink targets so Chromium cannot write through to the original. */
|
||||
export async function copyLegacyIndexedDb(
|
||||
profile: string,
|
||||
snapshot: string
|
||||
): Promise<void> {
|
||||
await cp(join(profile, 'IndexedDB'), join(snapshot, 'IndexedDB'), {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export const DB_WORKER_OPERATIONS = [
|
||||
'DB_CREATE_PLAYLIST',
|
||||
'DB_UPSERT_APP_PLAYLIST',
|
||||
'DB_UPSERT_APP_PLAYLISTS',
|
||||
'DB_MIGRATE_APP_PLAYLISTS',
|
||||
'DB_GET_APP_PLAYLISTS',
|
||||
'DB_GET_APP_PLAYLIST_METAS',
|
||||
'DB_GET_APP_PLAYLIST',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { migrateAppPlaylists } from '../database/operations/playlist-migration.operations';
|
||||
import {
|
||||
closeWorkerDatabase,
|
||||
getWorkerDatabase,
|
||||
@@ -679,6 +680,14 @@ async function executeRequest(
|
||||
);
|
||||
}
|
||||
|
||||
case 'DB_MIGRATE_APP_PLAYLISTS': {
|
||||
const payload = message.payload as {
|
||||
playlists: Record<string, unknown>[];
|
||||
key?: string;
|
||||
};
|
||||
return migrateAppPlaylists(db, payload.playlists, payload.key);
|
||||
}
|
||||
|
||||
case 'DB_UPSERT_APP_PLAYLISTS': {
|
||||
return upsertAppPlaylists(
|
||||
db,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Select persistence before eager imports (notably electron-conf) cache userData.
|
||||
import './app/services/electron-profile-bootstrap';
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { getElectronUserDataPath } from '@iptvnator/shared/database';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import fixPath from 'fix-path';
|
||||
import App from './app/app';
|
||||
@@ -68,12 +69,6 @@ if (
|
||||
app.commandLine.appendSwitch('ozone-platform', 'x11');
|
||||
}
|
||||
|
||||
const electronUserDataPath = getElectronUserDataPath();
|
||||
|
||||
if (electronUserDataPath) {
|
||||
app.setPath('userData', electronUserDataPath);
|
||||
}
|
||||
|
||||
// The frame-copy embedded MPV engine must be decided before the main window
|
||||
// exists: it relaxes the window sandbox for the preload frame pump, and
|
||||
// webPreferences are fixed at window creation. The Settings toggle persists
|
||||
|
||||
Reference in new issue
Block a user