项目文件夹

文件
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:40:49 +08:00

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();