mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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>
This commit is contained in:
1 parent
bc4e3a2e2c
commit
99a85da6b0
8 files changed
+285
-42
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,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
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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