Merge origin/master into claude/parental-control-feature-31dde2

Resolves the settings-store defaults split, the electron-conf key list and
the guidance reorganization (CLAUDE.md now imports AGENTS.md; the parental
lock contract is linked from the agent context map instead).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-25 22:12:58 +02:00
commit ae5d3f49de
732 files changed
+42572 -15380

No files matched your search

+5
View File
@@ -1,3 +1,4 @@
export * from './lib/app-update-channel.util';
export * from './lib/channel-drm.interface';
export * from './lib/channel.interface';
export * from './lib/channel.model';
@@ -6,6 +7,7 @@ export * from './lib/dev-logger.util';
export * from './lib/download-metadata.interface';
export * from './lib/embedded-mpv-session.interface';
export * from './lib/subtitle-style.util';
export * from './lib/zoom-level.util';
export * from './lib/electron-api.interface';
export * from './lib/epg-channel-metadata.model';
export * from './lib/epg-channel-with-programs.interface';
@@ -14,6 +16,7 @@ export * from './lib/epg-display-offset.util';
export * from './lib/epg-item.interface';
export * from './lib/epg-mapping-key.util';
export * from './lib/epg-program.model';
export * from './lib/epg-source-reference.util';
export * from './lib/external-player-arguments.utils';
export * from './lib/embedded-mpv-extra-options.util';
export * from './lib/external-player-session.interface';
@@ -47,6 +50,7 @@ export * from './lib/provider-overview.util';
export * from './lib/random-id.util';
export * from './lib/recording-metadata.interface';
export * from './lib/recording-program-overlap.util';
export * from './lib/renderer-reload-route.util';
export * from './lib/security-policy-error.utils';
export * from './lib/settings.interface';
export * from './lib/stalker-auth-failure.util';
@@ -58,6 +62,7 @@ export * from './lib/stream-format.enum';
export * from './lib/catalog-title-match.interface';
export * from './lib/theme.enum';
export * from './lib/season-marker.util';
export * from './lib/search-text-fold.util';
export * from './lib/stalker-account-info-dialog-data.interface';
export * from './lib/title-normalization.util';
export * from './lib/tmdb.interface';
@@ -0,0 +1,126 @@
import {
appUpdateReleasesApiUrl,
appUpdateReleasesListUrl,
appUpdateReleasesPageUrl,
buildAppUpdateReleaseNotesNotFoundMessage,
isAppUpdateReleaseNotesNotFoundMessage,
appUpdateRepository,
appVersionChannel,
compareAppVersions,
normalizeAppUpdateChannel,
parseAppVersion,
} from './app-update-channel.util';
describe('app update channel util', () => {
it('collapses unknown channels to stable', () => {
expect(normalizeAppUpdateChannel('nightly')).toBe('nightly');
expect(normalizeAppUpdateChannel('stable')).toBe('stable');
expect(normalizeAppUpdateChannel('canary')).toBe('stable');
expect(normalizeAppUpdateChannel(undefined)).toBe('stable');
expect(normalizeAppUpdateChannel(null)).toBe('stable');
});
it('maps each channel to its repository and pages', () => {
expect(appUpdateRepository('stable')).toEqual({
owner: '4gray',
repo: 'iptvnator',
});
expect(appUpdateRepository('nightly')).toEqual({
owner: '4gray',
repo: 'iptvnator-nightly',
});
// /releases/latest never resolves to a prerelease.
expect(appUpdateReleasesPageUrl('stable')).toBe(
'https://github.com/4gray/iptvnator/releases/latest'
);
expect(appUpdateReleasesPageUrl('nightly')).toBe(
'https://github.com/4gray/iptvnator-nightly/releases'
);
expect(appUpdateReleasesApiUrl('nightly')).toBe(
'https://api.github.com/repos/4gray/iptvnator-nightly/releases'
);
expect(appUpdateReleasesListUrl('stable')).toBe(
'https://github.com/4gray/iptvnator/releases'
);
expect(appUpdateReleasesListUrl('nightly')).toBe(
'https://github.com/4gray/iptvnator-nightly/releases'
);
});
it('recognises the not-found rejection through the IPC wrapper', () => {
const message = buildAppUpdateReleaseNotesNotFoundMessage('0.24.0');
expect(message).toBe('Release notes were not found for 0.24.0');
expect(buildAppUpdateReleaseNotesNotFoundMessage(undefined)).toBe(
'Release notes were not found for latest release'
);
expect(
isAppUpdateReleaseNotesNotFoundMessage(
`Error invoking remote method 'APP_UPDATE:GET_RELEASE_NOTES': Error: ${message}`
)
).toBe(true);
expect(
isAppUpdateReleaseNotesNotFoundMessage(
'GitHub releases request failed: 403 Forbidden'
)
).toBe(false);
expect(isAppUpdateReleaseNotesNotFoundMessage(null)).toBe(false);
});
it('reads the channel a build belongs to off its version', () => {
expect(appVersionChannel('0.23.0')).toBe('stable');
expect(appVersionChannel('v0.23.0')).toBe('stable');
expect(appVersionChannel('0.23.1-nightly.20260915.1234')).toBe(
'nightly'
);
expect(appVersionChannel('v0.23.1-nightly.20260915.1234')).toBe(
'nightly'
);
expect(appVersionChannel('0.23.1-beta.1')).toBe('stable');
expect(appVersionChannel('junk')).toBe('stable');
expect(appVersionChannel(undefined)).toBe('stable');
});
it('parses core and prerelease identifiers', () => {
expect(parseAppVersion('v0.23.1-nightly.20260915.7+abc')).toEqual({
core: [0, 23, 1],
prerelease: ['nightly', '20260915', '7'],
});
expect(parseAppVersion('0.23')).toBeNull();
});
it('orders versions by semver precedence', () => {
expect(compareAppVersions('0.24.0', '0.23.0')).toBe(1);
expect(compareAppVersions('0.23.0', '0.23.0')).toBe(0);
// A nightly sits between the released version and the next patch.
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.23.0')).toBe(
1
);
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.23.1')).toBe(
-1
);
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.24.0')).toBe(
-1
);
// Same day: the run number decides numerically, not lexically.
expect(
compareAppVersions(
'0.23.1-nightly.20260915.1000',
'0.23.1-nightly.20260915.999'
)
).toBe(1);
expect(
compareAppVersions(
'0.23.1-nightly.20260916.1',
'0.23.1-nightly.20260915.999'
)
).toBe(1);
// Fewer identifiers rank lower; numeric identifiers rank below words.
expect(compareAppVersions('0.23.1-nightly', '0.23.1-nightly.1')).toBe(
-1
);
expect(compareAppVersions('0.23.1-1', '0.23.1-alpha')).toBe(-1);
// Junk never looks like an update.
expect(compareAppVersions('junk', '0.23.0')).toBe(0);
});
});
@@ -0,0 +1,208 @@
/**
* Desktop update channel. Electron only.
*
* `stable` follows the tagged releases of `4gray/iptvnator`; `nightly`
* follows the prereleases that every master merge publishes to
* `4gray/iptvnator-nightly`. The value is persisted with the other settings
* in the renderer and mirrored into the main-process config by the
* `SETTINGS_UPDATE` handler, because the startup update check runs before
* any renderer exists to ask.
*
* Channel switches are forward-only on purpose: a nightly build stays
* installed until the next stable version is newer than it. A downgrade
* could land on a release that does not understand the database schema a
* nightly migration already applied.
*/
export type AppUpdateChannel = 'stable' | 'nightly';
export const APP_UPDATE_CHANNELS: readonly AppUpdateChannel[] = [
'stable',
'nightly',
];
export const DEFAULT_APP_UPDATE_CHANNEL: AppUpdateChannel = 'stable';
/**
* Prerelease identifier every nightly version carries
* (`0.23.1-nightly.20260915.1234`). The nightly build sets the same name as
* electron-builder's publish channel, which names the updater metadata
* (`nightly-mac.yml`, `nightly.yml`, `nightly-linux.yml`), and
* electron-updater matches release tags on the same identifier.
*/
export const NIGHTLY_PRERELEASE_TAG = 'nightly';
/** Collapses anything that is not a known channel to `stable`. */
export function normalizeAppUpdateChannel(value: unknown): AppUpdateChannel {
return APP_UPDATE_CHANNELS.includes(value as AppUpdateChannel)
? (value as AppUpdateChannel)
: DEFAULT_APP_UPDATE_CHANNEL;
}
interface AppUpdateRepository {
owner: string;
repo: string;
}
const APP_UPDATE_REPOSITORIES: Record<AppUpdateChannel, AppUpdateRepository> = {
stable: { owner: '4gray', repo: 'iptvnator' },
nightly: { owner: '4gray', repo: 'iptvnator-nightly' },
};
export function appUpdateRepository(
channel: AppUpdateChannel
): AppUpdateRepository {
return { ...APP_UPDATE_REPOSITORIES[channel] };
}
/**
* Page a user can open to install by hand. `/releases/latest` never resolves
* to a prerelease, and the nightly repository holds nothing else, so that
* channel links to the release list instead.
*/
export function appUpdateReleasesPageUrl(channel: AppUpdateChannel): string {
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
const base = `https://github.com/${owner}/${repo}/releases`;
return channel === 'stable' ? `${base}/latest` : base;
}
/** The channel's release list; the page to browse when one version is missing. */
export function appUpdateReleasesListUrl(channel: AppUpdateChannel): string {
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
return `https://github.com/${owner}/${repo}/releases`;
}
export function appUpdateReleasesApiUrl(channel: AppUpdateChannel): string {
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
return `https://api.github.com/repos/${owner}/${repo}/releases`;
}
const VERSION_PATTERN =
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
interface ParsedAppVersion {
core: [number, number, number];
prerelease: string[];
}
export function parseAppVersion(
value: string | null | undefined
): ParsedAppVersion | null {
const match = (value ?? '').trim().match(VERSION_PATTERN);
if (!match) {
return null;
}
return {
core: [Number(match[1]), Number(match[2]), Number(match[3])],
prerelease: match[4] ? match[4].split('.') : [],
};
}
/** The channel a build belongs to, read from its version string. */
export function appVersionChannel(
version: string | null | undefined
): AppUpdateChannel {
return parseAppVersion(version)?.prerelease[0] === NIGHTLY_PRERELEASE_TAG
? 'nightly'
: 'stable';
}
function compareIdentifiers(left: string, right: string): number {
const leftNumeric = /^\d+$/.test(left);
const rightNumeric = /^\d+$/.test(right);
if (leftNumeric && rightNumeric) {
return Math.sign(Number(left) - Number(right));
}
// Semver: numeric identifiers always have lower precedence.
if (leftNumeric !== rightNumeric) {
return leftNumeric ? -1 : 1;
}
return left < right ? -1 : left > right ? 1 : 0;
}
/**
* Semver precedence for the version strings the updater sees: core
* numbers first, then a release outranks its own prereleases, then the
* prerelease identifiers left to right. Unparseable input compares as
* equal so a junk version can never look like an update.
*/
export function compareAppVersions(left: string, right: string): number {
const parsedLeft = parseAppVersion(left);
const parsedRight = parseAppVersion(right);
if (!parsedLeft || !parsedRight) {
return 0;
}
for (let index = 0; index < 3; index += 1) {
const difference = parsedLeft.core[index] - parsedRight.core[index];
if (difference !== 0) {
return Math.sign(difference);
}
}
if (parsedLeft.prerelease.length === 0) {
return parsedRight.prerelease.length === 0 ? 0 : 1;
}
if (parsedRight.prerelease.length === 0) {
return -1;
}
const length = Math.max(
parsedLeft.prerelease.length,
parsedRight.prerelease.length
);
for (let index = 0; index < length; index += 1) {
const leftIdentifier = parsedLeft.prerelease[index];
const rightIdentifier = parsedRight.prerelease[index];
if (leftIdentifier === undefined) {
return -1;
}
if (rightIdentifier === undefined) {
return 1;
}
const difference = compareIdentifiers(leftIdentifier, rightIdentifier);
if (difference !== 0) {
return difference;
}
}
return 0;
}
/**
* Marker the main process puts into the release-notes rejection when a
* version has no published GitHub release. `ipcRenderer.invoke` strips every
* custom property off a rejection and wraps the message, so the renderer can
* only recognise the case by this text.
*/
export const APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER =
'Release notes were not found for';
export function buildAppUpdateReleaseNotesNotFoundMessage(
version: string | undefined
): string {
return `${APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER} ${version ?? 'latest release'}`;
}
export function isAppUpdateReleaseNotesNotFoundMessage(
message: string | null | undefined
): boolean {
return Boolean(
message?.includes(APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER)
);
}
@@ -1,5 +1,7 @@
import type { AppUpdateChannel } from './app-update-channel.util';
import type { SourceProbeContext, SourceHealthResult } from './source-health';
import type { XtreamConnectionFailure } from './xtream-connection-test';
import type { ZoomLevelAction } from './zoom-level.util';
import type {
CatchupDownloadMetadata,
DownloadRecoveryResult,
@@ -263,7 +265,20 @@ export interface ElectronBridgeAppUpdateStatus {
progress?: ElectronBridgeAppUpdateProgress;
error?: string;
supportedSelfUpdate: boolean;
/** Release page for the configured channel; opened for manual installs. */
manualDownloadUrl: string;
/** Channel the updater checks (`Settings.updateChannel`, mirrored). */
channel: AppUpdateChannel;
/**
* Channel the current verdict — `latestVersion`, `release`, a download
* in flight or finished — was produced on. Absent until the first
* check. Differs from `channel` after a channel change saved while a
* download was running or done: the download is kept, the new channel
* has not been checked yet.
*/
verdictChannel?: AppUpdateChannel;
/** Channel the running build came from, read off its version string. */
installedChannel: AppUpdateChannel;
}
export type ElectronBridgeAppUpdateReleaseNotesDirection = 'previous' | 'next';
@@ -714,6 +729,19 @@ export interface ElectronBridgeApi {
*/
toggleFullScreenWindow: () => Promise<ElectronBridgeWindowState>;
closeWindow: () => Promise<void>;
/**
* Zoom shortcuts (Cmd/Ctrl and +/−/0). Synchronous and preload-local:
* steps the FRAME-BOUND temporary zoom level through `webFrame` (never a
* main-process `webContents.setZoomLevel`, whose per-URL entry the app's
* `file://` path routing resets — issue #1109) by `stepZoomLevel`'s
* rules — one `ZOOM_LEVEL_STEP` inside `ZOOM_LEVEL_MIN..ZOOM_LEVEL_MAX`,
* a stored level already outside them never moved against the request —
* and returns the level now applied, which is therefore not itself
* guaranteed to be within the limits. Persistence needs nothing from the
* caller: the main process reads the live level back on close, quit and
* reload.
*/
adjustZoomLevel: (action: ZoomLevelAction) => number;
getWindowState: () => Promise<ElectronBridgeWindowState>;
onWindowStateChange: (
callback: (state: ElectronBridgeWindowState) => void
@@ -848,6 +876,8 @@ export interface ElectronBridgeApi {
url: string,
options?: ElectronBridgeTrustOptions
) => Promise<ElectronBridgeEpgFetchResult>;
/** Native picker for a local XMLTV file; resolves null when cancelled. */
openEpgFileDialog: () => Promise<string | null>;
clearEpgData: () => Promise<ElectronBridgeResult>;
reconcileEpgSources: (urls: string[]) => Promise<ElectronBridgeResult>;
clearEpgDataForSource: (sourceUrl: string) => Promise<ElectronBridgeResult>;
@@ -0,0 +1,80 @@
import {
classifyEpgSourceReference,
isLocalEpgSourceReference,
isRemoteEpgSourceUrl,
validateEpgSourceReferenceControl,
} from './epg-source-reference.util';
describe('classifyEpgSourceReference', () => {
it.each([
'http://epg.example.org/guide.xml',
'https://epg.example.org/guide.xml.gz?token=abc',
' HTTPS://epg.example.org/guide.xml ',
])('treats %s as remote', (value) => {
expect(classifyEpgSourceReference(value)).toBe('remote');
expect(isRemoteEpgSourceUrl(value)).toBe(true);
expect(isLocalEpgSourceReference(value)).toBe(false);
});
it.each([
'file:///home/user/epg/guide.xml.gz',
'file:///C:/epg/guide.xml',
'/home/user/epg/guide.xml',
'/Users/me/Downloads/guide.xml.gz',
'C:\\epg\\guide.xml.gz',
'd:/epg/guide.xml',
'\\\\nas\\share\\epg\\guide.xml.gz',
])('treats %s as a local file', (value) => {
expect(classifyEpgSourceReference(value)).toBe('local');
expect(isLocalEpgSourceReference(value)).toBe(true);
expect(isRemoteEpgSourceUrl(value)).toBe(false);
});
it.each([
'guide.xml',
'./guide.xml',
'~/epg/guide.xml',
'ftp://epg.example.org/guide.xml',
'file://',
'epg.example.org/guide.xml',
'C:',
'',
' ',
])('rejects %j', (value) => {
expect(classifyEpgSourceReference(value)).toBeNull();
});
it('rejects null and undefined', () => {
expect(classifyEpgSourceReference(null)).toBeNull();
expect(classifyEpgSourceReference(undefined)).toBeNull();
});
});
describe('validateEpgSourceReferenceControl', () => {
it('accepts an empty control like Validators.pattern does', () => {
expect(validateEpgSourceReferenceControl({ value: '' })).toBeNull();
expect(validateEpgSourceReferenceControl({ value: null })).toBeNull();
});
it('accepts remote links and local files', () => {
expect(
validateEpgSourceReferenceControl({
value: 'https://epg.example.org/guide.xml',
})
).toBeNull();
expect(
validateEpgSourceReferenceControl({ value: '/tmp/guide.xml.gz' })
).toBeNull();
});
it('flags relative paths and unsupported schemes', () => {
expect(
validateEpgSourceReferenceControl({ value: 'guide.xml' })
).toEqual({ epgSourceReference: true });
expect(
validateEpgSourceReferenceControl({
value: 'ftp://epg.example.org/guide.xml',
})
).toEqual({ epgSourceReference: true });
});
});
@@ -0,0 +1,72 @@
/**
* Classification of the value a user types as an EPG source.
*
* A source is either a remote XMLTV link (`http:`/`https:`) or a local file:
* a `file:` URL, an absolute POSIX path, a Windows drive path or a UNC path.
* Relative paths are refused on purpose — the main process has no meaningful
* working directory to resolve them against.
*
* Only values the user entered by hand (Settings → EPG, the playlist dialog)
* may be local. URLs harvested from an M3U header are filtered to remote ones
* before they are stored, so a downloaded playlist can never point the EPG
* importer at a file on the user's disk.
*/
export type EpgSourceReferenceKind = 'remote' | 'local';
export const EPG_SOURCE_REFERENCE_ERROR = 'epgSourceReference';
const REMOTE_EPG_SOURCE_PATTERN = /^https?:\/\/[^\s"]+$/i;
const FILE_URL_EPG_SOURCE_PATTERN = /^file:\/\/[^\s"]+$/i;
const POSIX_ABSOLUTE_PATH_PATTERN = /^\/[^\0]+$/;
const WINDOWS_DRIVE_PATH_PATTERN = /^[a-z]:[\\/][^\0]*$/i;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\/\0]+\\[^\\/\0]+/;
export function classifyEpgSourceReference(
value: string | null | undefined
): EpgSourceReferenceKind | null {
const trimmed = value?.trim() ?? '';
if (trimmed === '') {
return null;
}
if (REMOTE_EPG_SOURCE_PATTERN.test(trimmed)) {
return 'remote';
}
if (
FILE_URL_EPG_SOURCE_PATTERN.test(trimmed) ||
POSIX_ABSOLUTE_PATH_PATTERN.test(trimmed) ||
WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) ||
WINDOWS_UNC_PATH_PATTERN.test(trimmed)
) {
return 'local';
}
return null;
}
export function isRemoteEpgSourceUrl(
value: string | null | undefined
): boolean {
return classifyEpgSourceReference(value) === 'remote';
}
export function isLocalEpgSourceReference(
value: string | null | undefined
): boolean {
return classifyEpgSourceReference(value) === 'local';
}
/**
* Form validator shared by the Settings EPG list and the playlist dialog.
* Typed structurally so this contracts library stays Angular-free; an empty
* control is valid, mirroring `Validators.pattern`.
*/
export function validateEpgSourceReferenceControl(control: {
value: unknown;
}): Record<typeof EPG_SOURCE_REFERENCE_ERROR, true> | null {
const value = control.value;
if (typeof value !== 'string' || value.trim() === '') {
return null;
}
return classifyEpgSourceReference(value)
? null
: { [EPG_SOURCE_REFERENCE_ERROR]: true };
}
@@ -140,6 +140,11 @@ export const WINDOW_TOGGLE_FULLSCREEN = 'WINDOW:TOGGLE_FULLSCREEN';
export const WINDOW_CLOSE = 'WINDOW:CLOSE';
export const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
export const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
// Synchronous preload request for the persisted app zoom level (answered
// with `event.returnValue`), and the preload's acknowledgement once it has
// applied that level with webFrame.setZoomLevel at DOMContentLoaded
export const WINDOW_GET_ZOOM_LEVEL = 'WINDOW:GET_ZOOM_LEVEL';
export const WINDOW_ZOOM_LEVEL_APPLIED = 'WINDOW:ZOOM_LEVEL_APPLIED';
// Close guard: while active, closing/quitting the app is intercepted in the
// main process and handed to the renderer for a save/discard/stay decision
@@ -0,0 +1,92 @@
import { buildPlaylistRecentItems } from './playlist-recently-viewed.utils';
import { resolvePortalActivityWatchKind } from './portal-activity-item.interface';
import type { PlaylistMeta } from './playlist-meta.type';
const labels = { stalker: 'Stalker', m3u: 'M3U' };
function stalkerPlaylist(recentlyViewed: unknown[]): PlaylistMeta {
return {
_id: 'stalker-1',
title: 'Portal',
count: 0,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
macAddress: '00:1A:79:00:00:01',
recentlyViewed,
} as unknown as PlaylistMeta;
}
describe('buildPlaylistRecentItems (Stalker watch kind)', () => {
it('keeps embedded-VOD shows routing as movies while tracking them as series', () => {
// Real stored shape: no `is_series`, numeric VOD category, and the
// `series[]` episode array the portal answered with.
const [item] = buildPlaylistRecentItems(
[
stalkerPlaylist([
{
id: '17572',
title: 'Fake (10 episodes)',
category_id: '7',
series: [1, 2, 3],
added_at: '2026-09-19T16:13:50.000Z',
},
]),
],
labels
);
expect(item.type).toBe('movie');
expect(item.watch_kind).toBe('series');
expect(resolvePortalActivityWatchKind(item)).toBe('series');
});
it('marks lazy Ministra is_series rows as series-tracked too', () => {
const [item] = buildPlaylistRecentItems(
[
stalkerPlaylist([
{
id: '50001',
title: 'Flagged',
category_id: 'vod',
is_series: '1',
added_at: '2026-09-19T16:13:50.000Z',
},
]),
],
labels
);
expect(item.type).toBe('series');
expect(item.watch_kind).toBe('series');
});
it('leaves plain movies and live channels without a watch kind override', () => {
const items = buildPlaylistRecentItems(
[
stalkerPlaylist([
{
id: '900',
title: 'A Movie',
category_id: '7',
series: [],
added_at: '2026-09-19T16:13:50.000Z',
},
{
id: '712',
title: 'Channel',
category_id: 'itv',
added_at: '2026-09-19T14:49:22.000Z',
},
]),
],
labels
);
expect(items.map((item) => item.watch_kind)).toEqual([
undefined,
undefined,
]);
expect(resolvePortalActivityWatchKind(items[0])).toBe('movie');
expect(resolvePortalActivityWatchKind(items[1])).toBeNull();
});
});
@@ -6,6 +6,7 @@ import {
extractStalkerItemPoster,
extractStalkerItemTitle,
extractStalkerItemType,
isStalkerSeriesItem,
normalizeStalkerDate,
} from './stalker-item.normalizer';
import {
@@ -59,6 +60,10 @@ function mapStalkerPlaylistRecentItems(
backdrop_url: extractStalkerItemTmdbHints(item).backdropUrl,
source: 'stalker',
stalker_item: rawItem,
// `type` stays the routing verdict: an embedded-VOD row
// (a `series[]` array, no flag) routes as a movie. Either
// shape tracks progress per episode, keyed by this parent id.
...(isStalkerSeriesItem(item) ? { watch_kind: 'series' } : {}),
});
return acc;
@@ -47,6 +47,43 @@ export interface PortalActivityItem {
source?: 'xtream' | 'stalker' | 'm3u';
/** Original stalker item for navigation state; undefined for xtream. */
stalker_item?: StalkerPortalItem;
/**
* How the item's watch progress is tracked, when that differs from the
* catalog section `type` routes it to.
*
* The case that needs it is a Stalker embedded-VOD row: its stored entry
* announces episodes through a `series[]` array, but `extractStalkerItemType`
* is deliberately blind to that array so the item keeps routing to the VOD
* catalog, and it therefore reports `type: 'movie'` while its progress is a
* set of EPISODE positions keyed by the parent id. (A lazy Ministra
* `is_series` row already reports `series` — the flag is read — so there
* this field only restates the type.)
*
* Readers that decide between a movie position and a series position go
* through `resolvePortalActivityWatchKind`, never `type` alone. Absent
* means "same as `type`".
*/
watch_kind?: PortalActivityWatchKind;
}
/** Progress model of a VOD activity item: one position, or per-episode positions. */
export type PortalActivityWatchKind = 'movie' | 'series';
/**
* The progress model a dashboard/collection reader should use for an item:
* the explicit `watch_kind` when a mapper recorded one, else the catalog
* `type`. Live items have no progress and resolve to `null`.
*/
export function resolvePortalActivityWatchKind(
item: Pick<PortalActivityItem, 'type' | 'watch_kind'>
): PortalActivityWatchKind | null {
if (item.watch_kind === 'movie' || item.watch_kind === 'series') {
return item.watch_kind;
}
if (item.type === 'movie' || item.type === 'series') {
return item.type;
}
return null;
}
/** A recently-viewed item with a `viewed_at` timestamp. */
@@ -0,0 +1,85 @@
import {
RENDERER_RESTORE_ROUTE_QUERY_PARAM,
resolveRestoredRendererRoute,
} from './renderer-reload-route.util';
const rendererRoot = 'file:///Applications/IPTVnator.app/Contents/web/';
const packagedIndex = `${rendererRoot}index.html`;
function reloadedIndex(route: string): string {
const url = new URL(packagedIndex);
url.searchParams.set(RENDERER_RESTORE_ROUTE_QUERY_PARAM, route);
return url.href;
}
describe('resolveRestoredRendererRoute', () => {
it('leaves a document without a restore request alone', () => {
expect(resolveRestoredRendererRoute(packagedIndex, packagedIndex)).toBe(
null
);
expect(
resolveRestoredRendererRoute(
`${packagedIndex}?other=1`,
packagedIndex
)
).toBe(null);
});
it('restores a route relative to the renderer directory', () => {
expect(
resolveRestoredRendererRoute(
reloadedIndex('workspace/sources'),
packagedIndex
)
).toBe(`${rendererRoot}workspace/sources`);
});
it('keeps the restored route query and fragment', () => {
expect(
resolveRestoredRendererRoute(
reloadedIndex('workspace/xtreams/3/search?q=dune#top'),
packagedIndex
)
).toBe(`${rendererRoot}workspace/xtreams/3/search?q=dune#top`);
});
it('resolves against the base URI, not the document URL', () => {
// The packaged <base href="./"> resolves to the renderer directory
// even when the document itself sits deeper.
expect(
resolveRestoredRendererRoute(
`${rendererRoot}workspace/sources?${RENDERER_RESTORE_ROUTE_QUERY_PARAM}=workspace%2Fdashboard`,
rendererRoot
)
).toBe(`${rendererRoot}workspace/dashboard`);
});
it('works for an http origin with a root base href', () => {
expect(
resolveRestoredRendererRoute(
`http://localhost:4200/?${RENDERER_RESTORE_ROUTE_QUERY_PARAM}=workspace%2Fsettings%2Fplayback`,
'http://localhost:4200/'
)
).toBe('http://localhost:4200/workspace/settings/playback');
});
it.each([
['an absolute URL', 'https://example.com/phish'],
['another scheme', 'javascript:alert(1)'],
['a directory escape', '../../etc/passwd'],
['a root-absolute path outside the renderer', '/etc/passwd'],
['a scheme-relative URL', '//example.com/'],
['the renderer directory itself', './'],
['an empty route', ''],
])('drops %s and only removes the parameter', (_label, route) => {
expect(
resolveRestoredRendererRoute(reloadedIndex(route), packagedIndex)
).toBe(packagedIndex);
});
it('returns null for an unparsable document URL', () => {
expect(resolveRestoredRendererRoute('not a url', packagedIndex)).toBe(
null
);
});
});
@@ -0,0 +1,72 @@
/**
* Route hand-off for a reloaded packaged renderer.
*
* The packaged renderer is loaded from `dist/apps/web/index.html` over
* `file://` and Angular uses PATH routing, so after in-app navigation the
* document URL is `file:///…/web/workspace/sources` — a path with no file
* behind it. A reload (the macOS View › Reload menu, DevTools, the settings
* unsaved-changes guard's confirmed reload) therefore fails with
* `ERR_FILE_NOT_FOUND` and strands the window on Chromium's error page.
*
* The Electron main process recovers such a failure by loading `index.html`
* again with the failed URL's route (its path relative to the renderer root,
* plus query and fragment) carried in this query parameter. The renderer
* consumes it before Angular bootstraps: `resolveRestoredRendererRoute`
* turns the current document URL into the in-app URL the router should
* start from, and `main.ts` installs it with `history.replaceState`, so the
* router's initial navigation lands on the route the user was on.
*/
/** Query parameter carrying the route to restore on the reloaded index. */
export const RENDERER_RESTORE_ROUTE_QUERY_PARAM = 'restoreRoute';
/**
* The URL to present instead of `currentHref` before the router's initial
* navigation, or `null` when the document carries no restore request.
*
* `baseUri` is `document.baseURI`: the packaged build's `<base href="./">`
* resolves to the renderer's directory, which is also what Angular strips
* from `location.pathname` to obtain the route. A route that would leave
* that directory — an absolute URL, another scheme, a `..` escape — is
* dropped and only the parameter is removed, so the app boots at its
* default route rather than following an arbitrary target.
*/
export function resolveRestoredRendererRoute(
currentHref: string,
baseUri: string
): string | null {
let current: URL;
let baseDirectory: URL;
try {
current = new URL(currentHref);
baseDirectory = new URL('./', baseUri);
} catch {
return null;
}
const route = current.searchParams.get(RENDERER_RESTORE_ROUTE_QUERY_PARAM);
if (route === null) {
return null;
}
current.searchParams.delete(RENDERER_RESTORE_ROUTE_QUERY_PARAM);
const stripped = current.href;
let target: URL;
try {
target = new URL(route, baseDirectory);
} catch {
return stripped;
}
const staysInsideRenderer =
target.protocol === baseDirectory.protocol &&
target.host === baseDirectory.host &&
target.pathname.startsWith(baseDirectory.pathname) &&
target.pathname !== baseDirectory.pathname;
return staysInsideRenderer ? target.href : stripped;
}
@@ -0,0 +1,58 @@
import { foldSearchText } from './search-text-fold.util';
describe('foldSearchText', () => {
it('folds the Turkish dotted capital I onto a plain lower-case i (issue #609)', () => {
expect(foldSearchText('İnşaat Kanalı')).toBe('inşaat kanalı');
expect(
foldSearchText('İnşaat Kanalı').includes(foldSearchText('inş'))
).toBe(true);
expect(foldSearchText('İnş')).toBe(foldSearchText('inş'));
});
it.each([
['Ünlü Şef', 'ünlü şef'],
['ÇANAKKALE', 'çanakkale'],
['Первый канал HD', 'первый канал hd'],
['Ёжик', 'ёжик'],
['Йога для всех', 'йога для всех'],
['Ελλάδα Σήμερα', 'ελλάδα σήμερα'],
['Amélie', 'amélie'],
['Straße', 'straße'],
])(
'keeps precomposed letters of other scripts intact: %s',
(input, expected) => {
expect(foldSearchText(input)).toBe(expected);
}
);
it.each([
['Amélie', 'Amélie'],
['İnşaat', 'İnşaat'],
['Ёжик', 'Ёжик'],
['Йога', 'Йога'],
['Ünlü', 'Ünlü'],
['Ά', 'Ά'],
])(
'folds canonically equivalent spellings of %s to one string',
(precomposed, decomposed) => {
expect(foldSearchText(decomposed)).toBe(
foldSearchText(precomposed)
);
}
);
it('stays accent-sensitive, unlike the diacritic-folding SQL index', () => {
// The renderer filters were accent-sensitive before this helper and
// stay so: only marks that cannot compose are dropped. Accent-blind
// matching is the FTS index's own behaviour, not this fold's.
expect(foldSearchText('Amélie')).not.toBe(foldSearchText('Amelie'));
});
it('does not fold the dotless ı onto i', () => {
// The Turkish I/ı pair is a separate letter, not a case form of i;
// it is deliberately left alone (see the FTS index, which does not
// fold it either).
expect(foldSearchText('Işık')).toBe('işık');
expect(foldSearchText('ışık')).toBe('ışık');
});
});
@@ -0,0 +1,35 @@
/**
* Locale-invariant case fold for in-memory search filters (channel lists,
* catalog and category filters, source and download lists). Pure function,
* no Angular/Node dependencies.
*
* `toLowerCase()` alone is not enough for one visible input: the Turkish
* dotted capital "İ" (U+0130) lower-cases to "i" plus a combining dot above
* (U+0307), so `"İnşaat".toLowerCase().includes("inş")` is `false` even
* though the two spellings are the same word (issue #609).
*
* The fold therefore runs `toLowerCase()`, re-composes the result to NFC and
* drops whatever combining marks are left. Composing first is what makes the
* fold agree on canonically equivalent text: a provider title stored
* decomposed ("e" + U+0301) and a query typed precomposed ("é") reach the
* same string, and a Greek "Ά" lower-cases to a form NFC maps onto the same
* "ά" the user types. Only marks that cannot compose survive to be stripped —
* the dotted I's leftover dot among them — so precomposed letters (ç, ş, ü,
* é, ё, й) are preserved and the fold stays accent-sensitive, exactly as the
* filters were before. A lower-cased string of printable ASCII is already NFC
* and carries no marks, which is the fast path taken by most titles.
*
* `toLocaleLowerCase()` is deliberately not used: under a Turkish or Azeri
* OS locale it maps ASCII "I" to the dotless "ı", so the same list would
* filter differently per machine.
*/
const COMBINING_MARKS_REGEXP = /[̀-ͯ]/g;
const NON_PRINTABLE_ASCII_REGEXP = /[^ -~]/;
export function foldSearchText(value: string): string {
const lowered = value.toLowerCase();
return NON_PRINTABLE_ASCII_REGEXP.test(lowered)
? lowered.normalize('NFC').replace(COMBINING_MARKS_REGEXP, '')
: lowered;
}
@@ -1,3 +1,4 @@
import type { AppUpdateChannel } from './app-update-channel.util';
import { Language } from './language.enum';
import { StreamFormat } from './stream-format.enum';
import { Theme } from './theme.enum';
@@ -189,6 +190,15 @@ export interface Settings {
* value means `'normal'`. F11 leaves or re-enters fullscreen.
*/
startupWindowMode?: StartupWindowMode;
/**
* Desktop update channel: `stable` (tagged releases) or `nightly`
* (prereleases published from every master merge). Electron only;
* a missing value means `stable`. Mirrored into the main-process config
* on Save so the startup update check already uses it. Forward-only:
* switching back to stable keeps the installed nightly until a newer
* stable release exists.
*/
updateChannel?: AppUpdateChannel;
/** Show the desktop footer bar for external playback status */
showExternalPlaybackBar?: boolean;
/** Strip country/group prefixes like "US | " or "UK - " from channel names */
@@ -241,6 +251,15 @@ export interface Settings {
portalConnectivityGuard?: boolean;
/** Cover/poster sizing preset applied across grids and rails */
coverSize?: CoverSize;
/**
* Titles under movie/series covers. Default on; only an explicit false
* switches VOD, series, favorites and recent grids to a posters-only
* wall whose title appears as an overlay on hover/focus. Live channel
* grids, search results and the dashboard rails always keep their
* labels: logos are often missing, a search answers by name, and rails
* carry meta rows the overlay cannot hold.
*/
showCoverTitles?: boolean;
/** Live EPG panel layout: horizontal timeline (default) or vertical list */
epgViewMode?: EpgViewMode;
/**
@@ -1,4 +1,5 @@
import {
cleanTitleForSearch,
normalizeTitle,
normalizeTitleKeys,
titleYearsCompatible,
@@ -429,3 +430,81 @@ describe('titleYearsCompatible', () => {
expect(titleYearsCompatible(1982, 2049)).toBe(false);
});
});
describe('cleanTitleForSearch', () => {
it('keeps Cyrillic letters that folding would rewrite', () => {
// NFD splits "й" into "и" + a breve and "ё" into "е" + a diaeresis;
// the comparison key drops both marks, and TMDB answers that folded
// spelling with nothing. The titles here are illustrative stand-ins
// chosen to fold the same way.
expect(normalizeTitle('Лейка (10 серий)')).toBe('леика');
expect(cleanTitleForSearch('Лейка (10 серий)')).toBe('Лейка');
expect(cleanTitleForSearch('Ёжик 2010')).toBe('Ёжик');
expect(cleanTitleForSearch('Тестовый Сериал s02')).toBe(
'Тестовый Сериал'
);
});
it('keeps Arabic hamza forms that folding splits into two words', () => {
// "أ" decomposes into a bare alef + U+0654, which is outside the
// stripped mark range and so becomes a SPACE in the key — splitting
// the word in two, which is not a spelling TMDB indexes.
expect(normalizeTitle('AR| أمثلة تجريبية')).toBe('ا مثلة تجريبية');
expect(cleanTitleForSearch('AR| أمثلة تجريبية')).toBe('أمثلة تجريبية');
});
it('keeps Latin diacritics and casing', () => {
expect(cleanTitleForSearch('Amélie (2001)')).toBe('Amélie');
expect(cleanTitleForSearch('THE LAST OF US')).toBe('THE LAST OF US');
});
it('recomposes decomposed input and keeps marks attached to their letter', () => {
// Providers ship "Dönüş" as "o" + U+0308; the key folds it away but
// the query must not split the word on the combining mark.
expect(
cleanTitleForSearch(
'1942\u2032ye Do\u0308nu\u0308s\u0327 (2012) TR'
)
).toBe('1942 ye Dönüş');
expect(
normalizeTitle('1942\u2032ye Do\u0308nu\u0308s\u0327 (2012) TR')
).toBe('1942 ye donus');
// No precomposed form exists for "ọ̀": the mark stays on the letter
expect(cleanTitleForSearch('AR - Ìjọ̀gbọ̀n (2023)')).toBe('Ìjọ̀gbọ̀n');
expect(normalizeTitle('AR - Ìjọ̀gbọ̀n (2023)')).toBe('ijogbon');
});
it('applies the same tag, bracket, season and year stripping as the key', () => {
expect(cleanTitleForSearch('EN - The Matrix (1999) 4K')).toBe(
'The Matrix'
);
expect(cleanTitleForSearch('|DE| Breaking Bad-DE')).toBe(
'Breaking Bad'
);
expect(cleanTitleForSearch('The Boys s05')).toBe('The Boys');
expect(cleanTitleForSearch('Пацаны 2 сезон')).toBe('Пацаны');
expect(cleanTitleForSearch('The Matrix 1999')).toBe('The Matrix');
// A leading token that is the film's own name survives on both tiers
expect(cleanTitleForSearch('AKA - 2023')).toBe('AKA');
expect(normalizeTitle('AKA - 2023')).toBe('aka');
});
it('normalizes to the same key as the text it was derived from', () => {
for (const raw of [
'Лейка (10 серий)',
'EN - The Matrix (1999) 4K',
'Ёжик 2010',
'Amélie (2001)',
'|FR|VO|Le dernier empereur',
]) {
expect(normalizeTitle(cleanTitleForSearch(raw))).toBe(
normalizeTitle(raw)
);
}
});
it('returns an empty string for empty input', () => {
expect(cleanTitleForSearch('')).toBe('');
expect(cleanTitleForSearch(null)).toBe('');
});
});
@@ -302,13 +302,13 @@ const HAS_LETTER = /\p{L}/u;
* "2023" and were offered to each other as alternative sources. A miss beats
* a wrong match, so an unknown token keeps its title.
*/
function normalizeAfterLeadingTag(value: string): string {
function normalizeAfterLeadingTag(value: string, fold: boolean): string {
const match = value.match(LANGUAGE_PREFIX);
if (!match) {
return normalizeRest(value);
return normalizeRest(value, fold);
}
const stripped = normalizeRest(value.replace(LANGUAGE_PREFIX, ''));
const stripped = normalizeRest(value.replace(LANGUAGE_PREFIX, ''), fold);
// Deliberately not `stripSeason`: its "never return empty" fallback would
// report a lone season marker as a surviving word.
if (HAS_LETTER.test(stripped.replace(SEASON_SUFFIX_PATTERN, ''))) {
@@ -316,7 +316,7 @@ function normalizeAfterLeadingTag(value: string): string {
}
const token = match[0].replace(PREFIX_SEPARATOR_TAIL, '');
return isKnownPrefixTag(token) ? stripped : normalizeRest(value);
return isKnownPrefixTag(token) ? stripped : normalizeRest(value, fold);
}
const DOUBLE_DASH_SUFFIX = /[-–]{2}[A-Za-z]{2,5}\s*$/;
@@ -438,28 +438,49 @@ const SEASON_SUFFIX_PATTERN = new RegExp(
* actually produce instead of predicting it. Cheap enough to run twice,
* because the second run only happens for a title whose stripped form came
* out with no word in it at all.
*
* `fold` selects the letter folding (diacritics, case, sigma). It is ON for
* every comparison key and OFF for the text sent to a remote search — see
* `cleanTitleForSearch`. Folding is lossy for scripts whose "diacritics" are
* distinct letters: NFD turns Cyrillic "й" into "и" + a combining breve, and
* dropping the breve rewrites "Лейка" as "леика", "ё" as "е". Two provider
* copies of a title still meet on that key, which is all a comparison needs,
* but TMDB's search does not fold Cyrillic the same way and answers a folded
* query with nothing at all.
*/
function normalizeRest(value: string): string {
return (
stripTrailingTags(value)
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
// Greek Σ has two lowercase forms and `toLowerCase` picks by
// position: "ΑΣ" becomes "ας" while an already-lowercase "ασ"
// stays medial, so the same word reaches this line spelled two
// ways. Both SQL tiers fold them together — SQLite's trigram
// tokenizer does it natively, and the scan's GLOB classes do it in
// `caseInsensitiveGlobPattern` — so without this the candidate is
// admitted by the query and then thrown away by the confirmation.
// Folding to the medial form is what Unicode case folding does.
.replace(/ς/g, 'σ')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.split(' ')
.filter((token) => token !== '' && !QUALITY_TAGS.has(token))
.join(' ')
.trim()
);
function normalizeRest(value: string, fold: boolean): string {
const withoutTrailingTags = stripTrailingTags(value);
const folded = fold
? withoutTrailingTags
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
// Greek Σ has two lowercase forms and `toLowerCase` picks by
// position: "ΑΣ" becomes "ας" while an already-lowercase "ασ"
// stays medial, so the same word reaches this line spelled two
// ways. Both SQL tiers fold them together — SQLite's trigram
// tokenizer does it natively, and the scan's GLOB classes do
// it in `caseInsensitiveGlobPattern` — so without this the
// candidate is admitted by the query and then thrown away by
// the confirmation. Folding to the medial form is what Unicode
// case folding does.
.replace(/ς/g, 'σ')
: // Providers ship some titles decomposed ("o" + U+0308 for "ö").
// Recompose so the query reads as the provider meant it, and
// keep any mark that has no precomposed form ("ọ̀") attached to
// its letter instead of letting the word-splitting step below
// turn it into a space inside the word.
withoutTrailingTags.normalize('NFC');
const nonWord = fold ? /[^\p{L}\p{N}]+/gu : /[^\p{L}\p{N}\p{M}]+/gu;
return folded
.replace(nonWord, ' ')
.split(' ')
.filter(
(token) => token !== '' && !QUALITY_TAGS.has(token.toLowerCase())
)
.join(' ')
.trim();
}
/**
@@ -488,6 +509,28 @@ export interface NormalizedTitleKeys {
export function normalizeTitleKeys(
raw: string | null | undefined
): NormalizedTitleKeys {
return buildTitleKeys(raw, true);
}
/**
* The title to SEND to a remote search such as TMDB: the same tag, bracket,
* season and year stripping as `normalizeTitle`, but with the letters left
* exactly as the provider wrote them — no diacritic folding, no lowercasing.
*
* Comparison keys must fold so two spellings of one film meet; a search
* query must not, because the search engine folds by its own rules and a
* pre-folded Cyrillic query matches nothing there ("леика" for "Лейка"
* illustrates the rewrite).
* Compare the results with `normalizeTitle`, never with this.
*/
export function cleanTitleForSearch(raw: string | null | undefined): string {
return buildTitleKeys(raw, false).base;
}
function buildTitleKeys(
raw: string | null | undefined,
fold: boolean
): NormalizedTitleKeys {
if (!raw) {
return { exact: '', base: '', trailingYear: null };
@@ -498,7 +541,8 @@ export function normalizeTitleKeys(
.replace(WRAPPED_TAG_PREFIX, '')
// Inner classes exclude the opening delimiter too, so runaway
// inputs like "[[[[[..." backtrack linearly (CodeQL js/polynomial-redos)
.replace(/\[[^\][]*\]|\([^()]*\)|\{[^{}]*\}/g, ' ')
.replace(/\[[^\][]*\]|\([^()]*\)|\{[^{}]*\}/g, ' '),
fold
);
const exact = stripSeason(cleaned);
@@ -17,6 +17,13 @@ export interface XtreamSerieDetails {
* the provider's `seasons[].overview` is empty or URL-only junk.
*/
tmdb_season_overviews?: Record<string, string>;
/**
* Populated by lazy TMDB season enrichment; absent in raw provider
* responses. Keyed like `tmdb_season_overviews`. Full image URLs of
* each season's own TMDB poster, the first choice for the season
* cover in the detail view and the fullscreen episode panel.
*/
tmdb_season_posters?: Record<string, string>;
}
export interface XtreamSerieInfo {
@@ -0,0 +1,60 @@
import {
clampZoomLevel,
stepZoomLevel,
ZOOM_LEVEL_MAX,
ZOOM_LEVEL_MIN,
ZOOM_LEVEL_STEP,
} from './zoom-level.util';
describe('zoom level shortcuts contract', () => {
it('steps by the Electron menu-role step in both directions', () => {
expect(stepZoomLevel(0, 'in')).toBe(ZOOM_LEVEL_STEP);
expect(stepZoomLevel(0, 'out')).toBe(-ZOOM_LEVEL_STEP);
expect(stepZoomLevel(1, 'in')).toBe(1.5);
expect(stepZoomLevel(-1.5, 'out')).toBe(-2);
});
it('resets to the default level from anywhere', () => {
expect(stepZoomLevel(3, 'reset')).toBe(0);
expect(stepZoomLevel(-2.5, 'reset')).toBe(0);
expect(stepZoomLevel(Number.NaN, 'reset')).toBe(0);
});
it('clamps at the app limits, not at Chromium limits', () => {
expect(stepZoomLevel(ZOOM_LEVEL_MAX, 'in')).toBe(ZOOM_LEVEL_MAX);
expect(stepZoomLevel(ZOOM_LEVEL_MIN, 'out')).toBe(ZOOM_LEVEL_MIN);
expect(stepZoomLevel(ZOOM_LEVEL_MAX - 0.5, 'in')).toBe(ZOOM_LEVEL_MAX);
expect(clampZoomLevel(ZOOM_LEVEL_MIN - 1)).toBe(ZOOM_LEVEL_MIN);
});
it('never moves a stored out-of-range level against the request', () => {
// The macOS menu roles never clamped and the store restores any
// finite level, so an upgrade can start beyond the limits.
expect(stepZoomLevel(ZOOM_LEVEL_MAX + 1, 'in')).toBe(
ZOOM_LEVEL_MAX + 1
);
expect(stepZoomLevel(ZOOM_LEVEL_MAX + 1, 'out')).toBe(ZOOM_LEVEL_MAX);
expect(stepZoomLevel(ZOOM_LEVEL_MAX + 0.3, 'out')).toBe(ZOOM_LEVEL_MAX);
expect(stepZoomLevel(ZOOM_LEVEL_MIN - 1, 'out')).toBe(
ZOOM_LEVEL_MIN - 1
);
expect(stepZoomLevel(ZOOM_LEVEL_MIN - 1, 'in')).toBe(ZOOM_LEVEL_MIN);
expect(stepZoomLevel(ZOOM_LEVEL_MIN - 1, 'reset')).toBe(0);
});
it('snaps an off-grid level to the next grid point in the requested direction', () => {
// A level the macOS menu roles or an older store left between steps.
expect(stepZoomLevel(0.3, 'in')).toBe(0.5);
expect(stepZoomLevel(0.3, 'out')).toBe(0);
expect(stepZoomLevel(-0.7, 'out')).toBe(-1);
expect(stepZoomLevel(-0.7, 'in')).toBe(-0.5);
});
it('treats a non-finite level as the default', () => {
expect(stepZoomLevel(Number.NaN, 'in')).toBe(ZOOM_LEVEL_STEP);
expect(stepZoomLevel(Number.POSITIVE_INFINITY, 'out')).toBe(
-ZOOM_LEVEL_STEP
);
expect(clampZoomLevel(Number.NaN)).toBe(0);
});
});
@@ -0,0 +1,66 @@
/**
* App zoom shortcut contract (Cmd/Ctrl and +/−/0), shared by the renderer
* key binding (`WorkspaceKeyboardShortcutsService`) and the preload, which
* applies the result through `webFrame.setZoomLevel` (issue #1109: the level
* must stay a frame-bound temporary zoom; see
* `docs/architecture/workspace-shell.md`, "Zoom level").
*
* A Chromium zoom level is the exponent of a 1.2 factor: level 1 renders at
* 120 %, level −1 at ~83 %. The step matches Electron's `zoomIn`/`zoomOut`
* menu roles (0.5, about 10 % per press), so the level the macOS menu used
* to produce and the level the shortcuts produce are the same series.
*/
export type ZoomLevelAction = 'in' | 'out' | 'reset';
export const ZOOM_LEVEL_DEFAULT = 0;
/** Electron's `zoomIn`/`zoomOut` role step. */
export const ZOOM_LEVEL_STEP = 0.5;
/** ≈48 % — below this the workspace chrome is unreadable. */
export const ZOOM_LEVEL_MIN = -4;
/** ≈299 % — well inside Chromium's own 25–500 % limits. */
export const ZOOM_LEVEL_MAX = 6;
export function clampZoomLevel(level: number): number {
if (!Number.isFinite(level)) {
return ZOOM_LEVEL_DEFAULT;
}
return Math.min(ZOOM_LEVEL_MAX, Math.max(ZOOM_LEVEL_MIN, level));
}
/**
* The level `action` produces from `current`. Steps are snapped to the step
* grid so a level restored from an older store (or set by the macOS menu
* before the shortcuts existed) does not carry a fractional offset forever.
* A level already outside the limits — the macOS menu roles never clamped,
* and the store restores any finite level — is never moved AGAINST the
* request: a press further out leaves it where it is, a press back in lands
* on the limit. A non-finite `current` is treated as the default.
*/
export function stepZoomLevel(
current: number,
action: ZoomLevelAction
): number {
if (action === 'reset') {
return ZOOM_LEVEL_DEFAULT;
}
const base = Number.isFinite(current) ? current : ZOOM_LEVEL_DEFAULT;
const direction = action === 'in' ? 1 : -1;
if (direction > 0 ? base >= ZOOM_LEVEL_MAX : base <= ZOOM_LEVEL_MIN) {
return base;
}
const steps = base / ZOOM_LEVEL_STEP;
// A level between grid points steps to the next grid point in the
// requested direction instead of skipping past it.
const nextSteps = Number.isInteger(steps)
? steps + direction
: direction > 0
? Math.ceil(steps)
: Math.floor(steps);
return clampZoomLevel(nextSteps * ZOOM_LEVEL_STEP);
}