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>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 20:29:57 +02:00
1 parent f80eb4d1b9
commit 96d89b35ce
6 files changed
+142 -7

No files matched your search

@@ -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.
+14
View File
@@ -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
@@ -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);
@@ -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),
+16
View File
@@ -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",
@@ -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 `<glob>` 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.