Files
iptvnator/apps/electron-backend-e2e/src/file-import.e2e.ts
T
4grayandClaude Opus 5 99a85da6b0 feat(packaging): register IPTVnator as the .m3u/.m3u8 handler (#1301)
* feat(packaging): register IPTVnator as the .m3u/.m3u8 handler

Every runtime path for an OS-supplied playlist existed, but no packaging
metadata claimed the file types — so the OS never offered IPTVnator as a
handler and `open-file` could not fire from Finder.

`fileAssociations` declares one entry per extension. Electron Builder derives
all three platform registrations from it: macOS `CFBundleDocumentTypes` (the
prerequisite for `open-file`), the NSIS registry entries, and, on Linux, the
desktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for
deb/rpm/pacman. Neither platform needs a dedicated icon — both fall back to the
app icon.

Declaring `MimeType` under `linux.desktop.entry` would not have worked:
Electron Builder assigns the association-derived value *after* spreading that
object, so an explicit key there is silently overwritten. The per-association
`mimeType` fields produce the same entry through the supported path.

Registering the types also exposes a gap in the delivery side. The generated
Linux `Exec` ends in `%U`, so file managers hand over a percent-encoded
`file://` URI rather than a path, which `createPlaylistOpenRequest` would have
resolved into a bogus relative path. It now decodes a `file://` candidate
before the extension check. Suppressing the `%U` instead would mean putting an
exec code in `linux.executableArgs`, which also passes it to the app as a real
argument.

Verified on macOS against a signed packaged bundle: Launch Services lists the
app as a `public.m3u-playlist` handler, and an LS-initiated open imports the
playlist both on a cold launch and against the already-running process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(playlist): open every playlist of a multi-file selection

`%U` is the plural exec code, so selecting several playlists in a Linux file
manager is one launch carrying one argument per file. Both argv paths called
`extractPlaylistOpenRequestFromArgv`, which returned at the first match, so
everything after the first playlist was silently discarded — a gap this PR
itself opened by making the desktop entry reachable in the first place.

The extractor is now plural and returns every match in argument order, and the
queue gained `enqueueAll` so a selection is pushed under a single flush: a
delivery that fails partway leaves the untouched remainder queued in arrival
order rather than interleaved.

Covered by unit tests over a mixed argv (percent-encoded `file://` URI, a
non-playlist argument, a second URI) and by a new Electron E2E that launches
with two playlist arguments and asserts both are imported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 21:53:43 +02:00

108 lines
3.5 KiB
TypeScript

import { copyFileSync } from 'node:fs';
import { join } from 'node:path';
import {
closeElectronApp,
expect,
importM3uPlaylistFromNativeDialog,
launchCompetingElectronInstance,
launchElectronApp,
m3uFixturePath,
openSources,
test,
} from './electron-test-fixtures';
test.describe('Electron Native Playlist Import', () => {
test('@m3u @electron imports an M3U playlist via the native file picker path', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/);
await expect(
app.mainWindow.getByTestId('channel-item')
).toHaveCount(4);
} finally {
await closeElectronApp(app);
}
});
test('@m3u @electron opens a playlist passed on the command line', async ({
dataDir,
}) => {
// Mirrors a file-association double-click on Windows/Linux: the path
// is the app's own argv, resolved and queued in the main process
// before the renderer exists.
const app = await launchElectronApp(dataDir, {
appArgs: [m3uFixturePath],
});
try {
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/, {
timeout: 30000,
});
await expect(
app.mainWindow.getByTestId('channel-item')
).toHaveCount(4);
} finally {
await closeElectronApp(app);
}
});
test('@m3u @electron opens every playlist of a multi-file selection', async ({
dataDir,
}) => {
// Selecting several playlists in a file manager is a single launch
// with one argument per file, because the generated desktop entry
// ends in `%U`. Stopping at the first would drop the rest.
const secondPlaylist = join(dataDir, 'second-selection.m3u');
copyFileSync(m3uFixturePath, secondPlaylist);
const app = await launchElectronApp(dataDir, {
appArgs: [m3uFixturePath, secondPlaylist],
});
try {
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/, {
timeout: 30000,
});
await openSources(app.mainWindow);
await expect(
app.mainWindow.locator('app-playlist-item')
).toHaveCount(2, { timeout: 30000 });
} finally {
await closeElectronApp(app);
}
});
test('@m3u @electron opens a playlist handed over by a second launch', async ({
dataDir,
}) => {
// The second process never gets a window: the single-instance guard
// makes it quit and forwards its argv to the running app.
const app = await launchElectronApp(dataDir);
try {
const competing = await launchCompetingElectronInstance(dataDir, {
appArgs: [m3uFixturePath],
});
expect(
competing.timedOut,
`Competing instance did not exit. stderr: ${competing.stderr}`
).toBe(false);
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/, {
timeout: 30000,
});
await expect(
app.mainWindow.getByTestId('channel-item')
).toHaveCount(4);
} finally {
await closeElectronApp(app);
}
});
});