2ded310472
- Add a dedicated plan-approval bottom sheet (interactive-prompt) driven by ExitPlanMode's now-4-option layout (Claude Code v2.1.177), with approve/YOLO/reject/feedback wired through respondPlan(requestId). - PermissionManager: stop auto-expiring AskUserQuestion/plan prompts and stop releasing pending prompts on client disconnect — they reflect real CLI state and must survive reconnects. - Detect server-rejected tokens (401/403 or failed WS handshake) and bounce back to the login screen via a new AUTH_EXPIRED_EVENT. - Add `/cert` endpoint + Settings entry so phones can download/trust the self-signed HTTPS cert directly; fix cert IP detection on non-macOS hosts. - Add `clawtap reset-login` command and `/api/auth/reset-attempts` endpoint to clear login rate-limit lockouts. - Codex adapter: only forward recognized keystrokes/option indices to tmux, never the synthetic "deny" dismissal signal. - PWA: actively poll for service-worker updates (iOS standalone apps don't reliably check on navigation). - update-service.sh: install tmux if missing, prompt to create the env file instead of failing, add --skip-firewall, and improve logging.
154 lines
4.8 KiB
TypeScript
154 lines
4.8 KiB
TypeScript
import { WS } from './ws-types';
|
|
|
|
export type WsStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
|
|
|
|
type MessageHandler = (msg: any) => void;
|
|
type StatusHandler = (status: WsStatus) => void;
|
|
|
|
/** Minimum hidden duration (ms) before forcing reconnect on visibility change. */
|
|
const VISIBILITY_RECONNECT_THRESHOLD = 3000;
|
|
|
|
export class WsClient {
|
|
private ws: WebSocket | null = null;
|
|
private url: string;
|
|
private onMessage: MessageHandler;
|
|
private onStatus: StatusHandler;
|
|
private reconnectDelay = 1000;
|
|
private maxReconnectDelay = 30000;
|
|
private shouldReconnect = true;
|
|
private activeSessionId: string | null = null;
|
|
private activeAdapter: string | null = null;
|
|
private visibilityHandler: (() => void) | null = null;
|
|
private hiddenSince: number | null = null;
|
|
private openedThisAttempt = false;
|
|
private onAuthCheckNeeded: (() => void) | null = null;
|
|
|
|
constructor(token: string, onMessage: MessageHandler, onStatus: StatusHandler, onAuthCheckNeeded?: () => void) {
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
this.url = `${proto}://${location.host}/ws?token=${encodeURIComponent(token)}`;
|
|
this.onMessage = onMessage;
|
|
this.onStatus = onStatus;
|
|
this.onAuthCheckNeeded = onAuthCheckNeeded || null;
|
|
}
|
|
|
|
connect() {
|
|
this.shouldReconnect = true;
|
|
this.openedThisAttempt = false;
|
|
this.onStatus('connecting');
|
|
|
|
this.ws = new WebSocket(this.url);
|
|
|
|
this.ws.onopen = () => {
|
|
this.openedThisAttempt = true;
|
|
this.reconnectDelay = 1000;
|
|
this.onStatus('connected');
|
|
|
|
if (this.activeSessionId) {
|
|
this.send({ type: WS.RECONNECT, sessionId: this.activeSessionId, adapter: this.activeAdapter });
|
|
}
|
|
// Tell server our current visibility state immediately on (re)connect
|
|
this.send({ type: WS.PAGE_VISIBILITY, visible: document.visibilityState === 'visible' });
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === WS.SESSION_CREATED) {
|
|
this.activeSessionId = msg.sessionId;
|
|
if (msg.adapter) this.activeAdapter = msg.adapter;
|
|
}
|
|
// Respond to server app-ping only when page is visible.
|
|
// If hidden, no pong → server fires push after 2s timeout.
|
|
if (msg.type === WS.APP_PING) {
|
|
if (document.visibilityState === 'visible') {
|
|
this.send({ type: WS.APP_PONG, sessionId: msg.sessionId });
|
|
}
|
|
return;
|
|
}
|
|
this.onMessage(msg);
|
|
} catch (err) {
|
|
console.error('[ws] Failed to parse message:', err);
|
|
}
|
|
};
|
|
|
|
this.ws.onclose = () => {
|
|
this.onStatus('disconnected');
|
|
// The handshake itself failed (e.g. server rejected the auth token) —
|
|
// check whether our token is still valid rather than reconnecting forever.
|
|
if (!this.openedThisAttempt) {
|
|
this.onAuthCheckNeeded?.();
|
|
}
|
|
if (this.shouldReconnect) {
|
|
this.onStatus('reconnecting');
|
|
setTimeout(() => this.connect(), this.reconnectDelay);
|
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
|
}
|
|
};
|
|
|
|
this.ws.onerror = () => {
|
|
this.ws?.close();
|
|
};
|
|
|
|
this._startVisibilityWatch();
|
|
}
|
|
|
|
send(msg: any) {
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
this.ws.send(JSON.stringify(msg));
|
|
}
|
|
}
|
|
|
|
setActiveSession(sessionId: string | null, adapter?: string | null) {
|
|
this.activeSessionId = sessionId;
|
|
this.activeAdapter = adapter || null;
|
|
}
|
|
|
|
/** Force close and reconnect immediately (reset backoff). */
|
|
forceReconnect() {
|
|
if (!this.shouldReconnect) return;
|
|
this.reconnectDelay = 1000;
|
|
this.hiddenSince = null;
|
|
const old = this.ws;
|
|
this.ws = null;
|
|
if (old) {
|
|
old.onclose = null;
|
|
old.onerror = null;
|
|
old.onmessage = null;
|
|
old.close();
|
|
}
|
|
this.connect();
|
|
}
|
|
|
|
disconnect() {
|
|
this.shouldReconnect = false;
|
|
this._stopVisibilityWatch();
|
|
this.ws?.close();
|
|
this.ws = null;
|
|
}
|
|
|
|
private _startVisibilityWatch() {
|
|
if (this.visibilityHandler) return;
|
|
this.visibilityHandler = () => {
|
|
const visible = document.visibilityState === 'visible';
|
|
this.send({ type: WS.PAGE_VISIBILITY, visible });
|
|
if (!visible) {
|
|
this.hiddenSince = Date.now();
|
|
} else if (this.shouldReconnect) {
|
|
const elapsed = this.hiddenSince ? Date.now() - this.hiddenSince : 0;
|
|
this.hiddenSince = null;
|
|
if (elapsed >= VISIBILITY_RECONNECT_THRESHOLD) {
|
|
this.forceReconnect();
|
|
}
|
|
}
|
|
};
|
|
document.addEventListener('visibilitychange', this.visibilityHandler);
|
|
}
|
|
|
|
private _stopVisibilityWatch() {
|
|
if (this.visibilityHandler) {
|
|
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
|
this.visibilityHandler = null;
|
|
}
|
|
}
|
|
}
|