slopus--happy
98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled
319 行
9.4 KiB
TypeScript
319 行
9.4 KiB
TypeScript
import { io, Socket } from 'socket.io-client';
|
|
import { AppState, Platform } from 'react-native';
|
|
import Constants from 'expo-constants';
|
|
import { TokenStorage } from '@/auth/tokenStorage';
|
|
import { Encryption } from './encryption/encryption';
|
|
import { storage } from './storage';
|
|
|
|
export function getHappyClientId(): string {
|
|
let platform: string = Platform.OS; // 'ios' | 'android' | 'web'
|
|
if (platform === 'web' && typeof window !== 'undefined' && '__TAURI__' in window) {
|
|
platform = 'desktop';
|
|
}
|
|
const version = Constants.expoConfig?.version || '0.0.0';
|
|
return `${platform}/${version}`;
|
|
}
|
|
|
|
/**
|
|
* Compute the current "active" or "background" state for the current platform.
|
|
* Mobile uses AppState. Web/desktop uses document.visibilityState + window focus —
|
|
* "active" means the tab is visible AND has focus, so a backgrounded tab or an
|
|
* unfocused window correctly counts as background and won't suppress mobile pushes.
|
|
*/
|
|
export function getCurrentAppState(): 'active' | 'background' {
|
|
if (Platform.OS === 'web') {
|
|
if (typeof document === 'undefined') {
|
|
return 'active';
|
|
}
|
|
const visible = document.visibilityState === 'visible';
|
|
const focused = typeof document.hasFocus === 'function' ? document.hasFocus() : true;
|
|
return visible && focused ? 'active' : 'background';
|
|
}
|
|
return AppState.currentState === 'active' ? 'active' : 'background';
|
|
}
|
|
|
|
//
|
|
// Types
|
|
//
|
|
|
|
export interface SyncSocketConfig {
|
|
endpoint: string;
|
|
token: string;
|
|
}
|
|
|
|
export interface SyncSocketState {
|
|
isConnected: boolean;
|
|
connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error';
|
|
lastError: Error | null;
|
|
}
|
|
|
|
export type SyncSocketListener = (state: SyncSocketState) => void;
|
|
|
|
//
|
|
// Main Class
|
|
//
|
|
|
|
class ApiSocket {
|
|
|
|
// State
|
|
private socket: Socket | null = null;
|
|
private config: SyncSocketConfig | null = null;
|
|
private encryption: Encryption | null = null;
|
|
private messageHandlers: Map<string, (data: any) => void> = new Map();
|
|
private reconnectedListeners: Set<() => void> = new Set();
|
|
private statusListeners: Set<(status: 'disconnected' | 'connecting' | 'connected' | 'error') => void> = new Set();
|
|
private currentStatus: 'disconnected' | 'connecting' | 'connected' | 'error' = 'disconnected';
|
|
|
|
//
|
|
// Initialization
|
|
//
|
|
|
|
initialize(config: SyncSocketConfig, encryption: Encryption) {
|
|
this.config = config;
|
|
this.encryption = encryption;
|
|
this.connect();
|
|
}
|
|
|
|
//
|
|
// Connection Management
|
|
//
|
|
|
|
connect() {
|
|
if (!this.config || this.socket) {
|
|
return;
|
|
}
|
|
|
|
this.updateStatus('connecting');
|
|
|
|
this.socket = io(this.config.endpoint, {
|
|
path: '/v1/updates',
|
|
auth: {
|
|
token: this.config.token,
|
|
clientType: 'user-scoped' as const,
|
|
happyClient: getHappyClientId(),
|
|
appState: getCurrentAppState(),
|
|
},
|
|
transports: ['websocket'],
|
|
reconnection: true,
|
|
reconnectionDelay: 1000,
|
|
reconnectionDelayMax: 5000,
|
|
reconnectionAttempts: Infinity
|
|
});
|
|
|
|
this.setupEventHandlers();
|
|
}
|
|
|
|
disconnect() {
|
|
if (this.socket) {
|
|
this.socket.disconnect();
|
|
this.socket = null;
|
|
}
|
|
this.updateStatus('disconnected');
|
|
}
|
|
|
|
//
|
|
// Listener Management
|
|
//
|
|
|
|
onReconnected = (listener: () => void) => {
|
|
this.reconnectedListeners.add(listener);
|
|
return () => this.reconnectedListeners.delete(listener);
|
|
};
|
|
|
|
onStatusChange = (listener: (status: 'disconnected' | 'connecting' | 'connected' | 'error') => void) => {
|
|
this.statusListeners.add(listener);
|
|
// Immediately notify with current status
|
|
listener(this.currentStatus);
|
|
return () => this.statusListeners.delete(listener);
|
|
};
|
|
|
|
//
|
|
// Message Handling
|
|
//
|
|
|
|
onMessage(event: string, handler: (data: any) => void) {
|
|
this.messageHandlers.set(event, handler);
|
|
return () => this.messageHandlers.delete(event);
|
|
}
|
|
|
|
offMessage(event: string, handler: (data: any) => void) {
|
|
this.messageHandlers.delete(event);
|
|
}
|
|
|
|
/**
|
|
* RPC call for sessions - uses session-specific encryption
|
|
*/
|
|
async sessionRPC<R, A>(sessionId: string, method: string, params: A): Promise<R> {
|
|
const sessionEncryption = this.encryption!.getSessionEncryption(sessionId);
|
|
if (!sessionEncryption) {
|
|
throw new Error(`Session encryption not found for ${sessionId}`);
|
|
}
|
|
|
|
const result = await this.socket!.emitWithAck('rpc-call', {
|
|
method: `${sessionId}:${method}`,
|
|
params: await sessionEncryption.encryptRaw(params)
|
|
});
|
|
|
|
if (result.ok) {
|
|
return await sessionEncryption.decryptRaw(result.result) as R;
|
|
}
|
|
throw new Error('RPC call failed');
|
|
}
|
|
|
|
/**
|
|
* RPC call for machines - uses legacy/global encryption (for now)
|
|
*/
|
|
async machineRPC<R, A>(machineId: string, method: string, params: A): Promise<R> {
|
|
const machineEncryption = this.encryption!.getMachineEncryption(machineId);
|
|
if (!machineEncryption) {
|
|
throw new Error(`Machine encryption not found for ${machineId}`);
|
|
}
|
|
|
|
const result = await this.socket!.emitWithAck('rpc-call', {
|
|
method: `${machineId}:${method}`,
|
|
params: await machineEncryption.encryptRaw(params)
|
|
});
|
|
|
|
if (result.ok) {
|
|
return await machineEncryption.decryptRaw(result.result) as R;
|
|
}
|
|
throw new Error(result.error || 'RPC call failed');
|
|
}
|
|
|
|
/**
|
|
* Sends app focus state to server for push notification routing.
|
|
* Server uses this to suppress pushes when the mobile app is in foreground.
|
|
*/
|
|
sendAppState(state: string) {
|
|
this.socket?.emit('app-state', { state });
|
|
}
|
|
|
|
send(event: string, data: any) {
|
|
this.socket!.emit(event, data);
|
|
return true;
|
|
}
|
|
|
|
async emitWithAck<T = any>(event: string, data: any): Promise<T> {
|
|
if (!this.socket) {
|
|
throw new Error('Socket not connected');
|
|
}
|
|
return await this.socket.emitWithAck(event, data);
|
|
}
|
|
|
|
//
|
|
// HTTP Requests
|
|
//
|
|
|
|
async request(path: string, options?: RequestInit): Promise<Response> {
|
|
if (!this.config) {
|
|
throw new Error('SyncSocket not initialized');
|
|
}
|
|
|
|
const credentials = await TokenStorage.getCredentials();
|
|
if (!credentials) {
|
|
throw new Error('No authentication credentials');
|
|
}
|
|
|
|
const url = `${this.config.endpoint}${path}`;
|
|
const headers = {
|
|
'Authorization': `Bearer ${credentials.token}`,
|
|
'X-Happy-Client': getHappyClientId(),
|
|
...options?.headers
|
|
};
|
|
|
|
return fetch(url, {
|
|
...options,
|
|
headers
|
|
});
|
|
}
|
|
|
|
//
|
|
// Token Management
|
|
//
|
|
|
|
updateToken(newToken: string) {
|
|
if (this.config && this.config.token !== newToken) {
|
|
this.config.token = newToken;
|
|
|
|
if (this.socket) {
|
|
this.disconnect();
|
|
this.connect();
|
|
}
|
|
}
|
|
}
|
|
|
|
//
|
|
// Private Methods
|
|
//
|
|
|
|
private isVerboseLogging(): boolean {
|
|
try {
|
|
return storage.getState().localSettings.verboseLogging;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private updateStatus(status: 'disconnected' | 'connecting' | 'connected' | 'error') {
|
|
if (this.currentStatus !== status) {
|
|
this.currentStatus = status;
|
|
this.statusListeners.forEach(listener => listener(status));
|
|
}
|
|
}
|
|
|
|
private setupEventHandlers() {
|
|
if (!this.socket) return;
|
|
|
|
// Connection events
|
|
this.socket.on('connect', () => {
|
|
if (this.isVerboseLogging()) {
|
|
console.log('🔌 SyncSocket: Connected, recovered: ' + this.socket?.recovered);
|
|
console.log('🔌 SyncSocket: Socket ID:', this.socket?.id);
|
|
}
|
|
this.updateStatus('connected');
|
|
if (!this.socket?.recovered) {
|
|
this.reconnectedListeners.forEach(listener => listener());
|
|
}
|
|
});
|
|
|
|
this.socket.on('disconnect', (reason) => {
|
|
if (this.isVerboseLogging()) {
|
|
console.log('🔌 SyncSocket: Disconnected', reason);
|
|
}
|
|
this.updateStatus('disconnected');
|
|
});
|
|
|
|
// Error events
|
|
this.socket.on('connect_error', (error) => {
|
|
if (this.isVerboseLogging()) {
|
|
console.error('🔌 SyncSocket: Connection error', error);
|
|
}
|
|
this.updateStatus('error');
|
|
});
|
|
|
|
this.socket.on('error', (error) => {
|
|
if (this.isVerboseLogging()) {
|
|
console.error('🔌 SyncSocket: Error', error);
|
|
}
|
|
this.updateStatus('error');
|
|
});
|
|
|
|
// Message handling
|
|
this.socket.onAny((event, data) => {
|
|
if (this.isVerboseLogging()) {
|
|
console.log(`📥 SyncSocket: Received event '${event}':`, JSON.stringify(data).substring(0, 200));
|
|
}
|
|
const handler = this.messageHandlers.get(event);
|
|
if (handler) {
|
|
handler(data);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
//
|
|
// Singleton Export
|
|
//
|
|
|
|
export const apiSocket = new ApiSocket();
|