diff --git a/.changes/playlist-register-m3u-file-type.md b/.changes/playlist-register-m3u-file-type.md new file mode 100644 index 000000000..6eae6beee --- /dev/null +++ b/.changes/playlist-register-m3u-file-type.md @@ -0,0 +1,9 @@ +--- +type: fix +area: playlist +--- + +IPTVnator now registers itself with the operating system as a handler for .m3u +and .m3u8 files, so Finder, Explorer and Linux file managers offer it in "Open +with" and a double-click actually opens the playlist. Installing or updating the +app is enough — no manual file-type setup. diff --git a/CLAUDE.md b/CLAUDE.md index e7eed5c33..c9c62a2af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -666,6 +666,24 @@ itself reuses the normal file path playlist-scoped EPG, and the navigation to the new playlist all behave exactly like a dialog import. +The OS-level registration that makes those paths reachable is +`fileAssociations` in `electron-builder.json` — one entry per extension, each +with its own `mimeType`. Electron Builder derives all three platform +registrations from it: macOS `CFBundleDocumentTypes` (which is what makes +`open-file` fire from Finder), the NSIS registry entries, and, on Linux, the +desktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for +deb/rpm/pacman. Two traps: it assigns the derived `MimeType` *after* spreading +`linux.desktop.entry`, so declaring `MimeType` there is silently overwritten and +must not be used; and it appends `%U` to `Exec`, so Linux file managers hand +over percent-encoded `file://` URIs rather than paths — +`createPlaylistOpenRequest` decodes them before the extension check. `%U` is +also the *plural* exec code, so a multi-file selection arrives as one launch +with one argument per file; `extractPlaylistOpenRequestsFromArgv` returns all +of them and `enqueueAll` queues the batch, because stopping at the first match +would silently drop the rest of the selection. Adding an exec code to +`linux.executableArgs` would suppress the `%U` but also pass that code to the +app as a real argument, so it is not an option. + **Video Players**: - Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer diff --git a/apps/electron-backend-e2e/src/file-import.e2e.ts b/apps/electron-backend-e2e/src/file-import.e2e.ts index dca15c6a2..22f264e56 100644 --- a/apps/electron-backend-e2e/src/file-import.e2e.ts +++ b/apps/electron-backend-e2e/src/file-import.e2e.ts @@ -1,3 +1,5 @@ +import { copyFileSync } from 'node:fs'; +import { join } from 'node:path'; import { closeElectronApp, expect, @@ -5,6 +7,7 @@ import { launchCompetingElectronInstance, launchElectronApp, m3uFixturePath, + openSources, test, } from './electron-test-fixtures'; @@ -48,6 +51,32 @@ test.describe('Electron Native Playlist Import', () => { } }); + 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, }) => { diff --git a/apps/electron-backend/src/app/services/playlist-open-request.spec.ts b/apps/electron-backend/src/app/services/playlist-open-request.spec.ts index e7e4f2b97..d74913d67 100644 --- a/apps/electron-backend/src/app/services/playlist-open-request.spec.ts +++ b/apps/electron-backend/src/app/services/playlist-open-request.spec.ts @@ -1,7 +1,8 @@ import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { createPlaylistOpenRequest, - extractPlaylistOpenRequestFromArgv, + extractPlaylistOpenRequestsFromArgv, isPlaylistFilePath, PlaylistOpenRequest, PlaylistOpenRequestQueue, @@ -49,6 +50,37 @@ describe('createPlaylistOpenRequest', () => { expect(first?.requestId).not.toEqual(second?.requestId); }); + // The Linux desktop entry ends in `%U`, so a double-click in the file + // manager delivers a URI rather than a path. + it('accepts a file:// URI', () => { + const filePath = resolve('/tmp/playlists/list.m3u'); + + expect(createPlaylistOpenRequest(pathToFileURL(filePath).href)).toEqual( + expect.objectContaining({ fileName: 'list.m3u', filePath }) + ); + }); + + it('decodes percent-encoding before matching the extension', () => { + const filePath = resolve('/tmp/My Playlist.m3u'); + const uri = pathToFileURL(filePath).href; + + expect(uri).toContain('%20'); + expect(createPlaylistOpenRequest(uri)).toEqual( + expect.objectContaining({ + fileName: 'My Playlist.m3u', + filePath, + }) + ); + }); + + it('rejects a file:// URI that cannot be parsed', () => { + expect(createPlaylistOpenRequest('file://[bad/list.m3u')).toBeNull(); + }); + + it('rejects a file:// URI that is not a playlist', () => { + expect(createPlaylistOpenRequest('file:///tmp/movie.mkv')).toBeNull(); + }); + it('recognizes playlist extensions through the exported guard', () => { expect(isPlaylistFilePath('a.m3u')).toBe(true); expect(isPlaylistFilePath('a.m3u8')).toBe(true); @@ -56,59 +88,85 @@ describe('createPlaylistOpenRequest', () => { }); }); -describe('extractPlaylistOpenRequestFromArgv', () => { +describe('extractPlaylistOpenRequestsFromArgv', () => { it('finds the playlist path in a packaged launch', () => { expect( - extractPlaylistOpenRequestFromArgv([ + extractPlaylistOpenRequestsFromArgv([ '/opt/iptvnator/iptvnator', '/home/user/list.m3u', ]) - ).toMatchObject({ - fileName: 'list.m3u', - filePath: '/home/user/list.m3u', - }); + ).toMatchObject([ + { + fileName: 'list.m3u', + filePath: '/home/user/list.m3u', + }, + ]); }); it('skips the executable, switches and the development entry point', () => { expect( - extractPlaylistOpenRequestFromArgv([ + extractPlaylistOpenRequestsFromArgv([ '/usr/bin/electron', '--remote-debugging-port=9222', '--ozone-platform=x11', '/workspace/dist/apps/electron-backend/main.js', '/home/user/list.m3u8', ]) - ).toMatchObject({ - fileName: 'list.m3u8', - filePath: '/home/user/list.m3u8', - }); + ).toMatchObject([ + { + fileName: 'list.m3u8', + filePath: '/home/user/list.m3u8', + }, + ]); + }); + + // `%U` hands over the whole selection, one argument per file. + it('keeps every playlist of a multi-file selection, in order', () => { + const requests = extractPlaylistOpenRequestsFromArgv([ + '/opt/iptvnator/iptvnator', + '--ozone-platform=x11', + 'file:///home/user/first.m3u', + '/home/user/movie.mkv', + 'file:///home/user/second%20list.m3u8', + ]); + + expect(requests).toHaveLength(2); + expect(requests).toMatchObject([ + { fileName: 'first.m3u', filePath: '/home/user/first.m3u' }, + { + fileName: 'second list.m3u8', + filePath: '/home/user/second list.m3u8', + }, + ]); }); it('never treats the executable itself as the playlist', () => { - expect( - extractPlaylistOpenRequestFromArgv(['/opt/weird.m3u']) - ).toBeNull(); + expect(extractPlaylistOpenRequestsFromArgv(['/opt/weird.m3u'])).toEqual( + [] + ); }); - it('returns null when no playlist argument is present', () => { + it('returns nothing when no playlist argument is present', () => { expect( - extractPlaylistOpenRequestFromArgv([ + extractPlaylistOpenRequestsFromArgv([ '/opt/iptvnator/iptvnator', '--no-sandbox', ]) - ).toBeNull(); + ).toEqual([]); }); it('resolves a relative argument against the forwarded working directory', () => { expect( - extractPlaylistOpenRequestFromArgv( + extractPlaylistOpenRequestsFromArgv( ['/opt/iptvnator/iptvnator', 'list.m3u'], '/home/user/tv' ) - ).toMatchObject({ - fileName: 'list.m3u', - filePath: resolve('/home/user/tv', 'list.m3u'), - }); + ).toMatchObject([ + { + fileName: 'list.m3u', + filePath: resolve('/home/user/tv', 'list.m3u'), + }, + ]); }); }); @@ -218,6 +276,33 @@ describe('PlaylistOpenRequestQueue', () => { expect(queue.unacknowledgedRequests).toEqual([request('known')]); }); + it('delivers a whole selection in arrival order', () => { + const queue = new PlaylistOpenRequestQueue(); + const delivered: PlaylistOpenRequest[] = []; + + queue.setDelivery((entry) => { + delivered.push(entry); + return true; + }); + queue.enqueueAll([request('one'), null, request('two')]); + + expect(delivered).toEqual([request('one'), request('two')]); + }); + + it('keeps the untouched remainder of a selection queued', () => { + const queue = new PlaylistOpenRequestQueue(); + let accept = true; + + queue.enqueueAll([request('one'), request('two')]); + queue.setDelivery(() => { + const outcome = accept; + accept = false; + return outcome; + }); + + expect(queue.pendingRequests).toEqual([request('two')]); + }); + it('keeps a request queued when delivery reports a dead target', () => { const queue = new PlaylistOpenRequestQueue(); diff --git a/apps/electron-backend/src/app/services/playlist-open-request.ts b/apps/electron-backend/src/app/services/playlist-open-request.ts index 033028b5a..039eb4998 100644 --- a/apps/electron-backend/src/app/services/playlist-open-request.ts +++ b/apps/electron-backend/src/app/services/playlist-open-request.ts @@ -3,7 +3,8 @@ * * Three entry points converge here: * - a first launch with a path argument (`iptvnator playlist.m3u`, which is - * also what Windows/Linux file associations do), + * also what the Windows file association does; the Linux one passes a + * `file://` URI instead), * - a second launch while the app is already running, whose argv the * single-instance guard forwards, * - macOS, which never puts the path in argv and emits `open-file` instead. @@ -17,6 +18,7 @@ */ import { basename, isAbsolute, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; export interface PlaylistOpenRequest { readonly fileName: string; @@ -44,8 +46,30 @@ export function isPlaylistFilePath(candidate: string): boolean { } /** - * Normalizes an OS-supplied path into an open request, or returns `null` when - * it is not a playlist file. Relative paths are resolved against the working + * Linux desktop entries end in `%U`, so file managers hand over a + * percent-encoded `file:///…` URI rather than a path — Electron Builder appends + * that exec code unconditionally unless `linux.executableArgs` already carries + * one, and putting one there would also pass it to the app as a real argument. + * Every other source (macOS `open-file`, the Windows association command, a + * shell argument) supplies a plain path, which is returned untouched. + */ +function toLocalPath(candidate: string): string | null { + if (!/^file:\/\//i.test(candidate)) { + return candidate; + } + + try { + // Rejects a URI naming another host, whose path is not ours to read. + return fileURLToPath(candidate); + } catch { + return null; + } +} + +/** + * Normalizes an OS-supplied path or `file://` URI into an open request, or + * returns `null` when it is not a playlist file. Relative paths are resolved + * against the working * directory of the process that supplied them — for a forwarded second launch * that is *not* this process' cwd. */ @@ -54,14 +78,17 @@ export function createPlaylistOpenRequest( workingDirectory?: string ): PlaylistOpenRequest | null { const candidate = filePath?.trim(); + // Decoded before the extension check so a percent-encoded name still ends + // in a recognizable `.m3u`. + const localPath = candidate ? toLocalPath(candidate) : null; - if (!candidate || !isPlaylistFilePath(candidate)) { + if (!localPath || !isPlaylistFilePath(localPath)) { return null; } - const absolutePath = isAbsolute(candidate) - ? candidate - : resolve(workingDirectory?.trim() || process.cwd(), candidate); + const absolutePath = isAbsolute(localPath) + ? localPath + : resolve(workingDirectory?.trim() || process.cwd(), localPath); return { fileName: basename(absolutePath), @@ -71,18 +98,25 @@ export function createPlaylistOpenRequest( } /** - * Picks the playlist path out of a process argv. + * Picks every playlist path out of a process argv, in the order given. * * `argv[0]` is the executable and is always skipped; so is every switch, since * Electron and Chromium add plenty of them (`--remote-debugging-port=9222`, * `--ozone-platform=x11`, …) and a switch value is never the user's file. * In development the renderer entry (`main.js`) sits in argv too, but it is * not a playlist file so the extension check filters it out. + * + * Selecting several playlists in a Linux file manager delivers them as one + * launch with one argument each, because the desktop entry ends in `%U` — the + * plural exec code — so stopping at the first match would silently drop the + * rest of the selection. */ -export function extractPlaylistOpenRequestFromArgv( +export function extractPlaylistOpenRequestsFromArgv( argv: readonly string[], workingDirectory?: string -): PlaylistOpenRequest | null { +): PlaylistOpenRequest[] { + const requests: PlaylistOpenRequest[] = []; + for (const argument of argv.slice(1)) { if (typeof argument !== 'string' || argument.startsWith('-')) { continue; @@ -91,11 +125,11 @@ export function extractPlaylistOpenRequestFromArgv( const request = createPlaylistOpenRequest(argument, workingDirectory); if (request) { - return request; + requests.push(request); } } - return null; + return requests; } export class PlaylistOpenRequestQueue { @@ -114,6 +148,20 @@ export class PlaylistOpenRequestQueue { this.flush(); } + /** + * Queues a whole selection at once. One flush for the batch, so a delivery + * that fails partway leaves the untouched remainder in arrival order. + */ + enqueueAll(requests: readonly (PlaylistOpenRequest | null | undefined)[]): void { + for (const request of requests) { + if (request) { + this.pending.push(request); + } + } + + this.flush(); + } + /** Queued requests, in arrival order. Exposed for assertions. */ get pendingRequests(): readonly PlaylistOpenRequest[] { return this.pending; diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 7a155251a..dde0be3e6 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -41,7 +41,7 @@ import { runEmbeddedMpvRuntimeDiagnosticOrContinue } from './app/services/embedd import { acquireSingleInstanceLock } from './app/services/single-instance'; import { createPlaylistOpenRequest, - extractPlaylistOpenRequestFromArgv, + extractPlaylistOpenRequestsFromArgv, playlistOpenRequests, } from './app/services/playlist-open-request'; import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service'; @@ -210,10 +210,11 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { }); // Windows/Linux file associations and plain `iptvnator playlist.m3u` - // launches arrive as an argument instead. The renderer drains the queue - // once it is ready. - playlistOpenRequests.enqueue( - extractPlaylistOpenRequestFromArgv(process.argv) + // launches arrive as arguments instead — one per file, since selecting + // several playlists at once is a single launch. The renderer drains the + // queue once it is ready. + playlistOpenRequests.enqueueAll( + extractPlaylistOpenRequestsFromArgv(process.argv) ); // handle setup events as quickly as possible @@ -233,8 +234,8 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { // in the app you already have running". Its argv is the only // carrier for that, and it is relative to *its* cwd. onSecondInstance: (argv, workingDirectory) => { - playlistOpenRequests.enqueue( - extractPlaylistOpenRequestFromArgv( + playlistOpenRequests.enqueueAll( + extractPlaylistOpenRequestsFromArgv( argv, workingDirectory ) diff --git a/electron-builder.json b/electron-builder.json index fca1b100e..55a1728b3 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -2,6 +2,22 @@ "appId": "com.fourgray.iptvnator", "productName": "IPTVnator", "artifactName": "${name}-${version}-${os}-${arch}.${ext}", + "fileAssociations": [ + { + "ext": "m3u", + "name": "M3U Playlist", + "description": "IPTV playlist", + "mimeType": "audio/x-mpegurl", + "role": "Viewer" + }, + { + "ext": "m3u8", + "name": "M3U8 Playlist", + "description": "IPTV playlist", + "mimeType": "application/vnd.apple.mpegurl", + "role": "Viewer" + } + ], "publish": [ { "provider": "github", diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index cbdc9c0cc..1982965b5 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -153,6 +153,43 @@ test('Linux package identity does not expose the internal Electron backend proje ); }); +test('playlist file associations are registered with the operating system', () => { + // Without these the OS never offers IPTVnator as a handler, so every + // runtime path for an OS-supplied playlist is unreachable by double-click. + assert.deepEqual(electronBuilderConfig.fileAssociations, [ + { + ext: 'm3u', + name: 'M3U Playlist', + description: 'IPTV playlist', + mimeType: 'audio/x-mpegurl', + role: 'Viewer', + }, + { + ext: 'm3u8', + name: 'M3U8 Playlist', + description: 'IPTV playlist', + mimeType: 'application/vnd.apple.mpegurl', + role: 'Viewer', + }, + ]); + + // Electron Builder derives the Linux desktop entry's MimeType from these + // `mimeType` fields, and assigns it *after* spreading `linux.desktop.entry` + // — so declaring MimeType there instead would be silently overwritten. + assert.equal( + electronBuilderConfig.linux?.desktop?.entry?.MimeType, + undefined + ); + + // Each association needs its own extension: Electron Builder derives the + // per-extension NSIS registry entries and the Linux `` in + // /usr/share/mime from `ext`, one mimeType per association. + const extensions = electronBuilderConfig.fileAssociations.map( + (association) => association.ext + ); + assert.equal(new Set(extensions).size, extensions.length); +}); + test('GitHub Releases auto-update metadata is generated and uploaded', () => { // \r?\n keeps this host-agnostic: Windows checkouts with autocrlf see // CRLF in the workflow file.