From 96d89b35ceaa7f255f622420d86290ab1a15bbd9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 28 Jul 2026 08:51:49 +0200 Subject: [PATCH] feat(packaging): register IPTVnator as the .m3u/.m3u8 handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changes/playlist-register-m3u-file-type.md | 9 ++++ CLAUDE.md | 14 +++++++ .../services/playlist-open-request.spec.ts | 32 +++++++++++++++ .../src/app/services/playlist-open-request.ts | 41 +++++++++++++++---- electron-builder.json | 16 ++++++++ .../electron-package-identity.test.mjs | 37 +++++++++++++++++ 6 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 .changes/playlist-register-m3u-file-type.md 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..5c5c596bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -666,6 +666,20 @@ 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 a percent-encoded `file://` URI rather than a path — +`createPlaylistOpenRequest` decodes it before the extension check. 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/src/app/services/playlist-open-request.spec.ts b/apps/electron-backend/src/app/services/playlist-open-request.spec.ts index e7e4f2b97..d1f8c72de 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,4 +1,5 @@ import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { createPlaylistOpenRequest, extractPlaylistOpenRequestFromArgv, @@ -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); 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..a7e646a10 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), 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.