mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
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:
1 parent
57f2a33396
commit
c5477c5f64
9 files changed
+332
-6
No files matched your search
@@ -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()),
|
||||
];
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user