Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 120 additions & 20 deletions opennow-stable/src/main/gfn/signaling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,22 @@ export class GfnSignalingClient {
private peerId = 2;
private peerName = `peer-${Math.floor(Math.random() * 10_000_000_000)}`;
private ackCounter = 0;
private maxReceivedAckId = 0;
private heartbeatTimer: NodeJS.Timeout | null = null;
private reconnectTimer: NodeJS.Timeout | null = null;
private reconnectAttempts = 0;
private manualDisconnect = false;
private listeners = new Set<(event: MainToRendererSignalingEvent) => void>();
private static readonly MAX_RECONNECT_ATTEMPTS = 6;
private static readonly RECONNECT_BASE_DELAY_MS = 750;

constructor(
private readonly signalingServer: string,
private readonly sessionId: string,
private readonly signalingUrl?: string,
) {}

private buildSignInUrl(): string {
private buildSignInUrl(reconnect = false): string {
// Match Rust behavior: extract host:port from signalingUrl if available,
// since the signalingUrl contains the real server address (which may differ
// from signalingServer when the resource path was an rtsps:// URL)
Expand All @@ -58,7 +64,7 @@ export class GfnSignalingClient {
: `${this.signalingServer}:443`;
}

const url = `wss://${serverWithPort}/nvst/sign_in?peer_id=${this.peerName}&version=2`;
const url = `wss://${serverWithPort}/nvst/sign_in?peer_id=${this.peerName}&version=2&peer_role=1${reconnect ? "&reconnect=1" : ""}`;
console.log("[Signaling] URL:", url, "(server:", this.signalingServer, ", signalingUrl:", this.signalingUrl, ")");
return url;
}
Expand Down Expand Up @@ -88,9 +94,7 @@ export class GfnSignalingClient {

private setupHeartbeat(): void {
this.clearHeartbeat();
this.heartbeatTimer = setInterval(() => {
this.sendJson({ hb: 1 });
}, 5000);
// Official client does not proactively send signaling hb packets.
}

private clearHeartbeat(): void {
Expand All @@ -100,6 +104,47 @@ export class GfnSignalingClient {
}
}

private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}

private scheduleReconnect(closeReason: string): void {
if (this.manualDisconnect) {
return;
}
if (this.reconnectAttempts >= GfnSignalingClient.MAX_RECONNECT_ATTEMPTS) {
this.emit({ type: "disconnected", reason: `${closeReason} (reconnect exhausted)` });
return;
}
if (this.reconnectTimer) {
return;
}

this.reconnectAttempts += 1;
const attempt = this.reconnectAttempts;
const delayMs = Math.min(
5000,
GfnSignalingClient.RECONNECT_BASE_DELAY_MS * Math.pow(2, attempt - 1),
);
this.emit({
type: "log",
message: `Signaling reconnect attempt ${attempt}/${GfnSignalingClient.MAX_RECONNECT_ATTEMPTS} in ${delayMs}ms`,
});

this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
void this.connectSocket(true).catch((error) => {
const errorMsg = `Signaling reconnect failed: ${String(error)}`;
console.error("[Signaling]", errorMsg);
this.emit({ type: "error", message: errorMsg });
this.scheduleReconnect(errorMsg);
});
}, delayMs);
}

private sendPeerInfo(): void {
this.sendJson({
ackid: this.nextAckId(),
Expand All @@ -117,20 +162,27 @@ export class GfnSignalingClient {
}

async connect(): Promise<void> {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
this.manualDisconnect = false;
this.clearReconnectTimer();
this.reconnectAttempts = 0;
this.maxReceivedAckId = 0;
await this.connectSocket(false);
}

const url = this.buildSignInUrl();
private async connectSocket(reconnect: boolean): Promise<void> {
const url = this.buildSignInUrl(reconnect);
const protocol = `x-nv-sessionid.${this.sessionId}`;

console.log("[Signaling] Connecting to:", url);
console.log("[Signaling] Connecting to:", url, reconnect ? "(reconnect)" : "");
console.log("[Signaling] Session ID:", this.sessionId);
console.log("[Signaling] Protocol:", protocol);

await new Promise<void>((resolve, reject) => {
// Extract host:port for the Host header (matching Rust behavior)
const urlHost = url.replace(/^wss?:\/\//, "").split("/")[0];
let reconnectStabilized = !reconnect;

const ws = new WebSocket(url, protocol, {
rejectUnauthorized: false,
Expand All @@ -143,28 +195,66 @@ export class GfnSignalingClient {
});

this.ws = ws;
let opened = false;

ws.once("error", (error) => {
this.emit({ type: "error", message: `Signaling connect failed: ${String(error)}` });
reject(error);
if (!opened) {
this.emit({ type: "error", message: `Signaling connect failed: ${String(error)}` });
reject(error);
return;
}
const errorMsg = String(error);
console.error("[Signaling] WebSocket error during session:", errorMsg);
this.emit({ type: "error", message: `Signaling session error: ${errorMsg}` });
});

ws.once("open", () => {
this.sendPeerInfo();
this.setupHeartbeat();
opened = true;
this.manualDisconnect = false;
this.clearReconnectTimer();
if (!reconnect) {
this.sendPeerInfo();
this.setupHeartbeat();
}
this.emit({ type: "connected" });
resolve();
});

ws.on("message", (raw) => {
if (!reconnectStabilized && this.reconnectAttempts > 0) {
reconnectStabilized = true;
this.reconnectAttempts = 0;
this.emit({ type: "log", message: "Signaling reconnect stabilized" });
}
const text = typeof raw === "string" ? raw : raw.toString("utf8");
this.handleMessage(text);
});

ws.on("close", (_code, reason) => {
ws.on("close", (code, reason) => {
if (this.ws === ws) {
this.ws = null;
}
this.clearHeartbeat();
const reasonText = typeof reason === "string" ? reason : reason.toString("utf8");
this.emit({ type: "disconnected", reason: reasonText || "socket closed" });
const closeReason = reasonText || "socket closed";
console.log(`[Signaling] WebSocket closed - code: ${code}, reason: "${closeReason}"`);

if (!opened) {
reject(new Error(`Signaling socket closed before open: ${closeReason} (code: ${code})`));
return;
}

if (this.manualDisconnect) {
this.emit({ type: "disconnected", reason: `${closeReason} (code: ${code})` });
return;
}

if (code === 1006 || code === 1011 || code === 1001) {
this.scheduleReconnect(`${closeReason} (code: ${code})`);
return;
}

this.emit({ type: "disconnected", reason: `${closeReason} (code: ${code})` });
});
});
}
Expand All @@ -178,15 +268,22 @@ export class GfnSignalingClient {
return;
}

if (parsed.hb) {
// Official client ignores signaling hb payloads.
return;
}

let shouldProcessPayload = true;
if (typeof parsed.ackid === "number") {
const shouldAck = parsed.peer_info?.id !== this.peerId;
if (shouldAck) {
this.sendJson({ ack: parsed.ackid });
if (parsed.ackid <= this.maxReceivedAckId) {
shouldProcessPayload = false;
} else {
this.maxReceivedAckId = parsed.ackid;
}
this.sendJson({ ack: this.maxReceivedAckId });
}

if (parsed.hb) {
this.sendJson({ hb: 1 });
if (!shouldProcessPayload) {
return;
}

Expand Down Expand Up @@ -272,7 +369,10 @@ export class GfnSignalingClient {
}

disconnect(): void {
this.manualDisconnect = true;
this.clearHeartbeat();
this.clearReconnectTimer();
this.reconnectAttempts = 0;
if (this.ws) {
this.ws.close();
this.ws = null;
Expand Down
13 changes: 13 additions & 0 deletions opennow-stable/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ async function createMainWindow(): Promise<void> {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
backgroundThrottling: false,
v8CacheOptions: "code",
},
});

Expand Down Expand Up @@ -441,6 +443,17 @@ function registerIpcHandlers(): void {
return signalingClient.sendIceCandidate(payload);
});

// Power save blocker handlers - DISABLED to fix Windows stream start issues
ipcMain.handle(IPC_CHANNELS.POWER_SAVE_BLOCKER_START, async (): Promise<void> => {
// Disabled: power save blocker was causing stream start issues on Windows after sleep
console.log("[Main] Power save blocker start requested (disabled)");
});

ipcMain.handle(IPC_CHANNELS.POWER_SAVE_BLOCKER_STOP, async (): Promise<void> => {
// Disabled: power save blocker was causing stream start issues on Windows after sleep
console.log("[Main] Power save blocker stop requested (disabled)");
});

// Toggle fullscreen via IPC (for completeness)
ipcMain.handle(IPC_CHANNELS.TOGGLE_FULLSCREEN, async () => {
if (mainWindow && !mainWindow.isDestroyed()) {
Expand Down
2 changes: 2 additions & 0 deletions opennow-stable/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ const api: PreloadApi = {
resetSettings: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_RESET),
exportLogs: (format?: "text" | "json") => ipcRenderer.invoke(IPC_CHANNELS.LOGS_EXPORT, format),
pingRegions: (regions: StreamRegion[]) => ipcRenderer.invoke(IPC_CHANNELS.PING_REGIONS, regions),
startPowerSaveBlocker: () => ipcRenderer.invoke(IPC_CHANNELS.POWER_SAVE_BLOCKER_START),
stopPowerSaveBlocker: () => ipcRenderer.invoke(IPC_CHANNELS.POWER_SAVE_BLOCKER_STOP),
};

contextBridge.exposeInMainWorld("openNow", api);
Loading