RSS Git Download  Clone
Raw Blame History 12kB 336 lines
import { Injectable, Inject, ApplicationRef } from '@angular/core';
import { Subject } from 'rxjs';

declare const p3xr: any;
declare const io: any;

/**
 * Angular Socket.IO service — standalone, no AngularJS dependency.
 * All callbacks run inside Angular's zone for automatic change detection.
 */
@Injectable({ providedIn: 'root' })
export class SocketService {

    private ioClient: any;
    private reconnect = false;
    private licenseRefreshInterval: any;
    private connectErrorWas = false;

    readonly connections$ = new Subject<any>();
    readonly redisDisconnected$ = new Subject<any>();
    readonly redisStatus$ = new Subject<any>();
    readonly configuration$ = new Subject<any>();
    readonly licenseUpdate$ = new Subject<any>();
    readonly socketError$ = new Subject<any>();
    readonly stateChanged$ = new Subject<void>();

    constructor(@Inject(ApplicationRef) private appRef: ApplicationRef) {
        this.initConnection();
    }

    tick(): void {
        setTimeout(() => {
            this.appRef.tick();
        });
    }

    private initConnection(): void {
        const ioOptions: any = {
            rejectUnauthorized: false,
            path: '/socket.io',
            secure: true,
        };

        if ((globalThis as any).p3xrDevMode === true) {
            ioOptions.transports = ['websocket'];
        }

        this.ioClient = io.connect(p3xr.api.host, ioOptions);


        this.ioClient.on('connect', async () => {
            if (this.reconnect) {
                console.log('p3xr-socket RE-connected', this.ioClient.id);
            } else {
                console.log('p3xr-socket connected', this.ioClient.id);
            }
            this.reconnect = true;

            if (this.licenseRefreshInterval) {
                clearInterval(this.licenseRefreshInterval);
            }
            this.licenseRefreshInterval = setInterval(() => {
                this.refreshLicenseStatus();
            }, 1000 * 60 * 60);

            await this.refreshLicenseStatus();
        });

        this.ioClient.on('disconnect', () => {
            if (this.licenseRefreshInterval) {
                clearInterval(this.licenseRefreshInterval);
                this.licenseRefreshInterval = undefined;
            }
            location.reload();
        });

        this.ioClient.on('info-interval', (data: any) => {
            this.applyLicenseData(data);
            this.tick();
        });

        this.ioClient.on('error', (error: any) => {
            this.handleSocketError(error);
        });

        this.ioClient.on('connect_error', (error: any) => {
            this.handleSocketError(error);
        });

        this.ioClient.on('connections', (data: any) => {
            if (data.status === 'error') {
                p3xr.connectionsReset();
                this.tick();
                return;
            }
            p3xr.state.connections = data.connections;
            this.connections$.next(data);
            this.tick();
        });

        this.ioClient.on('redis-disconnected', (data: any) => {
            if (p3xr.state.connection !== undefined && p3xr.state.connection.id === data.connectionId) {
                p3xr.state.monitor = false;
                p3xr.state.connection = undefined;

                if (data.status === 'error') {
                    const msg = p3xr.strings?.status?.redisDisconnected?.(data) ?? 'Redis disconnected';
                    this.showToast(msg);
                } else if (data.status === 'code') {
                    const codes = p3xr.strings?.code ?? {};
                    const msg = codes[data.code] ?? `unknown redis disconnect code: ${data.code}`;
                    this.showToast(msg);
                }

                this.redisDisconnected$.next(data);
                this.tick();
                this.request({ action: 'trigger-redis-disconnect', enableResponse: false }).catch(() => {});
            }
        });

        this.ioClient.on('redis-status', (data: any) => {
            p3xr.state.redisConnections = data.redisConnections;
            this.redisStatus$.next(data);
            this.tick();
        });

        let receivedVersion = false;
        this.ioClient.on('configuration', (data: any) => {
            p3xr.state.cfg = data;
            if (data.snapshot === true) {
                p3xr.state.version = 'SNAPSHOT';
            } else {
                p3xr.state.version = 'v' + data.version;
                if (!receivedVersion) {
                    receivedVersion = true;
                    try {
                        (window as any).gtag?.('config', p3xr.settings.googleAnalytics, {
                            page_path: '/version/' + p3xr.state.version
                        });
                    } catch { /* noop */ }
                }
            }
            this.configuration$.next(data);
            this.tick();
        });
    }

    // --- License ---

    private applyLicenseData(data: any = {}): void {
        const nextLicense: any = Object.assign({
            licenseEditable: true,
            editableActive: true,
            disabled: false,
            hasLicenseKey: false,
            licenseKeyMasked: '',
            tier: 'free',
            valid: false,
            reason: 'LICENSE_MISSING',
            licenseStatus: 'inactive',
            maxDevices: null,
            activeDevices: null,
            deviceLease: null,
            daysLeft: null,
            features: [],
        }, (data.license && typeof data.license === 'object') ? data.license : {});

        if (typeof data.hasLicenseKey === 'boolean') {
            nextLicense.hasLicenseKey = data.hasLicenseKey;
        } else {
            nextLicense.hasLicenseKey = nextLicense.hasLicenseKey === true;
        }
        if (typeof data.licenseEditable === 'boolean') {
            nextLicense.licenseEditable = data.licenseEditable;
        } else if (typeof data.editableActive === 'boolean') {
            nextLicense.licenseEditable = data.editableActive;
        } else if (typeof data.disabled === 'boolean') {
            nextLicense.licenseEditable = !data.disabled;
        } else if (typeof nextLicense.licenseEditable !== 'boolean') {
            nextLicense.licenseEditable = true;
        }
        nextLicense.editableActive = nextLicense.licenseEditable;
        nextLicense.disabled = !nextLicense.licenseEditable;
        if (typeof data.licenseKeyMasked === 'string') {
            nextLicense.licenseKeyMasked = data.licenseKeyMasked;
        } else if (typeof nextLicense.licenseKeyMasked !== 'string') {
            nextLicense.licenseKeyMasked = '';
        }
        if (typeof data.tier === 'string' && data.tier.length > 0) {
            nextLicense.tier = data.tier;
        }
        if (!Array.isArray(nextLicense.features)) {
            nextLicense.features = [];
        }
        if (!nextLicense.deviceLease || typeof nextLicense.deviceLease !== 'object') {
            nextLicense.deviceLease = null;
        }
        if (typeof nextLicense.maxDevices !== 'number') {
            nextLicense.maxDevices = nextLicense.deviceLease?.maxDevices ?? null;
        }
        if (typeof nextLicense.activeDevices !== 'number') {
            nextLicense.activeDevices = nextLicense.deviceLease?.activeDevices ?? null;
        }

        const wasDonated = p3xr.state.donated === true;
        // All features are free — always enterprise
        const isDonated = true;
        const activeForFeatures = true;
        const hasProOrEnterpriseJsonBinary = true;

        p3xr.state.license = nextLicense;
        p3xr.state.donated = isDonated;
        // All features are free — always enterprise
        p3xr.state.hasProOrEnterpriseJsonBinary = true;
        // p3xr.state.hasProOrEnterpriseJsonBinary = hasProOrEnterpriseJsonBinary;
        if (p3xr.state.cfg && typeof data.readonlyConnections === 'boolean') {
            p3xr.state.cfg.readonlyConnections = data.readonlyConnections;
        }

        if (wasDonated !== isDonated) {
            try {
                if (!p3xr.isBot?.()) {
                    (window as any).gtag?.('config', p3xr.settings.googleAnalytics, {
                        page_path: isDonated ? '/donated' : '/free'
                    });
                }
            } catch { /* noop */ }
        }

        this.licenseUpdate$.next(nextLicense);
    }

    private async refreshLicenseStatus(): Promise<void> {
        try {
            const data = await this.request({ action: 'license-status' });
            this.applyLicenseData(data);
        } catch (e: any) {
            console.warn('license-status refresh failed', e.message);
        }
    }

    private handleSocketError(error: any): void {
        if (!this.connectErrorWas) {
            this.connectErrorWas = true;
            this.socketError$.next(error);
        }
    }

    private showToast(message: string): void {
        try {
            const snackBar = (globalThis as any).__p3xr_snackbar;
            if (snackBar) {
                const ref = snackBar.open(message, 'x', {
                    duration: 5000,
                    horizontalPosition: 'right',
                    verticalPosition: 'bottom',
                });
                ref.onAction().subscribe(() => ref.dismiss());
            }
        } catch { /* noop */ }
    }

    // --- Request API ---

    request(options: {
        action: string;
        payload?: any;
        enableResponse?: boolean;
    }): Promise<any> {
        if (!this.ioClient) {
            return Promise.reject(new Error('Socket.IO client unavailable'));
        }

        if (!options.payload) {
            options.payload = {};
        }

        options.payload.maxKeys = parseInt(p3xr.settings?.maxKeys ?? '10000');

        const enableResponse = options.enableResponse !== false;

        if (!enableResponse) {
            this.ioClient.emit('p3xr-request', options);
            return Promise.resolve();
        }

        return new Promise((resolve, reject) => {
            const requestId = p3xr.nextId();
            (options as any).requestId = requestId;
            const responseEvent = `p3xr-response-${requestId}`;

            let timeout: any;
            const response = (data: any) => {
                clearTimeout(timeout);
                this.ioClient.off(responseEvent);
                if (data?.status === 'ok') {
                    if (data.license || data.licenseKey || data.donated || options.action === 'license-status') {
                        this.applyLicenseData(data);
                    }
                    resolve(data);
                } else {
                    let errMsg = 'Unknown error';
                    try {
                        const err = data?.error;
                        if (typeof err === 'string') {
                            errMsg = err;
                        } else if (err?.message) {
                            errMsg = err.message;
                        } else if (err !== undefined && err !== null) {
                            errMsg = String(err);
                        }
                    } catch { /* noop */ }
                    reject(new Error(errMsg));
                }
                // Tick after await continuations settle (avoids NG0100 in dev mode)
                this.tick();
            };

            timeout = setTimeout(() => {
                this.ioClient.off(responseEvent, response);
                const msg = p3xr.strings?.label?.socketIoTimeout?.({ timeout: p3xr.settings.socket.timeout })
                    ?? `Socket.IO request timeout (${p3xr.settings.socket.timeout}ms)`;
                reject(new Error(msg));
                this.tick();
            }, p3xr.settings.socket.timeout);

            this.ioClient.on(responseEvent, response);
            this.ioClient.emit('p3xr-request', options);
        });
    }

    getClient(): any {
        return this.ioClient;
    }
}