feat(pwa): probe for server storage and wire server mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 00:16:55 +02:00
1 parent 57f2a33396
commit c5477c5f64
9 files changed
+332 -6

No files matched your search

+13
View File
@@ -6,6 +6,10 @@ import {
SettingsStore,
} from '@iptvnator/services';
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
import {
serverAccessGuard,
serverSignInGuard,
} from './server-storage/server-access.guard';
import { settingsUnsavedChangesGuard } from './settings/settings-unsaved-changes.guard';
// The workspace activates only once settings AND the parental lock state
@@ -77,6 +81,7 @@ export const routes: Routes = [
},
{
path: 'workspace',
canActivate: [serverAccessGuard],
resolve: {
settingsReady: settingsReadyResolver,
},
@@ -207,6 +212,14 @@ export const routes: Routes = [
},
],
},
{
path: 'server-sign-in',
canActivate: [serverSignInGuard],
loadComponent: () =>
import('./server-storage/server-sign-in.component').then(
(c) => c.ServerSignInComponent
),
},
{
path: 'settings',
redirectTo: '/workspace/settings',
@@ -0,0 +1,47 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, UrlTree } from '@angular/router';
import { ServerStorageStatusService } from '@iptvnator/services';
import { serverAccessGuard, serverSignInGuard } from './server-access.guard';
describe('server access guards', () => {
function run(guard: typeof serverAccessGuard, state: string) {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{
provide: ServerStorageStatusService,
useValue: { state: () => state },
},
],
});
return TestBed.runInInjectionContext(() =>
guard({} as never, {} as never)
);
}
const serialize = (result: unknown) =>
TestBed.inject(Router).serializeUrl(result as UrlTree);
afterEach(() => TestBed.resetTestingModule());
it('sends an unauthorized workspace visit to the sign-in page', () => {
expect(serialize(run(serverAccessGuard, 'unauthorized'))).toBe(
'/server-sign-in'
);
});
it('lets everyone else into the workspace', () => {
expect(run(serverAccessGuard, 'online')).toBe(true);
expect(run(serverAccessGuard, 'offline')).toBe(true);
expect(run(serverAccessGuard, 'inactive')).toBe(true);
});
it('opens the sign-in page only while unauthorized', () => {
expect(run(serverSignInGuard, 'unauthorized')).toBe(true);
expect(serialize(run(serverSignInGuard, 'online'))).toBe('/workspace');
expect(serialize(run(serverSignInGuard, 'inactive'))).toBe(
'/workspace'
);
});
});
@@ -0,0 +1,14 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { ServerStorageStatusService } from '@iptvnator/services';
/** Without a valid token the workspace would show an empty library. */
export const serverAccessGuard: CanActivateFn = () =>
inject(ServerStorageStatusService).state() === 'unauthorized'
? inject(Router).parseUrl('/server-sign-in')
: true;
export const serverSignInGuard: CanActivateFn = () =>
inject(ServerStorageStatusService).state() === 'unauthorized'
? true
: inject(Router).parseUrl('/workspace');
@@ -0,0 +1,82 @@
import { probeServerInstance } from './server-instance-probe';
const instance = {
service: 'iptvnator',
apiVersion: 1,
instanceId: 'i-1',
storage: 'server',
auth: 'none',
};
describe('probeServerInstance', () => {
const ok = (body: unknown) =>
jest.fn().mockResolvedValue({ ok: true, json: async () => body });
it('skips the probe without a runtime backend URL', async () => {
const fetchFn = ok(instance);
await expect(
probeServerInstance({
runtimeConfig: {},
fetchFn: fetchFn as never,
})
).resolves.toBeNull();
expect(fetchFn).not.toHaveBeenCalled();
});
it('skips the probe in Electron', async () => {
const fetchFn = ok(instance);
await expect(
probeServerInstance({
runtimeConfig: { BACKEND_URL: '/api' },
isElectron: true,
fetchFn: fetchFn as never,
})
).resolves.toBeNull();
expect(fetchFn).not.toHaveBeenCalled();
});
it('returns the instance when it reports server storage', async () => {
const fetchFn = ok(instance);
await expect(
probeServerInstance({
runtimeConfig: { BACKEND_URL: '/api/' },
isElectron: false,
fetchFn: fetchFn as never,
})
).resolves.toEqual(instance);
expect(fetchFn.mock.calls[0][0]).toBe('/api/v1/instance');
});
it('returns null for storage none, bad bodies and failures', async () => {
const config = {
runtimeConfig: { BACKEND_URL: '/api' },
isElectron: false,
};
await expect(
probeServerInstance({
...config,
fetchFn: ok({ ...instance, storage: 'none' }) as never,
})
).resolves.toBeNull();
await expect(
probeServerInstance({
...config,
fetchFn: ok({ hello: 1 }) as never,
})
).resolves.toBeNull();
await expect(
probeServerInstance({
...config,
fetchFn: jest
.fn()
.mockResolvedValue({ ok: false, status: 404 }) as never,
})
).resolves.toBeNull();
await expect(
probeServerInstance({
...config,
fetchFn: jest.fn().mockRejectedValue(new Error('x')) as never,
})
).resolves.toBeNull();
});
});
@@ -0,0 +1,62 @@
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
import type { IptvnatorRuntimeConfig } from '../services/runtime-config';
const PROBE_TIMEOUT_MS = 2_500;
export interface ServerInstanceProbeOptions {
readonly runtimeConfig?: IptvnatorRuntimeConfig;
readonly isElectron?: boolean;
readonly fetchFn?: typeof fetch;
}
/**
* Asks the backend whether it offers server storage. Runs only when the
* deployment wrote a runtime `BACKEND_URL` (the Docker entrypoint does), so
* hosted builds never call an external parser API for it.
*/
export async function probeServerInstance(
options: ServerInstanceProbeOptions = {}
): Promise<ServerInstanceInfo | null> {
const runtimeConfig =
options.runtimeConfig ?? globalThis.window?.__IPTVNATOR_CONFIG__;
const isElectron = options.isElectron ?? !!globalThis.window?.electron;
const baseUrl = runtimeConfig?.BACKEND_URL?.trim();
if (isElectron || !baseUrl) {
return null;
}
try {
const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
const response = await fetchFn(
`${baseUrl.replace(/\/+$/, '')}/v1/instance`,
{
cache: 'no-store',
signal:
typeof AbortSignal.timeout === 'function'
? AbortSignal.timeout(PROBE_TIMEOUT_MS)
: undefined,
}
);
if (!response.ok) {
return null;
}
const body: unknown = await response.json();
return isServerInstance(body) && body.storage === 'server'
? body
: null;
} catch {
return null;
}
}
function isServerInstance(value: unknown): value is ServerInstanceInfo {
const record = value as Partial<ServerInstanceInfo> | null;
return (
!!record &&
record.service === 'iptvnator' &&
typeof record.apiVersion === 'number' &&
typeof record.instanceId === 'string' &&
(record.storage === 'server' || record.storage === 'none') &&
(record.auth === 'none' || record.auth === 'token')
);
}
@@ -0,0 +1,50 @@
import { inject, Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { NavigationEnd, Router } from '@angular/router';
import { filter, take } from 'rxjs';
import { ServerStorageStatusService } from '@iptvnator/services';
import {
ServerMigrationService,
type ServerMigrationPlaylist,
} from '@iptvnator/services/server-storage';
/** Opens the migration dialog once, after the first navigation settles. */
@Injectable({ providedIn: 'root' })
export class ServerMigrationPrompt {
private readonly router = inject(Router);
private readonly dialog = inject(MatDialog);
private readonly status = inject(ServerStorageStatusService);
private readonly migration = inject(ServerMigrationService);
schedule(): void {
this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
take(1)
)
.subscribe(() => void this.offer());
}
private async offer(): Promise<void> {
if (this.status.state() !== 'online') {
return;
}
let playlists: ServerMigrationPlaylist[] | null;
try {
playlists = await this.migration.offerOnStartup();
} catch {
return; // IndexedDB or server unreadable: ask again next start.
}
if (!playlists) {
return;
}
const { ServerMigrationDialogComponent } =
await import('./server-migration-dialog.component');
this.dialog.open(ServerMigrationDialogComponent, {
data: playlists,
width: '540px',
maxWidth: 'calc(100vw - 32px)',
disableClose: true,
});
}
}
@@ -0,0 +1,36 @@
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import {
EnvironmentProviders,
inject,
provideAppInitializer,
Provider,
} from '@angular/core';
import { USER_DATA_STORAGE } from '@iptvnator/services';
import {
SERVER_STORAGE_CONFIG,
ServerAccessTokenInterceptor,
ServerStorageSession,
} from '@iptvnator/services/server-storage';
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
import { ServerMigrationPrompt } from './server-migration-prompt.service';
/** Loaded with a dynamic import from `main.ts`, only in server mode. */
export function provideServerStorage(
instance: ServerInstanceInfo,
baseUrl: string
): (Provider | EnvironmentProviders)[] {
return [
{ provide: SERVER_STORAGE_CONFIG, useValue: { instance, baseUrl } },
{
provide: USER_DATA_STORAGE,
useFactory: () => inject(ServerStorageSession).userData,
},
{
provide: HTTP_INTERCEPTORS,
useClass: ServerAccessTokenInterceptor,
multi: true,
},
provideAppInitializer(() => inject(ServerStorageSession).start()),
provideAppInitializer(() => inject(ServerMigrationPrompt).schedule()),
];
}
+8 -3
View File
@@ -13,7 +13,7 @@ import {
switchMap,
throwError,
} from 'rxjs';
import { DataService } from '@iptvnator/services';
import { DataService, serverAuthorizationHeaders } from '@iptvnator/services';
import {
CONNECTIVITY_GUARD_RESET,
ERROR,
@@ -199,7 +199,10 @@ export class PwaService extends DataService {
`${this.corsProxyUrl}/connectivity-guard/reset`,
{
body: JSON.stringify({ url: payload.url }),
headers: { 'content-type': 'application/json' },
headers: {
'content-type': 'application/json',
...serverAuthorizationHeaders(),
},
method: 'POST',
signal: controller.signal,
}
@@ -637,7 +640,9 @@ export class PwaService extends DataService {
logPortalDebugRequest(context);
// Make the fetch request
const response = await fetch(requestUrl);
const response = await fetch(requestUrl, {
headers: serverAuthorizationHeaders(),
});
if (!response.ok) {
throw new Error(
+20 -3
View File
@@ -1,8 +1,11 @@
import { ApplicationConfig, mergeApplicationConfig } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { resolveRestoredRendererRoute } from '@iptvnator/shared/interfaces';
import { registerAppDateLocale } from './app/app-date-locales';
import { AppComponent } from './app/app.component';
import { appConfig, getInitialLanguage } from './app/app.config';
import { probeServerInstance } from './app/server-storage/server-instance-probe';
import { getRuntimeBackendUrl } from './app/services/runtime-config';
// A reloaded packaged renderer arrives on index.html with the route it was
// on carried in the query string (the Electron main process recovers the
@@ -16,12 +19,26 @@ if (restoredHref !== null) {
window.history.replaceState(window.history.state, '', restoredHref);
}
/** Adds server storage when the self-hosted backend offers it. */
async function resolveAppConfig(): Promise<ApplicationConfig> {
const instance = await probeServerInstance();
if (!instance) {
return appConfig;
}
const { provideServerStorage } =
await import('./app/server-storage/server-storage.providers');
return mergeApplicationConfig(appConfig, {
providers: provideServerStorage(instance, getRuntimeBackendUrl()),
});
}
// Angular ships only English locale data; the preferred language's data is a
// lazy chunk. It is awaited before bootstrapping because the first route
// renders dates with that locale, and a locale without data throws. English
// (the default) resolves immediately.
registerAppDateLocale(getInitialLanguage())
.then(() => bootstrapApplication(AppComponent, appConfig))
// (the default) resolves immediately. The server-storage probe runs in
// parallel and only when the deployment provides a runtime backend URL.
Promise.all([registerAppDateLocale(getInitialLanguage()), resolveAppConfig()])
.then(([, config]) => bootstrapApplication(AppComponent, config))
.then(() => {
// Splash is rendered eagerly by index.html so the user sees something
// immediately instead of a blank Material-grey background. Once Angular