mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
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:
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.
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user