mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
fix(stalker): tighten session protocol boundaries
This commit is contained in:
1 parent
f8a544d287
commit
92238ecbbb
13 files changed
+359
-78
No files matched your search
@@ -55,21 +55,6 @@ export const STALKER_PROFILE_RESULT_KINDS = {
|
||||
Failure: 'failure',
|
||||
} as const;
|
||||
|
||||
export const STALKER_APPLICATION_OPERATIONS = {
|
||||
CatalogCategories: 'get-categories',
|
||||
CatalogGenres: 'get-genres',
|
||||
CatalogItems: 'get-ordered-list',
|
||||
CatalogAllChannels: 'get-all-channels',
|
||||
CatalogSearch: 'search',
|
||||
SeriesSeasons: 'get-series-seasons',
|
||||
SeriesEpisodes: 'get-series-episodes',
|
||||
ShortEpg: 'get-short-epg',
|
||||
EpgInfo: 'get-epg-info',
|
||||
CreateLink: 'create-link',
|
||||
Favorites: 'favorites',
|
||||
MainInfo: 'get-main-info',
|
||||
} as const;
|
||||
|
||||
export const STALKER_RESERVED_REQUEST_PARAMETERS = [
|
||||
'action',
|
||||
'api_signature',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { StalkerSessionApplicationOperation } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
STALKER_APPLICATION_OPERATIONS,
|
||||
STALKER_CONNECTION_STAGES,
|
||||
STALKER_FAILURE_REASONS,
|
||||
STALKER_PROFILE_RESULT_KINDS,
|
||||
@@ -19,7 +19,7 @@ export type StalkerProfileResultKind =
|
||||
(typeof STALKER_PROFILE_RESULT_KINDS)[keyof typeof STALKER_PROFILE_RESULT_KINDS];
|
||||
|
||||
export type StalkerApplicationOperation =
|
||||
(typeof STALKER_APPLICATION_OPERATIONS)[keyof typeof STALKER_APPLICATION_OPERATIONS];
|
||||
StalkerSessionApplicationOperation;
|
||||
|
||||
export interface StalkerNormalizedProfileReady {
|
||||
kind: typeof STALKER_PROFILE_RESULT_KINDS.Ready;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { STALKER_SESSION_APPLICATION_OPERATIONS } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
STALKER_APPLICATION_OPERATIONS,
|
||||
validateStalkerApplicationRequest,
|
||||
} from './stalker-request-policy';
|
||||
|
||||
describe('Stalker application request policy', () => {
|
||||
it('uses the shared operation allowlist as its single source of truth', () => {
|
||||
expect(STALKER_APPLICATION_OPERATIONS).toBe(
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['handshake', 'get_profile', 'do_auth'])(
|
||||
'rejects the raw auth action %s',
|
||||
(operation) => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { STALKER_SESSION_APPLICATION_OPERATIONS } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
STALKER_APPLICATION_OPERATIONS,
|
||||
STALKER_MANAGED_REQUEST_HEADERS,
|
||||
STALKER_RESERVED_REQUEST_PARAMETERS,
|
||||
} from './stalker-protocol.constants';
|
||||
|
||||
export { STALKER_APPLICATION_OPERATIONS };
|
||||
export const STALKER_APPLICATION_OPERATIONS =
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS;
|
||||
|
||||
interface StalkerApplicationRequestInput {
|
||||
operation: string;
|
||||
|
||||
@@ -27,6 +27,21 @@ describe('Stalker response classifier', () => {
|
||||
).toMatchObject({ kind: 'ready', status: 'statusless' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ js: { error: 'Access denied', id: 7 } },
|
||||
{ js: { blocked: true, id: 7 } },
|
||||
{ js: { credentials_required: true, id: 7 } },
|
||||
{ error: 'Authorization failed', js: { id: 7 } },
|
||||
])(
|
||||
'rejects status-less profiles carrying failure indicators',
|
||||
(value) => {
|
||||
expect(classifyStalkerProfile(value)).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'incompatible-response',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ js: { id: 7, status: 3 } },
|
||||
{ js: { status: 'unknown' } },
|
||||
@@ -98,6 +113,34 @@ describe('Stalker response classifier', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('gives recognized 403 protection evidence precedence over token text', () => {
|
||||
expect(
|
||||
classifyStalkerResponseFailure({
|
||||
httpStatus: 403,
|
||||
rawBody: '<html>Cloudflare challenge</html>',
|
||||
value: { js: { error: 'Authorization failed' } },
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'portal-protection-blocked',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['Authorization failed.', 'Access denied.'])(
|
||||
'rejects the noncanonical error variant %s',
|
||||
(error) => {
|
||||
expect(
|
||||
classifyStalkerResponseFailure({
|
||||
httpStatus: 200,
|
||||
value: { js: { error } },
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'incompatible-response',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['application/json', 'text/json; charset=utf-8'])(
|
||||
'accepts JSON for %s',
|
||||
(contentType) => {
|
||||
|
||||
@@ -106,7 +106,7 @@ export function classifyStalkerProfile(
|
||||
if (
|
||||
profile['status'] === undefined &&
|
||||
hasRecognizedStatuslessProfileField(profile) &&
|
||||
typeof profile['error'] !== 'string'
|
||||
!hasProfileFailureIndicator(envelope ?? {}, profile)
|
||||
) {
|
||||
return {
|
||||
kind: STALKER_PROFILE_RESULT_KINDS.Ready,
|
||||
@@ -140,11 +140,14 @@ export function classifyStalkerResponseFailure(
|
||||
| { kind: 'none' }
|
||||
| { kind: 'token-rejected' }
|
||||
| StalkerClassifierFailure {
|
||||
const bodyError = extractExactBodyError(input.value, input.rawBody);
|
||||
if (
|
||||
input.httpStatus === 401 ||
|
||||
bodyError === 'Authorization failed'
|
||||
) {
|
||||
const bodyError = extractBodyError(input.value, input.rawBody);
|
||||
if (input.httpStatus === 403 && looksLikePortalProtection(input.rawBody)) {
|
||||
return {
|
||||
kind: 'failure',
|
||||
reason: STALKER_FAILURE_REASONS.PortalProtectionBlocked,
|
||||
};
|
||||
}
|
||||
if (input.httpStatus === 401 || bodyError === 'Authorization failed') {
|
||||
return { kind: 'token-rejected' };
|
||||
}
|
||||
if (bodyError === 'Access denied') {
|
||||
@@ -153,13 +156,7 @@ export function classifyStalkerResponseFailure(
|
||||
reason: STALKER_FAILURE_REASONS.AccountAccessDenied,
|
||||
};
|
||||
}
|
||||
if (input.httpStatus === 403) {
|
||||
if (looksLikePortalProtection(input.rawBody)) {
|
||||
return {
|
||||
kind: 'failure',
|
||||
reason: STALKER_FAILURE_REASONS.PortalProtectionBlocked,
|
||||
};
|
||||
}
|
||||
if (bodyError !== undefined || input.httpStatus === 403) {
|
||||
return incompatibleResponse();
|
||||
}
|
||||
if (input.httpStatus === 429) {
|
||||
@@ -199,6 +196,48 @@ function hasRecognizedStatuslessProfileField(
|
||||
].some((field) => profile[field] !== undefined);
|
||||
}
|
||||
|
||||
function hasProfileFailureIndicator(
|
||||
envelope: Readonly<Record<string, unknown>>,
|
||||
profile: Readonly<Record<string, unknown>>
|
||||
): boolean {
|
||||
const errorFields = ['error', 'errors', 'error_message', 'error_msg'];
|
||||
const blockFields = [
|
||||
'blocked',
|
||||
'is_blocked',
|
||||
'account_blocked',
|
||||
'disabled',
|
||||
'banned',
|
||||
];
|
||||
const credentialFields = [
|
||||
'credentials_required',
|
||||
'credential_required',
|
||||
'login_required',
|
||||
'auth_required',
|
||||
'authentication_required',
|
||||
'need_auth',
|
||||
'requires_auth',
|
||||
];
|
||||
return [...errorFields, ...blockFields, ...credentialFields].some(
|
||||
(field) =>
|
||||
isFailureIndicator(envelope[field]) ||
|
||||
isFailureIndicator(profile[field])
|
||||
);
|
||||
}
|
||||
|
||||
function isFailureIndicator(value: unknown): boolean {
|
||||
if (value === true) return true;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
if (typeof value !== 'string') return false;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return (
|
||||
normalized !== '' &&
|
||||
normalized !== '0' &&
|
||||
normalized !== 'false' &&
|
||||
normalized !== 'none' &&
|
||||
normalized !== 'ok'
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMediaType(
|
||||
contentType: string | undefined
|
||||
): string | undefined | 'invalid' {
|
||||
@@ -256,10 +295,10 @@ function asRecord(value: unknown): Readonly<Record<string, unknown>> | undefined
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function extractExactBodyError(
|
||||
function extractBodyError(
|
||||
value: unknown,
|
||||
rawBody: string | undefined
|
||||
): 'Access denied' | 'Authorization failed' | undefined {
|
||||
): string | undefined {
|
||||
const envelope = asRecord(value);
|
||||
const js = asRecord(envelope?.['js']);
|
||||
const candidate =
|
||||
@@ -268,13 +307,7 @@ function extractExactBodyError(
|
||||
: typeof envelope?.['error'] === 'string'
|
||||
? envelope['error']
|
||||
: rawBody;
|
||||
if (candidate === 'Authorization failed' || candidate === 'Authorization failed.') {
|
||||
return 'Authorization failed';
|
||||
}
|
||||
if (candidate === 'Access denied' || candidate === 'Access denied.') {
|
||||
return 'Access denied';
|
||||
}
|
||||
return undefined;
|
||||
return typeof candidate === 'string' ? candidate : undefined;
|
||||
}
|
||||
|
||||
function looksLikePortalProtection(body: string | undefined): boolean {
|
||||
|
||||
@@ -91,10 +91,15 @@ describe('Stalker endpoint URL recipes', () => {
|
||||
|
||||
it.each([
|
||||
'https://user:password@portal.test/c/',
|
||||
'https://@portal.test/c/',
|
||||
'https://portal.test/c/?username=account',
|
||||
'https://portal.test/c/?PASSWORD=secret',
|
||||
'https://portal.test/c/?token=secret',
|
||||
'https://portal.test/c/?mac=00%3A1A%3A79%3A00%3A00%3A01',
|
||||
'https://portal.test/c/?sn=serial',
|
||||
'https://portal.test/c/?auth_second_step=1',
|
||||
'https://portal.test/c/?random=handshake-random',
|
||||
'https://portal.test/c/?cookie=session-cookie',
|
||||
'ftp://portal.test/c/',
|
||||
])('rejects credential-bearing or unsupported URLs', (sourceUrl) => {
|
||||
expect(() => normalizeStalkerSourceUrl(sourceUrl)).toThrow(
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { STALKER_ENDPOINT_CANDIDATE_LIMIT } from './stalker-protocol.constants';
|
||||
|
||||
const CREDENTIAL_QUERY_KEYS = new Set([
|
||||
'access_token',
|
||||
'api_signature',
|
||||
'auth',
|
||||
'auth_second_step',
|
||||
'authorization',
|
||||
'bearer',
|
||||
'cookie',
|
||||
'credentials',
|
||||
'device_id',
|
||||
'device_id1',
|
||||
'device_id2',
|
||||
'login',
|
||||
'mac',
|
||||
'not_valid_token',
|
||||
'password',
|
||||
'prehash',
|
||||
'random',
|
||||
'refresh_token',
|
||||
'serial',
|
||||
'serial_number',
|
||||
'session',
|
||||
'session_id',
|
||||
'signature',
|
||||
'signature2',
|
||||
'sn',
|
||||
'token',
|
||||
'username',
|
||||
]);
|
||||
@@ -33,6 +47,7 @@ export function normalizeStalkerSourceUrl(sourceUrl: string): string {
|
||||
}
|
||||
|
||||
if (
|
||||
hasAuthorityUserInfo(sourceUrl) ||
|
||||
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
@@ -45,6 +60,23 @@ export function normalizeStalkerSourceUrl(sourceUrl: string): string {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function hasAuthorityUserInfo(sourceUrl: string): boolean {
|
||||
const trimmed = sourceUrl.trim();
|
||||
const schemeSeparator = trimmed.indexOf('://');
|
||||
if (schemeSeparator === -1) {
|
||||
return false;
|
||||
}
|
||||
const authorityStart = schemeSeparator + 3;
|
||||
const authorityEndOffset = trimmed
|
||||
.slice(authorityStart)
|
||||
.search(/[/?#]/);
|
||||
const authorityEnd =
|
||||
authorityEndOffset === -1
|
||||
? trimmed.length
|
||||
: authorityStart + authorityEndOffset;
|
||||
return trimmed.slice(authorityStart, authorityEnd).includes('@');
|
||||
}
|
||||
|
||||
export function deriveStalkerEndpointCandidates(
|
||||
sourceUrl: string
|
||||
): readonly string[] {
|
||||
|
||||
@@ -35,6 +35,7 @@ import { CatalogTitleMatch } from './catalog-title-match.interface';
|
||||
import { Settings } from './settings.interface';
|
||||
import {
|
||||
StalkerSessionConnectionOutcome,
|
||||
StalkerSessionApplicationOperation,
|
||||
StalkerSessionContinueRequest,
|
||||
StalkerSessionControlOutcome,
|
||||
StalkerSessionControlRequest,
|
||||
@@ -677,9 +678,11 @@ export interface ElectronBridgeApi {
|
||||
stalkerSessionContinue?: (
|
||||
request: StalkerSessionContinueRequest
|
||||
) => Promise<StalkerSessionConnectionOutcome>;
|
||||
stalkerSessionRequest?: (
|
||||
request: StalkerSessionRequest
|
||||
) => Promise<StalkerSessionRequestOutcome>;
|
||||
stalkerSessionRequest?: <
|
||||
Operation extends StalkerSessionApplicationOperation,
|
||||
>(
|
||||
request: StalkerSessionRequest<Operation>
|
||||
) => Promise<StalkerSessionRequestOutcome<Operation>>;
|
||||
stalkerSessionControl?: (
|
||||
request: StalkerSessionControlRequest
|
||||
) => Promise<StalkerSessionControlOutcome>;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type IpcCommandRequest,
|
||||
type IpcCommandResponse,
|
||||
} from './ipc-command.class';
|
||||
import type { ElectronBridgeApi } from './electron-api.interface';
|
||||
import {
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS,
|
||||
type StalkerSessionApplicationOperation,
|
||||
@@ -70,6 +71,7 @@ describe('typed IPC commands', () => {
|
||||
authenticatedSession: true,
|
||||
playbackContext: true,
|
||||
},
|
||||
connectionMode: 'provisional',
|
||||
endpoint: 'https://portal.test/server/load.php',
|
||||
kind: 'ready',
|
||||
landingUrl: 'https://portal.test/c/',
|
||||
@@ -81,6 +83,7 @@ describe('typed IPC commands', () => {
|
||||
stalkerRecipeClassifierVersion: 1,
|
||||
stalkerRequestRecipe: 'full-session',
|
||||
},
|
||||
provisionalReason: 'import',
|
||||
recipe: 'full-session',
|
||||
requestId: 'request-id',
|
||||
};
|
||||
@@ -94,7 +97,8 @@ describe('typed IPC commands', () => {
|
||||
leaseRef: 'opaque-lease',
|
||||
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
|
||||
parameters: { category: '10', page: 1 },
|
||||
requestId: 'request-id',
|
||||
// @ts-expect-error Request IDs are generated by Electron main.
|
||||
requestId: 'renderer-request-id',
|
||||
} satisfies StalkerSessionRequest;
|
||||
const response: IpcCommandResponse<typeof STALKER_SESSION_REQUEST> = {
|
||||
kind: 'success',
|
||||
@@ -107,6 +111,43 @@ describe('typed IPC commands', () => {
|
||||
expect(response.kind).toBe('success');
|
||||
});
|
||||
|
||||
it('preserves operation correlation through IPC and Electron API aliases', () => {
|
||||
type CatalogOperation =
|
||||
typeof STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems;
|
||||
const request: IpcCommandRequest<
|
||||
typeof STALKER_SESSION_REQUEST,
|
||||
CatalogOperation
|
||||
> = {
|
||||
leaseRef: 'opaque-lease',
|
||||
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
|
||||
parameters: { page: 1 },
|
||||
};
|
||||
const response: IpcCommandResponse<
|
||||
typeof STALKER_SESSION_REQUEST,
|
||||
CatalogOperation
|
||||
> = {
|
||||
kind: 'success',
|
||||
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
|
||||
payload: { items: [] },
|
||||
requestId: 'main-request-id',
|
||||
};
|
||||
|
||||
const assertApi = (api: ElectronBridgeApi) => {
|
||||
const outcome = api.stalkerSessionRequest?.(request);
|
||||
outcome?.then((value) => {
|
||||
if (value.kind === 'success') {
|
||||
expect(value.payload.items).toEqual([]);
|
||||
// @ts-expect-error Catalog results never expose streamUrl.
|
||||
void value.payload.streamUrl;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
expect(request.operation).toBe('get-ordered-list');
|
||||
expect(response.kind).toBe('success');
|
||||
expect(assertApi).toBeDefined();
|
||||
});
|
||||
|
||||
it('correlates each operation with its request and sanitized result', () => {
|
||||
type MissingOperations = Exclude<
|
||||
StalkerSessionApplicationOperation,
|
||||
@@ -146,7 +187,8 @@ describe('typed IPC commands', () => {
|
||||
const request: IpcCommandRequest<typeof STALKER_SESSION_CONTROL> = {
|
||||
action: 'commit',
|
||||
attemptRef: 'opaque-attempt',
|
||||
requestId: 'request-id',
|
||||
// @ts-expect-error Request IDs are generated by Electron main.
|
||||
requestId: 'renderer-request-id',
|
||||
} satisfies StalkerSessionControlRequest;
|
||||
const response: IpcCommandResponse<typeof STALKER_SESSION_CONTROL> = {
|
||||
action: 'commit',
|
||||
@@ -158,6 +200,82 @@ describe('typed IPC commands', () => {
|
||||
expect(response.kind).toBe('success');
|
||||
});
|
||||
|
||||
it('discriminates persisted and provisional connection descriptors', () => {
|
||||
const persistedWithReason: StalkerSessionOpenRequest = {
|
||||
descriptor: {
|
||||
connectionMode: 'persisted-open',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
playlistRef: 'playlist-ref',
|
||||
profilePreset: {
|
||||
id: 'mag250-public-5_1-minimal-v1',
|
||||
version: 1,
|
||||
},
|
||||
// @ts-expect-error Persisted opens never carry a provisional reason.
|
||||
provisionalReason: 'edit',
|
||||
sourceUrl: 'https://portal.test/c/',
|
||||
},
|
||||
};
|
||||
const provisionalWithoutReason: StalkerSessionOpenRequest = {
|
||||
// @ts-expect-error Provisional opens require their reason.
|
||||
descriptor: {
|
||||
connectionMode: 'provisional',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
playlistRef: 'playlist-ref',
|
||||
profilePreset: {
|
||||
id: 'mag250-public-5_1-minimal-v1',
|
||||
version: 1,
|
||||
},
|
||||
sourceUrl: 'https://portal.test/c/',
|
||||
},
|
||||
};
|
||||
|
||||
expect(persistedWithReason.descriptor.connectionMode).toBe(
|
||||
'persisted-open'
|
||||
);
|
||||
expect(provisionalWithoutReason.descriptor.connectionMode).toBe(
|
||||
'provisional'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps playback context exclusive to a create-link result', () => {
|
||||
const catalogOutcome: StalkerSessionRequestOutcome = {
|
||||
kind: 'success',
|
||||
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
|
||||
payload: { items: [] },
|
||||
// @ts-expect-error Catalog success cannot carry playback context.
|
||||
playbackContextRef: 'opaque-context',
|
||||
requestId: 'main-request-id',
|
||||
};
|
||||
|
||||
expect(catalogOutcome.kind).toBe('success');
|
||||
});
|
||||
|
||||
it('excludes ready outcomes from application request results', () => {
|
||||
const requestOutcome: StalkerSessionRequestOutcome = {
|
||||
attemptRef: 'opaque-attempt',
|
||||
capabilities: {
|
||||
authenticatedSession: true,
|
||||
playbackContext: true,
|
||||
},
|
||||
endpoint: 'https://portal.test/server/load.php',
|
||||
// @ts-expect-error Ready belongs only to open/continue outcomes.
|
||||
kind: 'ready',
|
||||
landingUrl: 'https://portal.test/c/',
|
||||
leaseRef: 'opaque-lease',
|
||||
persistenceDraft: {
|
||||
isFullStalkerPortal: true,
|
||||
portalUrl: 'https://portal.test/server/load.php',
|
||||
stalkerLandingUrl: 'https://portal.test/c/',
|
||||
stalkerRecipeClassifierVersion: 1,
|
||||
stalkerRequestRecipe: 'full-session',
|
||||
},
|
||||
recipe: 'full-session',
|
||||
requestId: 'main-request-id',
|
||||
};
|
||||
|
||||
expect(requestOutcome.kind).toBe('ready');
|
||||
});
|
||||
|
||||
it('keeps secret runtime state out of result DTO examples', () => {
|
||||
const outcome: IpcCommandResponse<typeof STALKER_SESSION_OPEN> = {
|
||||
kind: 'failure',
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
StalkerSessionControlOutcome,
|
||||
StalkerSessionControlRequest,
|
||||
StalkerSessionOpenRequest,
|
||||
StalkerSessionApplicationOperation,
|
||||
StalkerSessionRequest,
|
||||
StalkerSessionRequestOutcome,
|
||||
} from './stalker-session.interface';
|
||||
@@ -34,16 +35,30 @@ export interface IpcCommandContractMap {
|
||||
}
|
||||
|
||||
export type IpcCommandId = keyof IpcCommandContractMap;
|
||||
export type IpcCommandRequest<Command extends IpcCommandId> =
|
||||
IpcCommandContractMap[Command]['request'];
|
||||
export type IpcCommandResponse<Command extends IpcCommandId> =
|
||||
IpcCommandContractMap[Command]['response'];
|
||||
export type IpcCommandRequest<
|
||||
Command extends IpcCommandId,
|
||||
Operation extends
|
||||
StalkerSessionApplicationOperation = StalkerSessionApplicationOperation,
|
||||
> = Command extends typeof STALKER_SESSION_REQUEST
|
||||
? StalkerSessionRequest<Operation>
|
||||
: IpcCommandContractMap[Command]['request'];
|
||||
export type IpcCommandResponse<
|
||||
Command extends IpcCommandId,
|
||||
Operation extends
|
||||
StalkerSessionApplicationOperation = StalkerSessionApplicationOperation,
|
||||
> = Command extends typeof STALKER_SESSION_REQUEST
|
||||
? StalkerSessionRequestOutcome<Operation>
|
||||
: IpcCommandContractMap[Command]['response'];
|
||||
|
||||
export class IpcCommand<Command extends IpcCommandId> {
|
||||
export class IpcCommand<
|
||||
Command extends IpcCommandId,
|
||||
Operation extends
|
||||
StalkerSessionApplicationOperation = StalkerSessionApplicationOperation,
|
||||
> {
|
||||
constructor(
|
||||
public readonly id: Command,
|
||||
public readonly callback: (
|
||||
payload: IpcCommandResponse<Command>
|
||||
payload: IpcCommandResponse<Command, Operation>
|
||||
) => void
|
||||
) {
|
||||
this.id = id;
|
||||
|
||||
@@ -27,6 +27,7 @@ export type StalkerSessionContentType =
|
||||
(typeof STALKER_SESSION_CONTENT_TYPES)[keyof typeof STALKER_SESSION_CONTENT_TYPES];
|
||||
|
||||
export type StalkerSessionEntityId = string | number;
|
||||
export type StalkerPlaybackContextRef = string;
|
||||
export type StalkerSessionNoParameters = Readonly<Record<string, never>>;
|
||||
|
||||
export interface StalkerSessionCategoryParameters {
|
||||
@@ -165,7 +166,7 @@ export interface StalkerSessionEpgResult {
|
||||
|
||||
export interface StalkerSessionCreateLinkResult {
|
||||
streamUrl: string;
|
||||
playbackContextRef?: string;
|
||||
playbackContextRef?: StalkerPlaybackContextRef;
|
||||
title?: string;
|
||||
thumbnail?: string | null;
|
||||
isLive?: boolean;
|
||||
|
||||
@@ -93,7 +93,6 @@ export type StalkerSessionStage =
|
||||
export type StalkerSessionLeaseRef = string;
|
||||
export type StalkerSessionAttemptRef = string;
|
||||
export type StalkerSessionChallengeRef = string;
|
||||
export type StalkerPlaybackContextRef = string;
|
||||
|
||||
export interface StalkerSessionProfilePreset {
|
||||
id: 'mag250-public-5_1-minimal-v1';
|
||||
@@ -128,10 +127,8 @@ export interface StalkerSessionTransportConfiguration {
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface StalkerSessionConnectionDescriptor {
|
||||
export interface StalkerSessionConnectionDescriptorBase {
|
||||
playlistRef: string;
|
||||
connectionMode: StalkerSessionConnectionMode;
|
||||
provisionalReason?: StalkerSessionProvisionalReason;
|
||||
sourceUrl: string;
|
||||
learnedEndpointHint?: string;
|
||||
macAddress: string;
|
||||
@@ -140,6 +137,22 @@ export interface StalkerSessionConnectionDescriptor {
|
||||
transportConfiguration?: StalkerSessionTransportConfiguration;
|
||||
}
|
||||
|
||||
export interface StalkerSessionPersistedConnectionDescriptor
|
||||
extends StalkerSessionConnectionDescriptorBase {
|
||||
connectionMode: 'persisted-open';
|
||||
provisionalReason?: never;
|
||||
}
|
||||
|
||||
export interface StalkerSessionProvisionalConnectionDescriptor
|
||||
extends StalkerSessionConnectionDescriptorBase {
|
||||
connectionMode: 'provisional';
|
||||
provisionalReason: StalkerSessionProvisionalReason;
|
||||
}
|
||||
|
||||
export type StalkerSessionConnectionDescriptor =
|
||||
| StalkerSessionPersistedConnectionDescriptor
|
||||
| StalkerSessionProvisionalConnectionDescriptor;
|
||||
|
||||
export interface StalkerSessionOpenRequest {
|
||||
descriptor: StalkerSessionConnectionDescriptor;
|
||||
}
|
||||
@@ -179,29 +192,47 @@ export interface StalkerSessionPersistenceDraft {
|
||||
isFullStalkerPortal: boolean;
|
||||
}
|
||||
|
||||
export interface StalkerSessionFullReadyOutcome {
|
||||
export interface StalkerSessionPersistedReadyState {
|
||||
connectionMode: 'persisted-open';
|
||||
provisionalReason?: never;
|
||||
attemptRef?: never;
|
||||
}
|
||||
|
||||
export interface StalkerSessionProvisionalReadyState {
|
||||
connectionMode: 'provisional';
|
||||
provisionalReason: StalkerSessionProvisionalReason;
|
||||
attemptRef: StalkerSessionAttemptRef;
|
||||
}
|
||||
|
||||
export interface StalkerSessionFullReadyOutcomeBase {
|
||||
kind: 'ready';
|
||||
recipe: 'full-session';
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
landingUrl: string;
|
||||
leaseRef: StalkerSessionLeaseRef;
|
||||
attemptRef?: StalkerSessionAttemptRef;
|
||||
capabilities: StalkerSessionCapabilities;
|
||||
accountSummary?: StalkerSessionAccountSummary;
|
||||
persistenceDraft: StalkerSessionPersistenceDraft;
|
||||
}
|
||||
|
||||
export interface StalkerSessionStatelessReadyOutcome {
|
||||
export type StalkerSessionFullReadyOutcome =
|
||||
StalkerSessionFullReadyOutcomeBase &
|
||||
(StalkerSessionPersistedReadyState | StalkerSessionProvisionalReadyState);
|
||||
|
||||
export interface StalkerSessionStatelessReadyOutcomeBase {
|
||||
kind: 'ready';
|
||||
recipe: 'stateless-mac';
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
landingUrl: string;
|
||||
attemptRef?: StalkerSessionAttemptRef;
|
||||
persistenceDraft: StalkerSessionPersistenceDraft;
|
||||
}
|
||||
|
||||
export type StalkerSessionStatelessReadyOutcome =
|
||||
StalkerSessionStatelessReadyOutcomeBase &
|
||||
(StalkerSessionPersistedReadyState | StalkerSessionProvisionalReadyState);
|
||||
|
||||
export interface StalkerSessionOriginApprovalRequiredOutcome {
|
||||
kind: 'origin-approval-required';
|
||||
requestId: string;
|
||||
@@ -234,39 +265,46 @@ export type StalkerSessionConnectionOutcome =
|
||||
| StalkerSessionCredentialsRequiredOutcome
|
||||
| StalkerSessionFailureOutcome;
|
||||
|
||||
export type StalkerSessionRequest = {
|
||||
[Operation in StalkerSessionApplicationOperation]: {
|
||||
export type StalkerSessionRequest<
|
||||
Operation extends
|
||||
StalkerSessionApplicationOperation = StalkerSessionApplicationOperation,
|
||||
> = {
|
||||
[CurrentOperation in Operation]: {
|
||||
leaseRef: StalkerSessionLeaseRef;
|
||||
operation: Operation;
|
||||
parameters: StalkerSessionOperationParameters<Operation>;
|
||||
requestId?: string;
|
||||
operation: CurrentOperation;
|
||||
parameters: StalkerSessionOperationParameters<CurrentOperation>;
|
||||
};
|
||||
}[StalkerSessionApplicationOperation];
|
||||
}[Operation];
|
||||
|
||||
export type StalkerSessionRequestSuccess = {
|
||||
[Operation in StalkerSessionApplicationOperation]: {
|
||||
export type StalkerSessionRequestSuccess<
|
||||
Operation extends
|
||||
StalkerSessionApplicationOperation = StalkerSessionApplicationOperation,
|
||||
> = {
|
||||
[CurrentOperation in Operation]: {
|
||||
kind: 'success';
|
||||
requestId: string;
|
||||
operation: Operation;
|
||||
payload: StalkerSessionOperationResult<Operation>;
|
||||
playbackContextRef?: StalkerPlaybackContextRef;
|
||||
operation: CurrentOperation;
|
||||
payload: StalkerSessionOperationResult<CurrentOperation>;
|
||||
};
|
||||
}[StalkerSessionApplicationOperation];
|
||||
}[Operation];
|
||||
|
||||
export type StalkerSessionRequestOutcome =
|
||||
| StalkerSessionRequestSuccess
|
||||
| StalkerSessionConnectionOutcome;
|
||||
export type StalkerSessionRequestOutcome<
|
||||
Operation extends
|
||||
StalkerSessionApplicationOperation = StalkerSessionApplicationOperation,
|
||||
> =
|
||||
| StalkerSessionRequestSuccess<Operation>
|
||||
| StalkerSessionOriginApprovalRequiredOutcome
|
||||
| StalkerSessionCredentialsRequiredOutcome
|
||||
| StalkerSessionFailureOutcome;
|
||||
|
||||
export interface StalkerSessionLeaseControlRequest {
|
||||
action: 'activate' | 'deactivate' | 'close' | 'force-redetect';
|
||||
leaseRef: StalkerSessionLeaseRef;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export interface StalkerSessionAttemptControlRequest {
|
||||
action: 'commit' | 'discard';
|
||||
attemptRef: StalkerSessionAttemptRef;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export type StalkerSessionControlRequest =
|
||||
|
||||
Reference in new issue
Block a user