mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
feat(stalker): add route connection recovery flow
This commit is contained in:
1 parent
2125e5a907
commit
a6fd436e35
27 files changed
+1421
-22
No files matched your search
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "التصفية حسب الاسم",
|
||||
"FILTER_AND_SORT": "تصفية وفرز",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "فلتر بالاسم",
|
||||
"FILTER_AND_SORT": "فلتر وترتيب",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "Фільтраваць па імені",
|
||||
"FILTER_AND_SORT": "Фільтр і сартаванне",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Verwenden Sie nur Werte Ihres Anbieters oder eines registrierten Geräts. IPTVnator erfindet diese Werte nicht.",
|
||||
"USER_AGENT": "Browser-User-Agent überschreiben (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Anmeldedaten prüfen",
|
||||
"SAVE_AGAIN": "Erneut speichern"
|
||||
"SAVE_AGAIN": "Erneut speichern",
|
||||
"USERNAME": "Benutzername",
|
||||
"PASSWORD": "Passwort"
|
||||
},
|
||||
"FILTER_BY_NAME": "Nach Namen filtern",
|
||||
"FILTER_AND_SORT": "Filtern & Sortieren",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "Φιλτράρισμα κατά όνομα",
|
||||
"FILTER_AND_SORT": "Φιλτράρισμα & Ταξινόμηση",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filter by name",
|
||||
"FILTER_AND_SORT": "Filter & Sort",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtrar por nombre",
|
||||
"FILTER_AND_SORT": "Filtrar y ordenar",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtrer par nom",
|
||||
"FILTER_AND_SORT": "Filtrer et trier",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "Szűrés név alapján",
|
||||
"FILTER_AND_SORT": "Szűrés és rendezés",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtra per nome",
|
||||
"FILTER_AND_SORT": "Filtra e ordina",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "名前でフィルター",
|
||||
"FILTER_AND_SORT": "フィルターと並び替え",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "이름으로 필터",
|
||||
"FILTER_AND_SORT": "필터 및 정렬",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filter op naam",
|
||||
"FILTER_AND_SORT": "Filteren & sorteren",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtruj według nazwy",
|
||||
"FILTER_AND_SORT": "Filtruj i sortuj",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtrar por nome",
|
||||
"FILTER_AND_SORT": "Filtrar e ordenar",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Используйте только значения от провайдера или уже зарегистрированного устройства. IPTVnator не придумывает эти значения.",
|
||||
"USER_AGENT": "Переопределение браузерного User-Agent (необязательно)",
|
||||
"VALIDATE_CREDENTIALS": "Проверить данные",
|
||||
"SAVE_AGAIN": "Сохранить снова"
|
||||
"SAVE_AGAIN": "Сохранить снова",
|
||||
"USERNAME": "Имя пользователя",
|
||||
"PASSWORD": "Пароль"
|
||||
},
|
||||
"FILTER_BY_NAME": "Фильтровать по имени",
|
||||
"FILTER_AND_SORT": "Фильтр и сортировка",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "İsime göre filtrele",
|
||||
"FILTER_AND_SORT": "Filtrele ve Sırala",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "按名称筛选",
|
||||
"FILTER_AND_SORT": "筛选与排序",
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again"
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"FILTER_BY_NAME": "依名稱篩選",
|
||||
"FILTER_AND_SORT": "篩選與排序",
|
||||
|
||||
@@ -2,6 +2,9 @@ export * from './lib/stalker-inline-detail/stalker-inline-detail.component';
|
||||
export * from './lib/stalker-collection-detail.component';
|
||||
export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component';
|
||||
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
|
||||
export * from './lib/stalker-connection-flow/stalker-connection-flow.service';
|
||||
export * from './lib/stalker-connection-flow/stalker-credentials-dialog.component';
|
||||
export * from './lib/stalker-connection-flow/stalker-origin-approval-dialog.component';
|
||||
export * from './lib/stalker-catalog-facade.service';
|
||||
export * from './lib/stalker-feature.routes';
|
||||
export * from './lib/stalker-live-stream-layout/stalker-live-stream-layout.component';
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
StalkerCredentialsDialogComponent,
|
||||
StalkerCredentialsDialogData,
|
||||
} from './stalker-credentials-dialog.component';
|
||||
import {
|
||||
StalkerOriginApprovalDialogComponent,
|
||||
StalkerOriginApprovalDialogData,
|
||||
} from './stalker-origin-approval-dialog.component';
|
||||
|
||||
function configureTranslations(): void {
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation(
|
||||
'en',
|
||||
{
|
||||
CANCEL: 'Cancel',
|
||||
HOME: {
|
||||
STALKER_PORTAL: {
|
||||
APPROVE_ORIGIN: 'Approve target',
|
||||
CREDENTIALS_DESCRIPTION:
|
||||
'This portal requires registered credentials.',
|
||||
CREDENTIALS_REJECTED:
|
||||
'The saved credentials were rejected.',
|
||||
CREDENTIALS_TITLE: 'Portal credentials',
|
||||
HIDE_PASSWORD: 'Hide password',
|
||||
ORIGIN_APPROVAL_DESCRIPTION:
|
||||
'The MAC is sent only after approval.',
|
||||
ORIGIN_APPROVAL_TITLE: 'Approve portal redirect',
|
||||
ORIGIN_SOURCE: 'Entered origin',
|
||||
ORIGIN_TARGET: 'Redirect target',
|
||||
SHOW_PASSWORD: 'Show password',
|
||||
PASSWORD: 'Password',
|
||||
USERNAME: 'Username',
|
||||
VALIDATE_CREDENTIALS: 'Validate credentials',
|
||||
},
|
||||
},
|
||||
},
|
||||
true
|
||||
);
|
||||
translate.use('en');
|
||||
}
|
||||
|
||||
describe('Stalker route connection dialogs', () => {
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('shows the exact redirect origins and returns the explicit approval decision', async () => {
|
||||
const dialogRef = { close: jest.fn() };
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
StalkerOriginApprovalDialogComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: {
|
||||
finalOrigin: 'https://portal.example:8443',
|
||||
sourceOrigin: 'https://source.example',
|
||||
} satisfies StalkerOriginApprovalDialogData,
|
||||
},
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
],
|
||||
}).compileComponents();
|
||||
configureTranslations();
|
||||
const fixture = TestBed.createComponent(
|
||||
StalkerOriginApprovalDialogComponent
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
expect(text).toContain('https://source.example');
|
||||
expect(text).toContain('https://portal.example:8443');
|
||||
|
||||
fixture.componentInstance.approve();
|
||||
fixture.componentInstance.reject();
|
||||
expect(dialogRef.close).toHaveBeenNthCalledWith(1, true);
|
||||
expect(dialogRef.close).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
it('keeps the password masked by default and returns only submitted credentials', async () => {
|
||||
const dialogRef = { close: jest.fn() };
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
StalkerCredentialsDialogComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: {
|
||||
savedCredentialsRejected: true,
|
||||
username: 'saved-user',
|
||||
} satisfies StalkerCredentialsDialogData,
|
||||
},
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
],
|
||||
}).compileComponents();
|
||||
configureTranslations();
|
||||
const fixture = TestBed.createComponent(
|
||||
StalkerCredentialsDialogComponent
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const component = fixture.componentInstance;
|
||||
const password = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelector<HTMLInputElement>(
|
||||
'input[formControlName="password"]'
|
||||
);
|
||||
|
||||
expect(password?.type).toBe('password');
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).textContent
|
||||
).toContain('The saved credentials were rejected.');
|
||||
|
||||
component.passwordVisible.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(password?.type).toBe('text');
|
||||
|
||||
component.form.setValue({
|
||||
password: ' password with spaces ',
|
||||
username: ' replacement-user ',
|
||||
});
|
||||
component.submit();
|
||||
expect(dialogRef.close).toHaveBeenCalledWith({
|
||||
password: ' password with spaces ',
|
||||
username: 'replacement-user',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not submit a blank username and supports explicit cancellation', async () => {
|
||||
const dialogRef = { close: jest.fn() };
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
StalkerCredentialsDialogComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: {
|
||||
savedCredentialsRejected: false,
|
||||
} satisfies StalkerCredentialsDialogData,
|
||||
},
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
],
|
||||
}).compileComponents();
|
||||
configureTranslations();
|
||||
const component = TestBed.createComponent(
|
||||
StalkerCredentialsDialogComponent
|
||||
).componentInstance;
|
||||
|
||||
component.form.setValue({ password: '', username: ' ' });
|
||||
component.submit();
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
|
||||
component.cancel();
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
+447
@@ -0,0 +1,447 @@
|
||||
/* eslint-disable max-lines -- route connection state matrix is intentionally kept together */
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
StalkerSessionRecoveryHandler,
|
||||
StalkerSessionService,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import {
|
||||
Playlist,
|
||||
StalkerSessionConnectionOutcome,
|
||||
StalkerSessionFullReadyOutcome,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { EMPTY, of, throwError } from 'rxjs';
|
||||
import { StalkerCredentialsDialogComponent } from './stalker-credentials-dialog.component';
|
||||
import { StalkerConnectionFlowService } from './stalker-connection-flow.service';
|
||||
import { StalkerOriginApprovalDialogComponent } from './stalker-origin-approval-dialog.component';
|
||||
|
||||
const LEGACY_PLAYLIST: Playlist = {
|
||||
_id: 'stalker-route-1',
|
||||
autoRefresh: false,
|
||||
count: 0,
|
||||
importDate: '2026-07-27T00:00:00.000Z',
|
||||
lastUsage: '2026-07-27T00:00:00.000Z',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
portalUrl: 'https://source.example/c/',
|
||||
stalkerToken: 'legacy-token-must-be-removed',
|
||||
title: 'Living room',
|
||||
};
|
||||
|
||||
const READY: StalkerSessionFullReadyOutcome = {
|
||||
attemptRef: 'attempt-route-1',
|
||||
capabilities: {
|
||||
authenticatedSession: true,
|
||||
playbackContext: true,
|
||||
},
|
||||
connectionMode: 'provisional',
|
||||
endpoint: 'https://portal.example/stalker_portal/server/load.php',
|
||||
kind: 'ready',
|
||||
landingUrl: 'https://portal.example/c/',
|
||||
leaseRef: 'lease-route-1',
|
||||
persistenceDraft: {
|
||||
isFullStalkerPortal: true,
|
||||
portalUrl: 'https://portal.example/stalker_portal/server/load.php',
|
||||
stalkerLandingUrl: 'https://portal.example/c/',
|
||||
stalkerLastVerifiedAt: '2026-07-27T12:00:00.000Z',
|
||||
stalkerRecipeClassifierVersion: 1,
|
||||
stalkerRequestRecipe: 'full-session',
|
||||
stalkerSourceUrl: 'https://source.example/c/',
|
||||
},
|
||||
provisionalReason: 'migration',
|
||||
recipe: 'full-session',
|
||||
requestId: 'request-ready-route-1',
|
||||
};
|
||||
|
||||
const CONTROL_SUCCESS = {
|
||||
action: 'commit' as const,
|
||||
kind: 'success' as const,
|
||||
requestId: 'request-commit-route-1',
|
||||
};
|
||||
|
||||
function createSessionMock() {
|
||||
let recoveryHandler: StalkerSessionRecoveryHandler | undefined;
|
||||
return {
|
||||
activate: jest.fn(),
|
||||
commit: jest.fn().mockResolvedValue(CONTROL_SUCCESS),
|
||||
continue: jest.fn(),
|
||||
discard: jest.fn().mockResolvedValue({
|
||||
action: 'discard',
|
||||
kind: 'success',
|
||||
requestId: 'request-discard-route-1',
|
||||
}),
|
||||
forceRedetect: jest.fn(),
|
||||
getLeaseRef: jest.fn().mockReturnValue('lease-route-1'),
|
||||
open: jest.fn(),
|
||||
registerRecoveryHandler: jest.fn(
|
||||
(handler: StalkerSessionRecoveryHandler) => {
|
||||
recoveryHandler = handler;
|
||||
return () => {
|
||||
if (recoveryHandler === handler) {
|
||||
recoveryHandler = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
),
|
||||
supportsTypedSessions: jest.fn().mockReturnValue(true),
|
||||
recoveryHandler: () => recoveryHandler,
|
||||
};
|
||||
}
|
||||
|
||||
describe('StalkerConnectionFlowService', () => {
|
||||
let service: StalkerConnectionFlowService;
|
||||
let session: ReturnType<typeof createSessionMock>;
|
||||
let playlists: {
|
||||
persistStalkerConnection: jest.Mock;
|
||||
};
|
||||
let store: {
|
||||
dispatch: jest.Mock;
|
||||
};
|
||||
let dialog: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let queuedDialogResults: unknown[];
|
||||
|
||||
beforeEach(() => {
|
||||
session = createSessionMock();
|
||||
playlists = {
|
||||
persistStalkerConnection: jest.fn((playlist: Playlist) =>
|
||||
of(playlist)
|
||||
),
|
||||
};
|
||||
store = {
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
queuedDialogResults = [];
|
||||
dialog = {
|
||||
open: jest.fn(() => ({
|
||||
afterClosed: () => of(queuedDialogResults.shift()),
|
||||
close: jest.fn(),
|
||||
})),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
StalkerConnectionFlowService,
|
||||
{
|
||||
provide: StalkerSessionService,
|
||||
useValue: session,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlists,
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: store,
|
||||
},
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: dialog,
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
open: jest.fn(() => ({
|
||||
onAction: () => EMPTY,
|
||||
})),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: jest.fn((key: string) => key),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(StalkerConnectionFlowService);
|
||||
});
|
||||
|
||||
it('classifies an ambiguous legacy full portal lazily and persists before commit', async () => {
|
||||
session.open.mockResolvedValue(READY);
|
||||
|
||||
const connected = await service.ensureConnected(LEGACY_PLAYLIST);
|
||||
|
||||
expect(session.open).toHaveBeenCalledWith(LEGACY_PLAYLIST, {
|
||||
connectionMode: 'provisional',
|
||||
provisionalReason: 'migration',
|
||||
});
|
||||
const persistedDraft = playlists.persistStalkerConnection.mock
|
||||
.calls[0][0] as Playlist;
|
||||
expect(persistedDraft).toMatchObject({
|
||||
isFullStalkerPortal: true,
|
||||
portalUrl:
|
||||
'https://portal.example/stalker_portal/server/load.php',
|
||||
stalkerRequestRecipe: 'full-session',
|
||||
stalkerSourceUrl: 'https://source.example/c/',
|
||||
});
|
||||
expect(persistedDraft.stalkerToken).toBeUndefined();
|
||||
expect(
|
||||
playlists.persistStalkerConnection.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(session.commit.mock.invocationCallOrder[0]);
|
||||
expect(session.commit).toHaveBeenCalledWith('attempt-route-1');
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.stalkerConnectionPersisted({
|
||||
playlist: persistedDraft,
|
||||
})
|
||||
);
|
||||
expect(connected).toEqual(persistedDraft);
|
||||
});
|
||||
|
||||
it('keeps an explicitly simple record and PWA behavior unchanged', async () => {
|
||||
const simple = {
|
||||
...LEGACY_PLAYLIST,
|
||||
isFullStalkerPortal: false,
|
||||
};
|
||||
|
||||
await expect(service.ensureConnected(simple)).resolves.toBe(simple);
|
||||
session.supportsTypedSessions.mockReturnValueOnce(false);
|
||||
await expect(
|
||||
service.ensureConnected(LEGACY_PLAYLIST)
|
||||
).resolves.toBe(LEGACY_PLAYLIST);
|
||||
|
||||
expect(session.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows exact origin and credential challenges and persists only accepted credentials', async () => {
|
||||
const origin: StalkerSessionConnectionOutcome = {
|
||||
challengeRef: 'challenge-origin-route-1',
|
||||
finalOrigin: 'https://portal.example',
|
||||
kind: 'origin-approval-required',
|
||||
requestId: 'request-origin-route-1',
|
||||
sourceOrigin: 'https://source.example',
|
||||
};
|
||||
const credentials: StalkerSessionConnectionOutcome = {
|
||||
attemptNumber: 2,
|
||||
challengeRef: 'challenge-credentials-route-1',
|
||||
kind: 'credentials-required',
|
||||
requestId: 'request-credentials-route-1',
|
||||
savedCredentialsRejected: true,
|
||||
};
|
||||
session.open.mockResolvedValue(origin);
|
||||
session.continue
|
||||
.mockResolvedValueOnce(credentials)
|
||||
.mockResolvedValueOnce(READY);
|
||||
queuedDialogResults.push(true, {
|
||||
password: 'replacement-password',
|
||||
username: 'replacement-user',
|
||||
});
|
||||
|
||||
const connected = await service.ensureConnected({
|
||||
...LEGACY_PLAYLIST,
|
||||
password: 'old-password',
|
||||
username: 'old-user',
|
||||
});
|
||||
|
||||
expect(dialog.open).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
StalkerOriginApprovalDialogComponent,
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
finalOrigin: 'https://portal.example',
|
||||
sourceOrigin: 'https://source.example',
|
||||
},
|
||||
disableClose: true,
|
||||
})
|
||||
);
|
||||
expect(dialog.open).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
StalkerCredentialsDialogComponent,
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
savedCredentialsRejected: true,
|
||||
username: 'old-user',
|
||||
},
|
||||
disableClose: true,
|
||||
})
|
||||
);
|
||||
expect(session.continue).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
LEGACY_PLAYLIST._id,
|
||||
{
|
||||
challengeRef: 'challenge-origin-route-1',
|
||||
response: {
|
||||
approved: true,
|
||||
kind: 'origin-approval',
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(session.continue).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
LEGACY_PLAYLIST._id,
|
||||
{
|
||||
challengeRef: 'challenge-credentials-route-1',
|
||||
response: {
|
||||
kind: 'credentials',
|
||||
password: 'replacement-password',
|
||||
username: 'replacement-user',
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(connected).toMatchObject({
|
||||
password: 'replacement-password',
|
||||
username: 'replacement-user',
|
||||
});
|
||||
});
|
||||
|
||||
it('consumes a rejected origin challenge without persisting anything', async () => {
|
||||
const origin: StalkerSessionConnectionOutcome = {
|
||||
challengeRef: 'challenge-origin-rejected',
|
||||
finalOrigin: 'https://other.example',
|
||||
kind: 'origin-approval-required',
|
||||
requestId: 'request-origin-rejected',
|
||||
sourceOrigin: 'https://source.example',
|
||||
};
|
||||
session.open.mockResolvedValue(origin);
|
||||
session.continue.mockResolvedValue({
|
||||
kind: 'failure',
|
||||
reason: 'origin-not-approved',
|
||||
requestId: 'request-origin-rejected-result',
|
||||
retryable: false,
|
||||
stage: 'awaiting-origin-approval',
|
||||
});
|
||||
queuedDialogResults.push(false);
|
||||
|
||||
await expect(
|
||||
service.ensureConnected(LEGACY_PLAYLIST)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(session.continue).toHaveBeenCalledWith(
|
||||
LEGACY_PLAYLIST._id,
|
||||
{
|
||||
challengeRef: 'challenge-origin-rejected',
|
||||
response: {
|
||||
approved: false,
|
||||
kind: 'origin-approval',
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(playlists.persistStalkerConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains a ready attempt after a local write failure and retries the same draft', async () => {
|
||||
session.open.mockResolvedValue(READY);
|
||||
playlists.persistStalkerConnection.mockReturnValueOnce(
|
||||
throwError(() => new Error('disk-full'))
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.ensureConnected(LEGACY_PLAYLIST)
|
||||
).resolves.toBeUndefined();
|
||||
expect(session.commit).not.toHaveBeenCalled();
|
||||
|
||||
playlists.persistStalkerConnection.mockImplementationOnce(
|
||||
(playlist: Playlist) => of(playlist)
|
||||
);
|
||||
const connected = await service.retryPendingPersistence();
|
||||
|
||||
expect(playlists.persistStalkerConnection).toHaveBeenCalledTimes(2);
|
||||
expect(session.commit).toHaveBeenCalledWith('attempt-route-1');
|
||||
expect(connected).toMatchObject({
|
||||
stalkerRequestRecipe: 'full-session',
|
||||
});
|
||||
});
|
||||
|
||||
it('discards a ready attempt when navigation abandons persistence retry', async () => {
|
||||
session.open.mockResolvedValue(READY);
|
||||
playlists.persistStalkerConnection.mockReturnValue(
|
||||
throwError(() => new Error('disk-full'))
|
||||
);
|
||||
await service.ensureConnected(LEGACY_PLAYLIST);
|
||||
|
||||
await service.cancel();
|
||||
|
||||
expect(session.discard).toHaveBeenCalledWith('attempt-route-1');
|
||||
});
|
||||
|
||||
it('turns a rejected promotion into a discarded runtime attempt', async () => {
|
||||
session.open.mockResolvedValue(READY);
|
||||
session.commit.mockRejectedValueOnce(new Error('promotion-failed'));
|
||||
|
||||
await expect(
|
||||
service.ensureConnected(LEGACY_PLAYLIST)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(session.discard).toHaveBeenCalledWith('attempt-route-1');
|
||||
});
|
||||
|
||||
it('opens a provisional replacement for a principal transition and enables one facade reissue', async () => {
|
||||
session.open.mockResolvedValue(READY);
|
||||
const handler = session.recoveryHandler();
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
await expect(
|
||||
handler?.({
|
||||
outcome: {
|
||||
kind: 'failure',
|
||||
reason: 'principal-transition-required',
|
||||
requestId: 'request-principal-transition',
|
||||
retryable: false,
|
||||
stage: 'refreshing',
|
||||
},
|
||||
playlist: LEGACY_PLAYLIST,
|
||||
trigger: 'request',
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(session.open).toHaveBeenCalledWith(LEGACY_PLAYLIST, {
|
||||
connectionMode: 'provisional',
|
||||
provisionalReason: 'migration',
|
||||
});
|
||||
expect(session.commit).toHaveBeenCalledWith('attempt-route-1');
|
||||
});
|
||||
|
||||
it('routes force-redetect through the active lease and handles its challenge', async () => {
|
||||
const credentials: StalkerSessionConnectionOutcome = {
|
||||
attemptNumber: 1,
|
||||
challengeRef: 'challenge-force-redetect',
|
||||
kind: 'credentials-required',
|
||||
requestId: 'request-force-redetect',
|
||||
};
|
||||
session.forceRedetect.mockResolvedValue(credentials);
|
||||
session.continue.mockResolvedValue(READY);
|
||||
queuedDialogResults.push({
|
||||
password: 'password',
|
||||
username: 'user',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.forceRedetect(LEGACY_PLAYLIST)
|
||||
).resolves.toMatchObject({
|
||||
stalkerRequestRecipe: 'full-session',
|
||||
});
|
||||
|
||||
expect(session.forceRedetect).toHaveBeenCalledWith('lease-route-1');
|
||||
});
|
||||
|
||||
it('lets Test Connection reclassify an explicitly simple record', async () => {
|
||||
session.getLeaseRef.mockReturnValueOnce(undefined);
|
||||
session.open.mockResolvedValue(READY);
|
||||
|
||||
await expect(
|
||||
service.forceRedetect({
|
||||
...LEGACY_PLAYLIST,
|
||||
isFullStalkerPortal: false,
|
||||
stalkerRequestRecipe: 'stateless-mac',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
isFullStalkerPortal: true,
|
||||
stalkerRequestRecipe: 'full-session',
|
||||
});
|
||||
|
||||
expect(session.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isFullStalkerPortal: false,
|
||||
}),
|
||||
{
|
||||
connectionMode: 'provisional',
|
||||
provisionalReason: 'migration',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
import { DestroyRef, Injectable, inject } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
StalkerSessionRecoveryRequest,
|
||||
StalkerSessionService,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import {
|
||||
Playlist,
|
||||
StalkerSessionConnectionOutcome,
|
||||
StalkerSessionFullReadyOutcome,
|
||||
StalkerSessionStatelessReadyOutcome,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { Subject, firstValueFrom, take } from 'rxjs';
|
||||
import {
|
||||
StalkerCredentialsDialogComponent,
|
||||
StalkerCredentialsDialogResult,
|
||||
} from './stalker-credentials-dialog.component';
|
||||
import { StalkerOriginApprovalDialogComponent } from './stalker-origin-approval-dialog.component';
|
||||
|
||||
type ReadyOutcome =
|
||||
| StalkerSessionFullReadyOutcome
|
||||
| StalkerSessionStatelessReadyOutcome;
|
||||
|
||||
interface PendingPersistence {
|
||||
readonly announceReady: boolean;
|
||||
readonly draft: Playlist;
|
||||
readonly outcome: ReadyOutcome;
|
||||
readonly runId: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StalkerConnectionFlowService {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly playlists = inject(PlaylistsService);
|
||||
private readonly session = inject(StalkerSessionService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly store = inject(Store);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly readySubject = new Subject<Playlist>();
|
||||
private activeDialog: { close: () => void } | null = null;
|
||||
private activeAttemptRef: string | null = null;
|
||||
private pendingPersistence: PendingPersistence | null = null;
|
||||
private runId = 0;
|
||||
|
||||
readonly connectionReady$ = this.readySubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
const unregister = this.session.registerRecoveryHandler((request) =>
|
||||
this.recover(request)
|
||||
);
|
||||
this.destroyRef.onDestroy(() => {
|
||||
unregister();
|
||||
void this.cancel();
|
||||
this.readySubject.complete();
|
||||
});
|
||||
}
|
||||
|
||||
async ensureConnected(playlist: Playlist): Promise<Playlist | undefined> {
|
||||
if (!this.shouldUseTypedSession(playlist)) {
|
||||
return playlist;
|
||||
}
|
||||
const runId = ++this.runId;
|
||||
await this.discardPending();
|
||||
const needsMigration = !this.hasVerifiedRecipe(playlist);
|
||||
try {
|
||||
const outcome = await this.session.open(
|
||||
playlist,
|
||||
needsMigration
|
||||
? {
|
||||
connectionMode: 'provisional',
|
||||
provisionalReason: 'migration',
|
||||
}
|
||||
: {}
|
||||
);
|
||||
return await this.handleOutcome(playlist, outcome, runId, false);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async forceRedetect(playlist: Playlist): Promise<Playlist | undefined> {
|
||||
if (!this.session.supportsTypedSessions()) {
|
||||
return playlist;
|
||||
}
|
||||
let leaseRef = this.session.getLeaseRef(playlist._id);
|
||||
if (leaseRef === undefined) {
|
||||
const runId = ++this.runId;
|
||||
await this.discardPending();
|
||||
try {
|
||||
const outcome = await this.session.open(playlist, {
|
||||
connectionMode: 'provisional',
|
||||
provisionalReason: 'migration',
|
||||
});
|
||||
return this.handleOutcome(playlist, outcome, runId, true);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const runId = ++this.runId;
|
||||
const outcome = await this.session.forceRedetect(leaseRef);
|
||||
return outcome.kind === 'success'
|
||||
? playlist
|
||||
: this.handleOutcome(playlist, outcome, runId, true);
|
||||
}
|
||||
|
||||
async retryPendingPersistence(): Promise<Playlist | undefined> {
|
||||
const pending = this.pendingPersistence;
|
||||
if (pending === null || pending.runId !== this.runId) {
|
||||
return undefined;
|
||||
}
|
||||
return this.persistAndCommit({
|
||||
...pending,
|
||||
announceReady: true,
|
||||
});
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
this.runId += 1;
|
||||
this.activeDialog?.close();
|
||||
this.activeDialog = null;
|
||||
await this.discardPending();
|
||||
}
|
||||
|
||||
private async recover(
|
||||
request: StalkerSessionRecoveryRequest
|
||||
): Promise<boolean> {
|
||||
if (!this.shouldUseTypedSession(request.playlist)) {
|
||||
return false;
|
||||
}
|
||||
const runId = ++this.runId;
|
||||
await this.discardPending();
|
||||
try {
|
||||
const outcome =
|
||||
request.outcome.kind === 'failure'
|
||||
? await this.session.open(request.playlist, {
|
||||
connectionMode: 'provisional',
|
||||
provisionalReason: 'migration',
|
||||
})
|
||||
: request.outcome;
|
||||
return (
|
||||
(await this.handleOutcome(
|
||||
request.playlist,
|
||||
outcome,
|
||||
runId,
|
||||
true
|
||||
)) !== undefined
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOutcome(
|
||||
playlist: Playlist,
|
||||
initialOutcome: StalkerSessionConnectionOutcome,
|
||||
runId: number,
|
||||
announceReady: boolean
|
||||
): Promise<Playlist | undefined> {
|
||||
let outcome = initialOutcome;
|
||||
let credentials: StalkerCredentialsDialogResult | undefined;
|
||||
while (runId === this.runId) {
|
||||
if (outcome.kind === 'ready') {
|
||||
const draft = this.applyReadyOutcome(
|
||||
playlist,
|
||||
outcome,
|
||||
credentials
|
||||
);
|
||||
return this.persistAndCommit({
|
||||
announceReady,
|
||||
draft,
|
||||
outcome,
|
||||
runId,
|
||||
});
|
||||
}
|
||||
if (outcome.kind === 'origin-approval-required') {
|
||||
const approved = await this.requestOriginApproval(outcome);
|
||||
if (runId !== this.runId) {
|
||||
return undefined;
|
||||
}
|
||||
outcome = await this.session.continue(playlist._id, {
|
||||
challengeRef: outcome.challengeRef,
|
||||
response: {
|
||||
approved,
|
||||
kind: 'origin-approval',
|
||||
},
|
||||
});
|
||||
if (!approved) {
|
||||
return undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (outcome.kind === 'credentials-required') {
|
||||
credentials = await this.requestCredentials(
|
||||
playlist,
|
||||
outcome.savedCredentialsRejected === true
|
||||
);
|
||||
if (runId !== this.runId || credentials === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
outcome = await this.session.continue(playlist._id, {
|
||||
challengeRef: outcome.challengeRef,
|
||||
response: {
|
||||
kind: 'credentials',
|
||||
...credentials,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async requestOriginApproval(
|
||||
outcome: Extract<
|
||||
StalkerSessionConnectionOutcome,
|
||||
{ kind: 'origin-approval-required' }
|
||||
>
|
||||
): Promise<boolean> {
|
||||
const ref = this.dialog.open(StalkerOriginApprovalDialogComponent, {
|
||||
data: {
|
||||
finalOrigin: outcome.finalOrigin,
|
||||
sourceOrigin: outcome.sourceOrigin,
|
||||
},
|
||||
disableClose: true,
|
||||
});
|
||||
this.activeDialog = ref;
|
||||
const approved = await firstValueFrom(ref.afterClosed(), {
|
||||
defaultValue: false,
|
||||
});
|
||||
if (this.activeDialog === ref) {
|
||||
this.activeDialog = null;
|
||||
}
|
||||
return approved === true;
|
||||
}
|
||||
|
||||
private async requestCredentials(
|
||||
playlist: Playlist,
|
||||
savedCredentialsRejected: boolean
|
||||
): Promise<StalkerCredentialsDialogResult | undefined> {
|
||||
const ref = this.dialog.open(StalkerCredentialsDialogComponent, {
|
||||
data: {
|
||||
savedCredentialsRejected,
|
||||
username: playlist.username,
|
||||
},
|
||||
disableClose: true,
|
||||
});
|
||||
this.activeDialog = ref;
|
||||
const credentials = await firstValueFrom(ref.afterClosed(), {
|
||||
defaultValue: null,
|
||||
});
|
||||
if (this.activeDialog === ref) {
|
||||
this.activeDialog = null;
|
||||
}
|
||||
return credentials ?? undefined;
|
||||
}
|
||||
|
||||
private async persistAndCommit(
|
||||
pending: PendingPersistence
|
||||
): Promise<Playlist | undefined> {
|
||||
this.activeAttemptRef = pending.outcome.attemptRef ?? null;
|
||||
let persisted: Playlist;
|
||||
try {
|
||||
persisted = await firstValueFrom(
|
||||
this.playlists.persistStalkerConnection(pending.draft)
|
||||
);
|
||||
} catch {
|
||||
if (pending.runId === this.runId) {
|
||||
this.pendingPersistence = pending;
|
||||
this.offerPersistenceRetry();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (pending.runId !== this.runId) {
|
||||
await this.discardAttempt(pending.outcome.attemptRef);
|
||||
return undefined;
|
||||
}
|
||||
this.store.dispatch(
|
||||
PlaylistActions.stalkerConnectionPersisted({
|
||||
playlist: persisted,
|
||||
})
|
||||
);
|
||||
if (pending.outcome.attemptRef !== undefined) {
|
||||
let promotion;
|
||||
try {
|
||||
promotion = await this.session.commit(
|
||||
pending.outcome.attemptRef
|
||||
);
|
||||
} catch {
|
||||
await this.discardAttempt(pending.outcome.attemptRef);
|
||||
return undefined;
|
||||
}
|
||||
if (promotion.kind !== 'success') {
|
||||
await this.discardAttempt(pending.outcome.attemptRef);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
this.activeAttemptRef = null;
|
||||
this.pendingPersistence = null;
|
||||
if (pending.announceReady) {
|
||||
this.readySubject.next(persisted);
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
|
||||
private applyReadyOutcome(
|
||||
playlist: Playlist,
|
||||
outcome: ReadyOutcome,
|
||||
credentials: StalkerCredentialsDialogResult | undefined
|
||||
): Playlist {
|
||||
const draft: Playlist = {
|
||||
...playlist,
|
||||
...outcome.persistenceDraft,
|
||||
portalUrl: outcome.persistenceDraft.portalUrl,
|
||||
...(credentials === undefined ? {} : credentials),
|
||||
};
|
||||
delete draft.stalkerToken;
|
||||
return draft;
|
||||
}
|
||||
|
||||
private offerPersistenceRetry(): void {
|
||||
const ref = this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
|
||||
{ reason: 'local-persistence-failed' }
|
||||
),
|
||||
this.translate.instant('HOME.STALKER_PORTAL.SAVE_AGAIN'),
|
||||
{ duration: 120_000 }
|
||||
);
|
||||
ref.onAction()
|
||||
.pipe(take(1))
|
||||
.subscribe(() => void this.retryPendingPersistence());
|
||||
}
|
||||
|
||||
private async discardPending(): Promise<void> {
|
||||
const attemptRef =
|
||||
this.pendingPersistence?.outcome.attemptRef ??
|
||||
this.activeAttemptRef ??
|
||||
undefined;
|
||||
this.pendingPersistence = null;
|
||||
this.activeAttemptRef = null;
|
||||
await this.discardAttempt(attemptRef);
|
||||
}
|
||||
|
||||
private async discardAttempt(attemptRef: string | undefined): Promise<void> {
|
||||
if (attemptRef !== undefined) {
|
||||
await this.session.discard(attemptRef).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private shouldUseTypedSession(playlist: Playlist): boolean {
|
||||
return (
|
||||
this.session.supportsTypedSessions() &&
|
||||
playlist.isFullStalkerPortal !== false &&
|
||||
playlist.stalkerRequestRecipe !== 'stateless-mac'
|
||||
);
|
||||
}
|
||||
|
||||
private hasVerifiedRecipe(playlist: Playlist): boolean {
|
||||
return (
|
||||
playlist.stalkerRequestRecipe === 'full-session' &&
|
||||
typeof playlist.stalkerRecipeClassifierVersion === 'number'
|
||||
);
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
export interface StalkerCredentialsDialogData {
|
||||
readonly savedCredentialsRejected: boolean;
|
||||
readonly username?: string;
|
||||
}
|
||||
|
||||
export interface StalkerCredentialsDialogResult {
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
ReactiveFormsModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
selector: 'app-stalker-credentials-dialog',
|
||||
styles: `
|
||||
form,
|
||||
.credential-fields {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.credential-fields {
|
||||
gap: 0.75rem;
|
||||
min-width: min(24rem, 72vw);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.rejected {
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
`,
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
{{ 'HOME.STALKER_PORTAL.CREDENTIALS_TITLE' | translate }}
|
||||
</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
|
||||
<mat-dialog-content>
|
||||
<p>
|
||||
{{
|
||||
'HOME.STALKER_PORTAL.CREDENTIALS_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
@if (data.savedCredentialsRejected) {
|
||||
<p class="rejected" role="alert">
|
||||
{{
|
||||
'HOME.STALKER_PORTAL.CREDENTIALS_REJECTED'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
<div class="credential-fields">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>
|
||||
{{ 'HOME.STALKER_PORTAL.USERNAME' | translate }}
|
||||
</mat-label>
|
||||
<input
|
||||
matInput
|
||||
autocomplete="username"
|
||||
formControlName="username"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>
|
||||
{{ 'HOME.STALKER_PORTAL.PASSWORD' | translate }}
|
||||
</mat-label>
|
||||
<input
|
||||
matInput
|
||||
autocomplete="current-password"
|
||||
formControlName="password"
|
||||
[type]="passwordVisible() ? 'text' : 'password'"
|
||||
/>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
[attr.aria-label]="
|
||||
(passwordVisible()
|
||||
? 'HOME.STALKER_PORTAL.HIDE_PASSWORD'
|
||||
: 'HOME.STALKER_PORTAL.SHOW_PASSWORD'
|
||||
) | translate
|
||||
"
|
||||
(click)="passwordVisible.update(visible => !visible)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">
|
||||
{{
|
||||
passwordVisible()
|
||||
? 'visibility_off'
|
||||
: 'visibility'
|
||||
}}
|
||||
</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="cancel()">
|
||||
{{ 'CANCEL' | translate }}
|
||||
</button>
|
||||
<button mat-flat-button type="submit">
|
||||
{{
|
||||
'HOME.STALKER_PORTAL.VALIDATE_CREDENTIALS' | translate
|
||||
}}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
`,
|
||||
})
|
||||
export class StalkerCredentialsDialogComponent {
|
||||
private readonly dialogRef =
|
||||
inject<
|
||||
MatDialogRef<
|
||||
StalkerCredentialsDialogComponent,
|
||||
StalkerCredentialsDialogResult | null
|
||||
>
|
||||
>(MatDialogRef);
|
||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||
readonly data = inject<StalkerCredentialsDialogData>(MAT_DIALOG_DATA);
|
||||
readonly passwordVisible = signal(false);
|
||||
readonly form = this.formBuilder.group({
|
||||
username: [
|
||||
this.data.username ?? '',
|
||||
[Validators.required, Validators.pattern(/\S/)],
|
||||
],
|
||||
password: [''],
|
||||
});
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
const value = this.form.getRawValue();
|
||||
this.dialogRef.close({
|
||||
password: value.password,
|
||||
username: value.username.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close(null);
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
export interface StalkerOriginApprovalDialogData {
|
||||
readonly sourceOrigin: string;
|
||||
readonly finalOrigin: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
|
||||
selector: 'app-stalker-origin-approval-dialog',
|
||||
styles: `
|
||||
.origin-context {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
.origin-context div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font: var(--mat-sys-label-medium);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0.25rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
`,
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
{{ 'HOME.STALKER_PORTAL.ORIGIN_APPROVAL_TITLE' | translate }}
|
||||
</h2>
|
||||
<mat-dialog-content>
|
||||
<p>
|
||||
{{
|
||||
'HOME.STALKER_PORTAL.ORIGIN_APPROVAL_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<dl class="origin-context">
|
||||
<div>
|
||||
<dt>
|
||||
{{ 'HOME.STALKER_PORTAL.ORIGIN_SOURCE' | translate }}
|
||||
</dt>
|
||||
<dd>{{ data.sourceOrigin }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{{ 'HOME.STALKER_PORTAL.ORIGIN_TARGET' | translate }}
|
||||
</dt>
|
||||
<dd>{{ data.finalOrigin }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="reject()">
|
||||
{{ 'CANCEL' | translate }}
|
||||
</button>
|
||||
<button mat-flat-button type="button" (click)="approve()">
|
||||
{{ 'HOME.STALKER_PORTAL.APPROVE_ORIGIN' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
})
|
||||
export class StalkerOriginApprovalDialogComponent {
|
||||
private readonly dialogRef =
|
||||
inject<
|
||||
MatDialogRef<StalkerOriginApprovalDialogComponent, boolean>
|
||||
>(MatDialogRef);
|
||||
readonly data = inject<StalkerOriginApprovalDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
approve(): void {
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
|
||||
reject(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerConnectionFlowService } from './stalker-connection-flow/stalker-connection-flow.service';
|
||||
import { StalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service';
|
||||
|
||||
const PLAYLIST_ID = 'stalker-1';
|
||||
@@ -30,6 +31,8 @@ const FULL_STALKER_PLAYLIST: PlaylistMeta = {
|
||||
async function flushEffects(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function getStalkerSectionFromUrl(url: string): string | null {
|
||||
@@ -40,6 +43,7 @@ function getStalkerSectionFromUrl(url: string): string | null {
|
||||
|
||||
describe('StalkerWorkspaceRouteSession', () => {
|
||||
const routerEvents = new Subject<NavigationEnd>();
|
||||
const connectionReady = new Subject<PlaylistMeta>();
|
||||
const activePlaylist = signal<PlaylistMeta | null>(ACTIVE_PLAYLIST);
|
||||
const selectedContentType = signal<'vod' | 'itv' | 'series' | 'radio'>(
|
||||
'vod'
|
||||
@@ -67,6 +71,14 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
getPlaylistById: jest.fn(() => of(ACTIVE_PLAYLIST)),
|
||||
};
|
||||
|
||||
const connectionFlow = {
|
||||
cancel: jest.fn().mockResolvedValue(undefined),
|
||||
connectionReady$: connectionReady.asObservable(),
|
||||
ensureConnected: jest.fn(
|
||||
async (playlist: PlaylistMeta) => playlist
|
||||
),
|
||||
};
|
||||
|
||||
const router = {
|
||||
url: `/workspace/stalker/${PLAYLIST_ID}/vod`,
|
||||
events: routerEvents.asObservable(),
|
||||
@@ -99,6 +111,8 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
stalkerStore.setSelectedContentType.mockClear();
|
||||
stalkerStore.setSearchPhrase.mockClear();
|
||||
playlistsService.getPlaylistById.mockClear();
|
||||
connectionFlow.cancel.mockClear();
|
||||
connectionFlow.ensureConnected.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -119,6 +133,10 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
provide: StalkerStore,
|
||||
useValue: stalkerStore,
|
||||
},
|
||||
{
|
||||
provide: StalkerConnectionFlowService,
|
||||
useValue: connectionFlow,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -133,6 +151,14 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
|
||||
ACTIVE_PLAYLIST
|
||||
);
|
||||
expect(connectionFlow.ensureConnected).toHaveBeenCalledWith(
|
||||
ACTIVE_PLAYLIST
|
||||
);
|
||||
expect(
|
||||
connectionFlow.ensureConnected.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(
|
||||
stalkerStore.setCurrentPlaylist.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(stalkerStore.setSelectedContentType).toHaveBeenCalledWith('itv');
|
||||
expect(selectedContentType()).toBe('itv');
|
||||
expect(
|
||||
@@ -200,4 +226,52 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
FULL_STALKER_PLAYLIST
|
||||
);
|
||||
});
|
||||
|
||||
it('does not expose a playlist to catalog resources when connection flow is cancelled', async () => {
|
||||
connectionFlow.ensureConnected.mockResolvedValueOnce(undefined);
|
||||
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels a provisional route attempt before leaving the Stalker workspace', async () => {
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
router.url = '/workspace/m3u/playlist-2';
|
||||
playlistContext.syncFromUrl.mockReturnValueOnce({
|
||||
inWorkspace: true,
|
||||
playlistId: 'playlist-2',
|
||||
provider: 'm3u',
|
||||
section: null,
|
||||
});
|
||||
|
||||
routerEvents.next(
|
||||
new NavigationEnd(
|
||||
2,
|
||||
`/workspace/stalker/${PLAYLIST_ID}/vod`,
|
||||
router.url
|
||||
)
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
expect(connectionFlow.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(stalkerStore.setCurrentPlaylist).toHaveBeenLastCalledWith(
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('activates a connection completed by Save Again while the route is still current', async () => {
|
||||
connectionFlow.ensureConnected.mockResolvedValueOnce(undefined);
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
connectionReady.next(FULL_STALKER_PLAYLIST);
|
||||
await flushEffects();
|
||||
|
||||
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
|
||||
FULL_STALKER_PLAYLIST
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,13 @@ import {
|
||||
StalkerStore,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerConnectionFlowService } from './stalker-connection-flow/stalker-connection-flow.service';
|
||||
|
||||
@Injectable()
|
||||
export class StalkerWorkspaceRouteSession {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly connectionFlow = inject(StalkerConnectionFlowService);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly router = inject(Router);
|
||||
@@ -28,6 +30,7 @@ export class StalkerWorkspaceRouteSession {
|
||||
|
||||
private currentPlaylistId: string | null = null;
|
||||
private readonly currentSection = signal<PortalRailSection | null>(null);
|
||||
private syncGeneration = 0;
|
||||
|
||||
constructor() {
|
||||
this.router.events
|
||||
@@ -41,6 +44,13 @@ export class StalkerWorkspaceRouteSession {
|
||||
.subscribe(() => {
|
||||
void this.syncRouteContext();
|
||||
});
|
||||
this.connectionFlow.connectionReady$
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((playlist) => {
|
||||
if (playlist._id === this.currentPlaylistId) {
|
||||
void this.stalkerStore.setCurrentPlaylist(playlist);
|
||||
}
|
||||
});
|
||||
|
||||
void this.syncRouteContext();
|
||||
|
||||
@@ -52,13 +62,27 @@ export class StalkerWorkspaceRouteSession {
|
||||
}
|
||||
|
||||
private async syncRouteContext(): Promise<void> {
|
||||
const generation = ++this.syncGeneration;
|
||||
const routeContext = this.playlistContext.syncFromUrl(this.router.url);
|
||||
const playlistId =
|
||||
routeContext.provider === 'stalker'
|
||||
? routeContext.playlistId
|
||||
: null;
|
||||
|
||||
if (playlistId && this.currentPlaylistId !== playlistId) {
|
||||
if (!playlistId && this.currentPlaylistId !== null) {
|
||||
this.currentPlaylistId = null;
|
||||
await this.connectionFlow.cancel();
|
||||
if (generation !== this.syncGeneration) {
|
||||
return;
|
||||
}
|
||||
this.stalkerStore.resetCategories();
|
||||
this.stalkerStore.setSelectedCategory(null);
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
await this.stalkerStore.setCurrentPlaylist(undefined);
|
||||
} else if (playlistId && this.currentPlaylistId !== playlistId) {
|
||||
if (this.currentPlaylistId !== null) {
|
||||
await this.connectionFlow.cancel();
|
||||
}
|
||||
this.currentPlaylistId = playlistId;
|
||||
|
||||
this.stalkerStore.resetCategories();
|
||||
@@ -66,7 +90,21 @@ export class StalkerWorkspaceRouteSession {
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
|
||||
const playlist = await this.resolveStalkerPlaylist(playlistId);
|
||||
await this.stalkerStore.setCurrentPlaylist(playlist);
|
||||
const connected =
|
||||
playlist === undefined
|
||||
? undefined
|
||||
: await this.connectionFlow.ensureConnected(
|
||||
playlist as Playlist
|
||||
);
|
||||
if (
|
||||
generation !== this.syncGeneration ||
|
||||
playlistId !== this.currentPlaylistId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (connected !== undefined) {
|
||||
await this.stalkerStore.setCurrentPlaylist(connected);
|
||||
}
|
||||
}
|
||||
|
||||
this.syncRouteState(routeContext.section);
|
||||
@@ -134,6 +172,7 @@ export class StalkerWorkspaceRouteSession {
|
||||
|
||||
export function provideStalkerWorkspaceRouteSession(): Provider[] {
|
||||
return [
|
||||
StalkerConnectionFlowService,
|
||||
StalkerWorkspaceRouteSession,
|
||||
{
|
||||
provide: ENVIRONMENT_INITIALIZER,
|
||||
|
||||
Reference in new issue
Block a user