Files
2026-06-08 14:25:31 +03:00

3068 lines
124 KiB
JavaScript

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ElectronCapacitorApp = void 0;
exports.loadPersistedAllowedDomainsAtStartup = loadPersistedAllowedDomainsAtStartup;
exports.setupReloadWatcher = setupReloadWatcher;
exports.setupContentSecurityPolicy = setupContentSecurityPolicy;
exports.getSharedSettingsFilePath = getSharedSettingsFilePath;
exports.flushPersistentStore = flushPersistentStore;
exports.flushMiscPersistentStore = flushMiscPersistentStore;
exports.readAppSettings = readAppSettings;
exports.broadcastProgress = broadcastProgress;
exports.notifyPresenceTransportReady = notifyPresenceTransportReady;
exports.setLastP2POptions = setLastP2POptions;
exports.attachP2PListeners = attachP2PListeners;
exports.startDecentralizedStunAfterP2P = startDecentralizedStunAfterP2P;
exports.ensureReticulumManagersStarted = ensureReticulumManagersStarted;
exports.replayReticulumCachedPresence = replayReticulumCachedPresence;
exports.attachPresenceListeners = attachPresenceListeners;
exports.clearLateReticulumBridgeRecovery = clearLateReticulumBridgeRecovery;
exports.registerLateReticulumBridgeRecovery = registerLateReticulumBridgeRecovery;
exports.attachChatListeners = attachChatListeners;
exports.attachCallListeners = attachCallListeners;
exports.attachGroupCallListeners = attachGroupCallListeners;
const tslib_1 = require("tslib");
const electron_1 = require("@capacitor-community/electron");
const chokidar_1 = tslib_1.__importDefault(require("chokidar"));
const electron_2 = require("electron");
const electron_is_dev_1 = tslib_1.__importDefault(require("electron-is-dev"));
const electron_window_state_1 = tslib_1.__importDefault(require("electron-window-state"));
const path_1 = require("path");
const logger_1 = require("./logger");
const _1 = require(".");
const core_1 = require("./core");
const local_https_cert_1 = require("./local-https-cert");
const video_server_1 = require("./video-server");
const p2p_network_1 = require("./p2p-network");
const stun_coordinator_1 = require("./stun-coordinator");
const presence_1 = require("./presence");
const chat_1 = require("./chat");
const call_1 = require("./call");
const group_call_1 = require("./group-call");
const reticulum_bridge_1 = require("./reticulum-bridge");
const reticulum_daemon_1 = require("./reticulum-daemon");
const reticulum_mesh_1 = require("./reticulum-mesh");
const feature_flags_1 = require("./feature-flags");
const audio_window_policy_1 = require("./audio-window-policy");
const audio_surface_https_1 = require("./audio-surface-https");
const audio_surface_ipc_1 = require("./audio-surface-ipc");
const app_protocol_1 = require("./app-protocol");
const system_call_readiness_1 = require("./system-call-readiness");
const GCALL_AUDIO_RENDERER_SEND_AT_MS = Symbol.for('qortal.gcallAudioRendererSendAtMs');
const GCALL_AUDIO_MAIN_IPC_AT_MS = Symbol.for('qortal.gcallAudioMainIpcAtMs');
const OPEN_DEVTOOLS_IN_DEVELOPMENT = false;
const GCALL_AUDIO_IPC_DELAY_LOG_THRESHOLD_MS = 80;
const GCALL_MAIN_LOOP_SAMPLE_INTERVAL_MS = 50;
const GCALL_MAIN_LOOP_STALL_LOG_THRESHOLD_MS = 80;
const GCALL_MAIN_LOOP_STALL_RECENT_LIMIT = 16;
const GCALL_MAIN_LOOP_STALL_LOG_THROTTLE_MS = 1000;
let mainLoopExpectedAtMs = Date.now() + GCALL_MAIN_LOOP_SAMPLE_INTERVAL_MS;
let mainLoopStallCount = 0;
let mainLoopStallMaxDelayMs = 0;
let mainLoopLastStallAtMs = 0;
let mainLoopLastStallDelayMs = 0;
let mainLoopLastLogAtMs = 0;
const mainLoopRecentStalls = [];
function recordMainLoopStall(delayMs, nowMs = Date.now()) {
mainLoopStallCount++;
mainLoopStallMaxDelayMs = Math.max(mainLoopStallMaxDelayMs, delayMs);
mainLoopLastStallAtMs = nowMs;
mainLoopLastStallDelayMs = delayMs;
mainLoopRecentStalls.push({ atMs: nowMs, delayMs });
while (mainLoopRecentStalls.length > GCALL_MAIN_LOOP_STALL_RECENT_LIMIT) {
mainLoopRecentStalls.shift();
}
if (nowMs - mainLoopLastLogAtMs < GCALL_MAIN_LOOP_STALL_LOG_THROTTLE_MS) {
return;
}
mainLoopLastLogAtMs = nowMs;
(0, logger_1.warn)(`[GCall] target=reticulum-audio-ipc stage=main-event-loop-stall delay_ms=${Math.round(delayMs)} stall_count=${mainLoopStallCount} max_delay_ms=${Math.round(mainLoopStallMaxDelayMs)}`);
}
const mainLoopMonitorTimer = setInterval(() => {
const nowMs = Date.now();
const delayMs = Math.max(0, nowMs - mainLoopExpectedAtMs);
mainLoopExpectedAtMs = nowMs + GCALL_MAIN_LOOP_SAMPLE_INTERVAL_MS;
if (delayMs >= GCALL_MAIN_LOOP_STALL_LOG_THRESHOLD_MS) {
recordMainLoopStall(delayMs, nowMs);
}
}, GCALL_MAIN_LOOP_SAMPLE_INTERVAL_MS);
mainLoopMonitorTimer.unref?.();
function getMainLoopIpcTimingDetail(rendererSendAtMs, nowMs) {
const lastStallAgeMs = mainLoopLastStallAtMs > 0 ? Math.max(0, nowMs - mainLoopLastStallAtMs) : -1;
const currentLagMs = Math.max(0, nowMs - mainLoopExpectedAtMs);
let recentStallMaxMs = 0;
let stallSinceRendererMaxMs = 0;
for (const sample of mainLoopRecentStalls) {
if (nowMs - sample.atMs <= 5000) {
recentStallMaxMs = Math.max(recentStallMaxMs, sample.delayMs);
}
if (sample.atMs >= rendererSendAtMs) {
stallSinceRendererMaxMs = Math.max(stallSinceRendererMaxMs, sample.delayMs);
}
}
return {
currentLagMs,
lastStallAgeMs,
lastStallDelayMs: mainLoopLastStallDelayMs,
recentStallMaxMs,
stallSinceRendererMaxMs,
stallCount: mainLoopStallCount,
stallMaxDelayMs: mainLoopStallMaxDelayMs,
};
}
function attachGroupAudioIpcTiming(buf, timing, context) {
const rendererSendAtMs = timing?.rendererSendAtWallMs;
const mainIpcAtMs = Date.now();
if (typeof rendererSendAtMs === 'number' &&
Number.isFinite(rendererSendAtMs) &&
rendererSendAtMs > 0) {
Object.defineProperty(buf, GCALL_AUDIO_RENDERER_SEND_AT_MS, {
value: rendererSendAtMs,
enumerable: false,
configurable: true,
});
const rendererToMainMs = Math.max(0, mainIpcAtMs - rendererSendAtMs);
if (rendererToMainMs >= GCALL_AUDIO_IPC_DELAY_LOG_THRESHOLD_MS) {
const mainLoopTiming = getMainLoopIpcTimingDetail(rendererSendAtMs, mainIpcAtMs);
(0, logger_1.warn)(`[GCall] target=reticulum-audio-ipc stage=gcall-audio-ipc-handler-entry-delay channel=${context?.channel ?? 'unknown'} room=${context?.roomId ?? 'n/a'} target_count=${context?.targetCount ?? 0} delay_ms=${Math.round(rendererToMainMs)} main_loop_current_lag_ms=${Math.round(mainLoopTiming.currentLagMs)} main_loop_last_stall_ms=${Math.round(mainLoopTiming.lastStallDelayMs)} main_loop_last_stall_age_ms=${Math.round(mainLoopTiming.lastStallAgeMs)} main_loop_recent_stall_max_ms=${Math.round(mainLoopTiming.recentStallMaxMs)} main_loop_stall_since_renderer_max_ms=${Math.round(mainLoopTiming.stallSinceRendererMaxMs)} main_loop_stall_count=${mainLoopTiming.stallCount} main_loop_stall_max_ms=${Math.round(mainLoopTiming.stallMaxDelayMs)}`);
}
}
Object.defineProperty(buf, GCALL_AUDIO_MAIN_IPC_AT_MS, {
value: mainIpcAtMs,
enumerable: false,
configurable: true,
});
}
const AdmZip = require('adm-zip');
const fs = require('fs');
const path = require('path');
const writeFileAtomic = require('write-file-atomic');
const defaultDomains = [
'capacitor-electron://-',
'http://127.0.0.1:12391',
'https://127.0.0.1:12391',
'ws://127.0.0.1:12391',
'wss://127.0.0.1:12391',
'https://ext-node.qortal.link',
'wss://ext-node.qortal.link',
'https://appnode.qortal.org',
'wss://appnode.qortal.org',
'https://api.qortal.org',
'https://api2.qortal.org',
'https://apinode.qortalnodes.live',
'https://apinode1.qortalnodes.live',
'https://apinode2.qortalnodes.live',
'https://apinode3.qortalnodes.live',
'https://apinode4.qortalnodes.live',
'https://www.qort.trade',
];
// let allowedDomains: string[] = [...defaultDomains]
const domainHolder = {
allowedDomains: [...defaultDomains],
};
/** Same path layout as `getSharedSettingsFilePath('wallet-storage.json')` (preload `walletStorage`). */
function getWalletStorageJsonPathSync() {
return path.join(electron_2.app.getPath('appData'), 'qortal-hub', 'wallet-storage.json');
}
function readCustomNodeUrlsFromWalletStorageFile() {
try {
const filePath = getWalletStorageJsonPathSync();
if (!fs.existsSync(filePath))
return [];
const raw = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw);
const nodes = data?.customNodes;
if (!Array.isArray(nodes))
return [];
return nodes
.map((n) => typeof n?.url === 'string' ? n.url.trim() : '')
.filter(Boolean);
}
catch {
return [];
}
}
function mergeUserDomainsIntoAllowlist(domains) {
const validatedUserDomains = domains
.flatMap((domain) => {
try {
const url = new URL(domain);
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
const socketUrl = `${protocol}//${url.hostname}${url.port ? ':' + url.port : ''}`;
return [url.origin, socketUrl];
}
catch {
return [];
}
})
.filter(Boolean);
return [...new Set([...defaultDomains, ...validatedUserDomains])];
}
function applyAllowedDomainsFromUserUrls(domains, options) {
if (!Array.isArray(domains)) {
return;
}
const newAllowedDomains = mergeUserDomainsIntoAllowlist(domains);
const sortedCurrentDomains = [...domainHolder.allowedDomains].sort();
const sortedNewDomains = [...newAllowedDomains].sort();
const hasChanged = sortedCurrentDomains.length !== sortedNewDomains.length ||
sortedCurrentDomains.some((domain, index) => domain !== sortedNewDomains[index]);
if (hasChanged) {
domainHolder.allowedDomains = newAllowedDomains;
if (options.reloadWindow) {
const mainWindow = _1.myCapacitorApp.getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.reload();
}
}
}
}
/** Apply custom node URLs from wallet storage before the web app loads (no window reload). */
function loadPersistedAllowedDomainsAtStartup() {
const urls = readCustomNodeUrlsFromWalletStorageFile();
applyAllowedDomainsFromUserUrls(urls, { reloadWindow: false });
}
// Define components for a watcher to detect when the webapp is changed so we can reload in Dev mode.
const reloadWatcher = {
debouncer: null,
ready: false,
watcher: null,
};
const isolatedAudioSurfaceContents = new Set();
const audioSurfaceSubscribers = new Set();
const pendingAudioSurfaceCommands = new Map();
const AUDIO_SURFACE_IDLE_CLOSE_MS = 90000;
const AUDIO_SURFACE_READY_TIMEOUT_MS = 10000;
let audioSurfaceHostReady = false;
const audioSurfaceReadyResolvers = [];
let audioSurfaceBridgeState = (0, audio_surface_ipc_1.buildDefaultAudioSurfaceBridgeStateLike)();
function isMainShellSender(sender) {
const mainWindow = _1.myCapacitorApp?.getMainWindow?.();
return Boolean(mainWindow &&
!mainWindow.isDestroyed() &&
mainWindow.webContents.id === sender.id);
}
/**
* Trust only the hidden audio-surface window (webContents id captured at creation).
* Comparing to getAudioSurfaceWindow() is fragile if references or lifetimes diverge.
*/
function isAudioSurfaceHostSender(sender) {
return isolatedAudioSurfaceContents.has(sender.id);
}
function waitForAudioSurfaceHostReady() {
if (audioSurfaceHostReady) {
return Promise.resolve();
}
return new Promise((resolve) => {
let settled = false;
let timeout = null;
const resolveReady = () => {
if (settled)
return;
settled = true;
if (timeout) {
clearTimeout(timeout);
}
resolve();
};
timeout = setTimeout(() => {
if (settled)
return;
settled = true;
const resolverIndex = audioSurfaceReadyResolvers.indexOf(resolveReady);
if (resolverIndex !== -1) {
audioSurfaceReadyResolvers.splice(resolverIndex, 1);
}
(0, logger_1.warn)('[GCall:audio-surface] host ready wait timed out', {
timeoutMs: AUDIO_SURFACE_READY_TIMEOUT_MS,
});
resolve();
}, AUDIO_SURFACE_READY_TIMEOUT_MS);
audioSurfaceReadyResolvers.push(resolveReady);
});
}
function markAudioSurfaceHostReady() {
audioSurfaceHostReady = true;
audioSurfaceBridgeState = {
...audioSurfaceBridgeState,
hostReady: true,
};
for (const resolve of audioSurfaceReadyResolvers.splice(0)) {
resolve();
}
}
function markAudioSurfaceHostClosed() {
audioSurfaceHostReady = false;
audioSurfaceBridgeState = (0, audio_surface_ipc_1.buildDefaultAudioSurfaceBridgeStateLike)();
for (const resolve of audioSurfaceReadyResolvers.splice(0)) {
resolve();
}
for (const [, pending] of pendingAudioSurfaceCommands) {
pending.reject(new Error('audio-surface-window-closed'));
}
pendingAudioSurfaceCommands.clear();
for (const webContents of audioSurfaceSubscribers) {
if (!webContents.isDestroyed()) {
webContents.send('audio-surface:event', {
type: 'engine-closed',
});
}
}
}
function emitAudioSurfaceEvent(event) {
if (event.type === 'engine-ready') {
audioSurfaceBridgeState = {
...audioSurfaceBridgeState,
hostReady: true,
bootstrapRevisionApplied: event.bootstrapRevisionApplied,
};
}
else if (event.type === 'snapshot') {
audioSurfaceBridgeState = {
...audioSurfaceBridgeState,
snapshot: event.snapshot,
};
}
for (const webContents of audioSurfaceSubscribers) {
if (!webContents.isDestroyed()) {
webContents.send('audio-surface:event', event);
}
}
}
function setupReloadWatcher(electronCapacitorApp) {
reloadWatcher.watcher = chokidar_1.default
.watch((0, path_1.join)(electron_2.app.getAppPath(), 'app'), {
ignored: /[/\\]\./,
persistent: true,
})
.on('ready', () => {
reloadWatcher.ready = true;
})
.on('all', (_event, _path) => {
if (reloadWatcher.ready) {
clearTimeout(reloadWatcher.debouncer);
reloadWatcher.debouncer = setTimeout(async () => {
electronCapacitorApp.getMainWindow().webContents.reload();
reloadWatcher.ready = false;
clearTimeout(reloadWatcher.debouncer);
reloadWatcher.debouncer = null;
reloadWatcher.watcher = null;
setupReloadWatcher(electronCapacitorApp);
}, 1500);
}
});
}
// Define our class to manage our app.
class ElectronCapacitorApp {
constructor(capacitorFileConfig, trayMenuTemplate, appMenuBarMenuTemplate) {
this.MainWindow = null;
this.AudioSurfaceWindow = null;
this.SplashScreen = null;
this.TrayIcon = null;
this.TrayMenuTemplate = [
new electron_2.MenuItem({ label: 'Quit App', role: 'quit' }),
];
this.AppMenuBarMenuTemplate = [
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
{ role: 'viewMenu' },
{ role: 'editMenu' },
];
this.audioSurfaceHttpsOrigin = null;
this.audioSurfaceWindowReady = null;
this.audioSurfaceIdleCloseTimer = null;
this.CapacitorFileConfig = capacitorFileConfig;
this.customScheme =
this.CapacitorFileConfig.electron?.customUrlScheme ??
'capacitor-electron';
this.audioSurfaceScheme = (0, audio_window_policy_1.buildAudioSurfaceScheme)(this.customScheme);
if (trayMenuTemplate) {
this.TrayMenuTemplate = trayMenuTemplate;
}
if (appMenuBarMenuTemplate) {
this.AppMenuBarMenuTemplate = appMenuBarMenuTemplate;
}
// Setup our web app loader, this lets us load apps like react, vue, and angular without changing their build chains.
this.loadWebApp = async (window) => {
await window.loadURL(`${this.customScheme}://-`);
};
}
// Helper function to load in the app.
async loadMainWindow(thisRef) {
await thisRef.loadWebApp(thisRef.MainWindow);
}
// Expose the mainWindow ref for use outside of the class.
getMainWindow() {
return this.MainWindow;
}
getCustomURLScheme() {
return this.customScheme;
}
getAudioSurfaceWindow() {
return this.AudioSurfaceWindow;
}
async ensureAudioSurfaceWindow() {
this.cancelAudioSurfaceIdleClose('ensure');
if (this.AudioSurfaceWindow && !this.AudioSurfaceWindow.isDestroyed()) {
return this.AudioSurfaceWindow;
}
if (this.audioSurfaceWindowReady) {
return this.audioSurfaceWindowReady;
}
this.audioSurfaceWindowReady = this.createAudioSurfaceWindow();
try {
return await this.audioSurfaceWindowReady;
}
finally {
this.audioSurfaceWindowReady = null;
}
}
async createAudioSurfaceWindow() {
if (!this.MainWindow || this.MainWindow.isDestroyed()) {
throw new Error('Main window must exist before creating audio surface');
}
const preloadPath = (0, path_1.join)(electron_2.app.getAppPath(), 'build', 'src', 'audio-surface-preload.js');
const window = new electron_2.BrowserWindow({
show: false,
width: 320,
height: 240,
frame: false,
transparent: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
// The hidden audio surface should behave like a normal isolated web page.
// Node-enabled or unsandboxed renderers do not qualify for
// cross-origin isolation / SharedArrayBuffer in Electron.
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
preload: preloadPath,
additionalArguments: [`--window-role=${audio_window_policy_1.AUDIO_SURFACE_WINDOW_ROLE}`],
},
});
this.AudioSurfaceWindow = window;
const webContentsId = window.webContents.id;
isolatedAudioSurfaceContents.add(webContentsId);
window.on('closed', () => {
isolatedAudioSurfaceContents.delete(webContentsId);
if (this.AudioSurfaceWindow === window) {
this.AudioSurfaceWindow = null;
}
markAudioSurfaceHostClosed();
});
const targetUrl = (0, audio_window_policy_1.buildAudioSurfaceUrl)(this.audioSurfaceHttpsOrigin ?? this.MainWindow.webContents.getURL(), this.customScheme, this.audioSurfaceScheme);
(0, logger_1.log)('[GCall:audio-surface] create window target', {
mainWindowUrl: this.MainWindow.webContents.getURL(),
targetUrl,
webContentsId,
});
window.webContents.on('did-finish-load', () => {
(0, logger_1.log)('[GCall:audio-surface] did-finish-load', {
url: window.webContents.getURL(),
webContentsId,
});
void window.webContents
.executeJavaScript(`({
href: location.href,
origin: location.origin,
crossOriginIsolated: typeof crossOriginIsolated === 'boolean' ? crossOriginIsolated : null,
sharedArrayBufferDefined: typeof SharedArrayBuffer !== 'undefined'
})`, true)
.then((state) => {
(0, logger_1.log)('[GCall:audio-surface] runtime isolation probe', {
webContentsId,
...state,
});
})
.catch((error) => {
(0, logger_1.warn)('[GCall:audio-surface] runtime isolation probe failed', {
webContentsId,
message: error instanceof Error ? error.message : String(error),
});
});
});
await window.loadURL(targetUrl);
if (electron_is_dev_1.default && OPEN_DEVTOOLS_IN_DEVELOPMENT) {
try {
window.webContents.openDevTools({ mode: 'detach' });
(0, logger_1.log)('[GCall:audio-surface] dev: opened DevTools for audio-surface window');
}
catch (e) {
(0, logger_1.warn)('[GCall:audio-surface] dev: openDevTools failed', e);
}
}
return window;
}
cancelAudioSurfaceIdleClose(reason) {
if (!this.audioSurfaceIdleCloseTimer)
return;
clearTimeout(this.audioSurfaceIdleCloseTimer);
this.audioSurfaceIdleCloseTimer = null;
(0, logger_1.log)('[GCall:audio-surface] idle close canceled', { reason });
}
scheduleAudioSurfaceIdleClose(reason) {
if (!this.AudioSurfaceWindow || this.AudioSurfaceWindow.isDestroyed()) {
return;
}
this.cancelAudioSurfaceIdleClose('reschedule');
(0, logger_1.log)('[GCall:audio-surface] idle close scheduled', {
reason,
delayMs: AUDIO_SURFACE_IDLE_CLOSE_MS,
});
this.audioSurfaceIdleCloseTimer = setTimeout(() => {
this.audioSurfaceIdleCloseTimer = null;
this.closeAudioSurfaceWindow(`idle-timeout:${reason}`);
}, AUDIO_SURFACE_IDLE_CLOSE_MS);
}
closeAudioSurfaceWindow(reason) {
this.cancelAudioSurfaceIdleClose('close');
const audioWindow = this.AudioSurfaceWindow;
if (!audioWindow || audioWindow.isDestroyed()) {
markAudioSurfaceHostClosed();
return;
}
(0, logger_1.log)('[GCall:audio-surface] closing window', {
reason,
webContentsId: audioWindow.webContents.id,
});
audioWindow.close();
}
async init(p2pBootstrapSeeds) {
await (0, app_protocol_1.registerStaticAppProtocol)(electron_2.session.defaultSession, this.customScheme, (0, path_1.join)(electron_2.app.getAppPath(), 'app'));
await (0, app_protocol_1.registerStaticAppProtocol)(electron_2.session.defaultSession, this.audioSurfaceScheme, (0, path_1.join)(electron_2.app.getAppPath(), 'app'));
this.audioSurfaceHttpsOrigin = await (0, audio_surface_https_1.ensureAudioSurfaceHttpsServer)((0, path_1.join)(electron_2.app.getAppPath(), 'app'));
const icon = electron_2.nativeImage.createFromPath((0, path_1.join)(electron_2.app.getAppPath(), 'assets', process.platform === 'win32' ? 'appIcon.ico' : 'appIcon.png'));
this.mainWindowState = (0, electron_window_state_1.default)({
defaultWidth: 1000,
defaultHeight: 800,
});
// Setup preload script path and construct our main window.
const preloadPath = (0, path_1.join)(electron_2.app.getAppPath(), 'build', 'src', 'preload.js');
const seedsPayload = JSON.stringify({
v: 1,
seeds: Array.isArray(p2pBootstrapSeeds) ? p2pBootstrapSeeds : [],
});
const seedsB64 = Buffer.from(seedsPayload, 'utf8').toString('base64');
this.MainWindow = new electron_2.BrowserWindow({
icon,
show: false,
x: this.mainWindowState.x,
y: this.mainWindowState.y,
width: this.mainWindowState.width,
height: this.mainWindowState.height,
backgroundColor: '#27282c',
frame: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: true,
preload: preloadPath,
additionalArguments: [
`--hub-p2p-seeds=${seedsB64}`,
`--window-role=${audio_window_policy_1.MAIN_WINDOW_ROLE}`,
],
},
});
this.mainWindowState.manage(this.MainWindow);
this.MainWindow.on('maximize', () => {
this.MainWindow?.webContents.send('window:state-changed', true);
});
this.MainWindow.on('unmaximize', () => {
this.MainWindow?.webContents.send('window:state-changed', false);
});
// Allow microphone access for voice calls.
const summarizeMediaPermissionDetails = (details) => {
if (!details || typeof details !== 'object')
return {};
const d = details;
const out = {};
if (typeof d.requestingUrl === 'string')
out.requestingUrl = d.requestingUrl;
if (typeof d.isMainFrame === 'boolean')
out.isMainFrame = d.isMainFrame;
if (Array.isArray(d.mediaTypes))
out.mediaTypes = d.mediaTypes;
if (typeof d.securityOrigin === 'string')
out.securityOrigin = d.securityOrigin;
return out;
};
// TODO: Restore if mic permissions don't work
// this.MainWindow.webContents.session.setPermissionRequestHandler(
// (_webContents, permission, callback, details) => {
// const summary = summarizeMediaPermissionDetails(details);
// const granted = permission === 'media';
// loggerLog('[GCall][perm] request', { permission, granted, ...summary });
// if (granted) return callback(true);
// loggerWarn(
// '[GCall][perm] denied — handler only auto-allows "media"; got:',
// permission,
// summary
// );
// callback(false);
// }
// );
if (this.CapacitorFileConfig.backgroundColor) {
this.MainWindow.setBackgroundColor(this.CapacitorFileConfig.electron.backgroundColor);
}
// Close window: use saved preference (from SharedSettingsFilePath) or ask user.
// Must call event.preventDefault() synchronously so the window does not close before we decide.
this.MainWindow.on('close', async (event) => {
if (!_1.isQuitting) {
event.preventDefault();
const appSettings = await readAppSettings();
const closeAction = appSettings.closeAction ?? 'ask';
if (closeAction === 'minimizeToTray') {
this.MainWindow.hide();
return;
}
if (closeAction === 'quit') {
(0, _1.setIsQuitting)(true);
electron_2.app.quit();
return;
}
// closeAction === 'ask': show dialog
const backgroundText = process.platform === 'darwin'
? 'Minimize to Dock'
: 'Minimize to Tray';
const backgroundDetail = process.platform === 'darwin'
? 'Keep the app running in the dock'
: 'Keep the app running in the system tray';
const choice = await electron_2.dialog.showMessageBox(this.MainWindow, {
type: 'question',
buttons: [backgroundText, 'Quit Completely', 'Cancel'],
defaultId: 0,
title: 'Close Qortal Hub',
message: 'What would you like to do?',
detail: `${backgroundText}: ${backgroundDetail}\n\nQuit Completely: Stop the application entirely`,
cancelId: 2,
});
if (choice.response === 0) {
this.MainWindow.hide();
}
else if (choice.response === 1) {
(0, _1.setIsQuitting)(true);
electron_2.app.quit();
}
}
});
// If we close the main window with the splashscreen enabled we need to destroy the ref.
this.MainWindow.on('closed', () => {
if (this.SplashScreen?.getSplashWindow() &&
!this.SplashScreen.getSplashWindow().isDestroyed()) {
this.SplashScreen.getSplashWindow().close();
}
});
// When the tray icon is enabled, setup the options.
if (this.CapacitorFileConfig.electron?.trayIconAndMenuEnabled) {
// On macOS, use dock instead of menu bar tray icon (more conventional)
// On Windows and Linux, use the system tray icon
if (process.platform !== 'darwin') {
this.TrayIcon = new electron_2.Tray(icon);
// On Windows, single-click shows context menu (handled automatically by the OS)
// On Linux, single-click toggles window visibility
if (process.platform !== 'win32') {
this.TrayIcon.on('click', () => {
if (this.MainWindow) {
if (this.MainWindow.isVisible()) {
this.MainWindow.hide();
}
else {
this.MainWindow.show();
this.MainWindow.focus();
}
}
});
}
// Double-click toggles window visibility on all platforms
this.TrayIcon.on('double-click', () => {
if (this.MainWindow) {
if (this.MainWindow.isVisible()) {
this.MainWindow.hide();
}
else {
this.MainWindow.show();
this.MainWindow.focus();
}
}
});
this.TrayIcon.setToolTip(electron_2.app.getName());
this.TrayIcon.setContextMenu(electron_2.Menu.buildFromTemplate(this.TrayMenuTemplate));
}
}
// Setup the main manu bar at the top of our window.
electron_2.Menu.setApplicationMenu(electron_2.Menu.buildFromTemplate(this.AppMenuBarMenuTemplate));
// If the splashscreen is enabled, show it first while the main window loads then switch it out for the main window, or just load the main window from the start.
if (this.CapacitorFileConfig.electron?.splashScreenEnabled) {
this.SplashScreen = new electron_1.CapacitorSplashScreen({
imageFilePath: (0, path_1.join)(electron_2.app.getAppPath(), 'assets', this.CapacitorFileConfig.electron?.splashScreenImageName ??
'splash.png'),
windowWidth: 400,
windowHeight: 400,
});
this.SplashScreen.init(this.loadMainWindow, this);
}
else {
this.loadMainWindow(this);
}
// Security
this.MainWindow.webContents.setWindowOpenHandler((details) => {
if (!details.url.includes(this.customScheme)) {
return { action: 'deny' };
}
else {
return { action: 'allow' };
}
});
this.MainWindow.webContents.on('will-navigate', (event, _newURL) => {
if (!this.MainWindow.webContents.getURL().includes(this.customScheme)) {
event.preventDefault();
}
});
// Link electron plugins into the system.
(0, electron_1.setupCapacitorElectronPlugins)();
// When the web app is loaded we hide the splashscreen if needed and show the mainwindow.
this.MainWindow.webContents.on('dom-ready', () => {
if (this.CapacitorFileConfig.electron?.splashScreenEnabled) {
this.SplashScreen.getSplashWindow().hide();
}
if (!this.CapacitorFileConfig.electron?.hideMainWindowOnLaunch) {
this.MainWindow.show();
}
setTimeout(() => {
if (electron_is_dev_1.default && OPEN_DEVTOOLS_IN_DEVELOPMENT) {
this.MainWindow.webContents.openDevTools();
}
electron_1.CapElectronEventEmitter.emit('CAPELECTRON_DeeplinkListenerInitialized', '');
}, 400);
});
}
}
exports.ElectronCapacitorApp = ElectronCapacitorApp;
function setupContentSecurityPolicy(customScheme) {
electron_2.session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const requestUrl = details.url;
const expandedDomains = [...domainHolder.allowedDomains];
for (const d of domainHolder.allowedDomains) {
try {
const url = new URL(d);
if ((0, local_https_cert_1.isLocalPrivateHost)(url.hostname)) {
const hostPort = url.port
? `${url.hostname}:${url.port}`
: url.hostname;
expandedDomains.push(`http://${hostPort}`, `https://${hostPort}`, `ws://${hostPort}`, `wss://${hostPort}`);
}
}
catch {
/* ignore */
}
}
const allowedSources = [
"'self'",
customScheme,
...new Set(expandedDomains),
];
const frameSources = [
"'self'",
'http://localhost:*',
'https://localhost:*',
'ws://localhost:*',
'ws://127.0.0.1:*',
'http://127.0.0.1:*',
'https://127.0.0.1:*',
...allowedSources,
];
const isHubShellRequest = requestUrl.startsWith(`${customScheme}://`);
const inlineScriptSource = isHubShellRequest ? '' : " 'unsafe-inline'";
const evalScriptSource = isHubShellRequest ? '' : " 'unsafe-eval'";
const wasmEvalScriptSource = isHubShellRequest
? ''
: " 'wasm-unsafe-eval'";
// Create the Content Security Policy (CSP) string
const csp = `
default-src 'self' ${frameSources.join(' ')};
frame-src ${frameSources.join(' ')};
script-src 'self'${wasmEvalScriptSource}${evalScriptSource}${inlineScriptSource} ${frameSources.join(' ')};
worker-src 'self' blob: data: ${frameSources.join(' ')};
object-src 'self';
connect-src 'self' blob: ${frameSources.join(' ')};
img-src 'self' data: blob: ${frameSources.join(' ')};
media-src 'self' blob: ${frameSources.join(' ')};
style-src 'self' 'unsafe-inline';
font-src 'self' data:;
`
.replace(/\s+/g, ' ')
.trim();
// Get the request URL and origin
const requestOrigin = details.origin || details.referrer || 'capacitor-electron://-';
// Parse the request URL to get its origin
let requestUrlOrigin;
try {
const parsedUrl = new URL(requestUrl);
requestUrlOrigin = parsedUrl.origin;
}
catch (e) {
// Handle invalid URLs gracefully
requestUrlOrigin = '';
}
// Determine if the request is cross-origin
const isCrossOrigin = requestOrigin !== requestUrlOrigin;
// Check if the response already includes Access-Control-Allow-Origin
const hasAccessControlAllowOrigin = Object.keys(details.responseHeaders).some((header) => header.toLowerCase() === 'access-control-allow-origin');
// Prepare response headers: remove any existing CSP (e.g. from node over HTTPS)
// so only our permissive CSP is applied and qapps (e.g. extract7z) can use eval.
const cspHeaderLower = 'content-security-policy';
const filtered = Object.fromEntries(Object.entries(details.responseHeaders).filter(([key]) => key.toLowerCase() !== cspHeaderLower));
const responseHeaders = {
...filtered,
'Content-Security-Policy': [csp],
};
Object.assign(responseHeaders, (0, audio_window_policy_1.withAudioSurfaceIsolationHeaders)(responseHeaders, {
url: details.url,
resourceType: details.resourceType,
origin: details.origin,
referrer: details.referrer,
}));
if (isCrossOrigin && !hasAccessControlAllowOrigin) {
// Handle CORS for cross-origin requests lacking CORS headers
// Optionally, check if the requestOrigin is allowed
responseHeaders['Access-Control-Allow-Origin'] = requestOrigin;
responseHeaders['Access-Control-Allow-Methods'] =
'GET, POST, OPTIONS, DELETE';
responseHeaders['Access-Control-Allow-Headers'] =
'Content-Type, Authorization, x-api-key';
}
// Callback with modified headers
callback({ responseHeaders });
});
}
// IPC listener for updating allowed domains
electron_2.ipcMain.on('set-allowed-domains', (event, domains) => {
if (!Array.isArray(domains)) {
return;
}
applyAllowedDomainsFromUserUrls(domains, { reloadWindow: true });
});
// Custom title bar: window controls (minimize, maximize, close)
electron_2.ipcMain.handle('window:minimize', () => {
const win = _1.myCapacitorApp.getMainWindow();
if (win && !win.isDestroyed())
win.minimize();
});
electron_2.ipcMain.handle('window:maximize', () => {
const win = _1.myCapacitorApp.getMainWindow();
if (win && !win.isDestroyed()) {
if (win.isMaximized())
win.unmaximize();
else
win.maximize();
}
});
electron_2.ipcMain.handle('window:close', () => {
const win = _1.myCapacitorApp.getMainWindow();
if (win && !win.isDestroyed())
win.close();
});
electron_2.ipcMain.handle('window:focus', () => {
const win = _1.myCapacitorApp.getMainWindow();
if (win && !win.isDestroyed()) {
win.show();
win.focus();
}
});
electron_2.ipcMain.handle('window:isMaximized', () => {
const win = _1.myCapacitorApp.getMainWindow();
return win != null && !win.isDestroyed() && win.isMaximized();
});
electron_2.ipcMain.handle('window:getPlatform', () => process.platform);
(0, system_call_readiness_1.startSystemCallReadinessMonitor)();
electron_2.ipcMain.handle('systemCallReadiness:getSnapshot', () => (0, system_call_readiness_1.getSystemCallReadinessSnapshot)());
electron_2.ipcMain.handle('systemCallReadiness:refreshSnapshot', () => (0, system_call_readiness_1.refreshSystemCallReadinessSnapshot)());
electron_2.ipcMain.handle('window:showAppMenu', (event, { x, y }) => {
const win = _1.myCapacitorApp.getMainWindow();
const menu = electron_2.Menu.getApplicationMenu();
if (menu && win && !win.isDestroyed()) {
menu.popup({
window: win,
x: x ?? 0,
y: y ?? 32,
});
}
});
electron_2.ipcMain.handle('dialog:openFile', async () => {
const result = await electron_2.dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{ name: 'ZIP Files', extensions: ['zip'] }, // Restrict to ZIP files
],
});
return result.filePaths[0];
});
electron_2.ipcMain.handle('fs:readFile', async (_, filePath) => {
try {
// Ensure the file exists
if (!fs.existsSync(filePath)) {
throw new Error('File does not exist.');
}
// Ensure the filePath is an absolute path (optional but recommended for safety)
const absolutePath = path.resolve(filePath);
// Read the file as a Buffer
const fileBuffer = fs.readFileSync(absolutePath);
return fileBuffer;
}
catch (error) {
(0, logger_1.error)('Error reading file:', error.message);
return null; // Return null on error
}
});
electron_2.ipcMain.handle('fs:selectAndZip', async (_, path) => {
let directoryPath = path;
if (!directoryPath) {
const { canceled, filePaths } = await electron_2.dialog.showOpenDialog({
properties: ['openDirectory'],
});
if (canceled || filePaths.length === 0) {
(0, logger_1.error)('No directory selected');
return null;
}
directoryPath = filePaths[0];
}
try {
// Add the entire directory to the zip
const zip = new AdmZip();
// Add the entire directory to the zip
zip.addLocalFolder(directoryPath);
// Generate the zip file as a buffer
const zipBuffer = zip.toBuffer();
return { buffer: zipBuffer, directoryPath };
}
catch (error) {
return null;
}
});
// Helper to get or create the shared settings directory
async function getSharedSettingsFilePath(fileName) {
const dir = path.join(electron_2.app.getPath('appData'), 'qortal-hub');
await fs.promises.mkdir(dir, { recursive: true });
return path.join(dir, fileName);
}
// Persistent store: shared across instances via atomic writes to appData/qortal-hub/
// Uses write-file-atomic to prevent partial writes corrupting the file.
// On set/delete: read-from-disk → merge → atomic write, so concurrent instances
// never overwrite each other's keys (only a simultaneous write of the *same* key
// by two instances at the exact same moment could still race, which is acceptable).
const PERSISTENT_STORE_FILENAME = 'qortal-persistent-store.json';
const MISC_PERSISTENT_STORE_FILENAME = 'misc-persist.json';
function parsePersistentStoreRaw(raw) {
const trimmed = raw?.trim() ?? '';
if (trimmed === '')
return {};
try {
return JSON.parse(trimmed) || {};
}
catch (_) {
return {};
}
}
function createPersistentJsonStore(fileName, label) {
let cache = null;
let loadedFromDisk = false;
const getFilePath = () => {
const dir = path.join(electron_2.app.getPath('appData'), 'qortal-hub');
if (!fs.existsSync(dir))
fs.mkdirSync(dir, { recursive: true });
return path.join(dir, fileName);
};
const readFromDisk = async () => {
try {
const filePath = getFilePath();
const stats = await fs.promises.stat(filePath).catch(() => null);
if (!stats?.isFile())
return {};
const raw = await fs.promises.readFile(filePath, 'utf-8');
return parsePersistentStoreRaw(raw);
}
catch (err) {
(0, logger_1.error)(`Error reading ${label} from disk`, err);
return {};
}
};
const load = async () => {
if (cache !== null)
return cache;
const data = await readFromDisk();
const hadData = Object.keys(data).length > 0;
cache = data;
if (hadData)
loadedFromDisk = true;
return cache;
};
const flush = () => {
if (cache === null)
return;
if (!loadedFromDisk && Object.keys(cache).length === 0) {
return;
}
try {
const filePath = getFilePath();
let onDisk = {};
if (fs.existsSync(filePath)) {
try {
onDisk = parsePersistentStoreRaw(fs.readFileSync(filePath, 'utf-8'));
}
catch (_) {
onDisk = {};
}
}
const merged = { ...onDisk, ...cache };
writeFileAtomic.sync(filePath, JSON.stringify(merged, null, 2), {
encoding: 'utf8',
});
}
catch (err) {
(0, logger_1.error)(`Error flushing ${label}`, err);
}
};
const get = async (key) => {
const store = await load();
return store[key];
};
const set = async (key, value) => {
// Read-merge-write: fetch fresh disk state, merge the new key, write atomically.
// This ensures concurrent instances don't clobber each other's unrelated keys.
const onDisk = await readFromDisk();
onDisk[key] = value;
try {
const filePath = getFilePath();
await writeFileAtomic(filePath, JSON.stringify(onDisk, null, 2), {
encoding: 'utf8',
});
}
catch (err) {
(0, logger_1.error)(`Error writing ${label} (set)`, err);
}
if (cache === null)
cache = {};
cache[key] = value;
loadedFromDisk = true;
};
const deleteKey = async (key) => {
// Read-merge-write: fetch fresh disk state, remove the key, write atomically.
const onDisk = await readFromDisk();
delete onDisk[key];
try {
const filePath = getFilePath();
await writeFileAtomic(filePath, JSON.stringify(onDisk, null, 2), {
encoding: 'utf8',
});
}
catch (err) {
(0, logger_1.error)(`Error writing ${label} (delete)`, err);
}
if (cache !== null)
delete cache[key];
};
return { deleteKey, flush, get, set };
}
const persistentStore = createPersistentJsonStore(PERSISTENT_STORE_FILENAME, 'persistent store');
const miscPersistentStore = createPersistentJsonStore(MISC_PERSISTENT_STORE_FILENAME, 'misc persistent store');
function flushPersistentStore() {
persistentStore.flush();
}
function flushMiscPersistentStore() {
miscPersistentStore.flush();
}
electron_2.ipcMain.handle('persistentStore:get', async (_event, key) => persistentStore.get(key));
electron_2.ipcMain.handle('persistentStore:set', async (_event, key, value) => {
await persistentStore.set(key, value);
});
electron_2.ipcMain.handle('persistentStore:delete', async (_event, key) => {
await persistentStore.deleteKey(key);
});
electron_2.ipcMain.handle('miscPersistentStore:get', async (_event, key) => miscPersistentStore.get(key));
electron_2.ipcMain.handle('miscPersistentStore:set', async (_event, key, value) => {
await miscPersistentStore.set(key, value);
});
electron_2.ipcMain.handle('miscPersistentStore:delete', async (_event, key) => {
await miscPersistentStore.deleteKey(key);
});
// App settings (stored in SharedSettingsFilePath) - e.g. close/minimize to tray preference
const APP_SETTINGS_FILENAME = 'app-settings.json';
const DEFAULT_APP_SETTINGS = {
closeAction: 'ask',
disableStartupSound: false,
p2pEnabled: !feature_flags_1.isDisabledLegacy,
reticulumMeshUpnpEnabled: true,
reticulumManagedConfigEnabled: true,
};
async function readAppSettings() {
try {
const filePath = await getSharedSettingsFilePath(APP_SETTINGS_FILENAME);
const raw = await fs.promises.readFile(filePath, 'utf-8').catch(() => null);
if (!raw)
return { ...DEFAULT_APP_SETTINGS };
const parsed = JSON.parse(raw);
return {
...DEFAULT_APP_SETTINGS,
...parsed,
closeAction: parsed.closeAction &&
['ask', 'minimizeToTray', 'quit'].includes(parsed.closeAction)
? parsed.closeAction
: DEFAULT_APP_SETTINGS.closeAction,
disableStartupSound: parsed.disableStartupSound === true,
p2pEnabled: feature_flags_1.isDisabledLegacy
? false
: parsed.p2pEnabled === false
? false
: true,
legacyPublicStunFallback: feature_flags_1.isDisabledLegacy
? false
: parsed.legacyPublicStunFallback === true,
reticulumMeshUpnpEnabled: parsed.reticulumMeshUpnpEnabled === false ? false : true,
reticulumManagedConfigEnabled: parsed.reticulumManagedConfigEnabled === false ? false : true,
};
}
catch {
return { ...DEFAULT_APP_SETTINGS };
}
}
async function writeAppSettings(settings) {
const filePath = await getSharedSettingsFilePath(APP_SETTINGS_FILENAME);
await fs.promises.writeFile(filePath, JSON.stringify(settings, null, 2), 'utf-8');
}
// READ handler
electron_2.ipcMain.handle('walletStorage:read', async (_event, fileName) => {
try {
const filePath = await getSharedSettingsFilePath(fileName);
const stats = await fs.promises.stat(filePath).catch(() => null);
if (!stats || !stats.isFile())
return null;
return fs.promises.readFile(filePath, 'utf-8');
}
catch (err) {
(0, logger_1.error)(`Error in walletStorage:read for "${fileName}"`, err);
return null;
}
});
// WRITE handler
electron_2.ipcMain.handle('walletStorage:write', async (_event, fileName, contents) => {
try {
const filePath = await getSharedSettingsFilePath(fileName);
await fs.promises.writeFile(filePath, contents, 'utf-8');
return true;
}
catch (err) {
(0, logger_1.error)(`Error in walletStorage:write for "${fileName}"`, err);
throw err;
}
});
// Persistent store: shared across instances via atomic writes to appData/qortal-hub/
// Uses write-file-atomic to prevent partial writes corrupting the file.
// On set/delete: read-from-disk → merge → atomic write, so concurrent instances
// never overwrite each other's keys (only a simultaneous write of the *same* key
// by two instances at the exact same moment could still race, which is acceptable).
let persistentStoreCache = null;
let persistentStoreLoadedFromDisk = false;
function getPersistentStoreFilePath() {
const dir = path.join(electron_2.app.getPath('appData'), 'qortal-hub');
if (!fs.existsSync(dir))
fs.mkdirSync(dir, { recursive: true });
return path.join(dir, PERSISTENT_STORE_FILENAME);
}
async function readPersistentStoreFromDisk() {
try {
const filePath = getPersistentStoreFilePath();
const stats = await fs.promises.stat(filePath).catch(() => null);
if (!stats?.isFile())
return {};
const raw = await fs.promises.readFile(filePath, 'utf-8');
return parsePersistentStoreRaw(raw);
}
catch (err) {
(0, logger_1.error)('Error reading persistent store from disk', err);
return {};
}
}
async function loadPersistentStore() {
if (persistentStoreCache !== null)
return persistentStoreCache;
const data = await readPersistentStoreFromDisk();
const hadData = Object.keys(data).length > 0;
persistentStoreCache = data;
if (hadData)
persistentStoreLoadedFromDisk = true;
return persistentStoreCache;
}
// App settings (stored in SharedSettingsFilePath) - e.g. close/minimize to tray
electron_2.ipcMain.handle('appSettings:get', async () => {
return readAppSettings();
});
electron_2.ipcMain.handle('appSettings:set', async (_event, partial) => {
const current = await readAppSettings();
const next = {
...current,
...partial,
...(feature_flags_1.isDisabledLegacy
? {
p2pEnabled: false,
legacyPublicStunFallback: false,
}
: {}),
};
await writeAppSettings(next);
if (!feature_flags_1.isDisabledLegacy) {
(0, stun_coordinator_1.getStunCoordinator)()?.setLegacyPublicStunFallback(next.legacyPublicStunFallback === true);
}
return next;
});
electron_2.ipcMain.handle('hub:getIceServers', async () => {
if (feature_flags_1.isDisabledLegacy)
return [];
const c = (0, stun_coordinator_1.getStunCoordinator)();
if (!c)
return [];
return await new Promise((resolve, reject) => {
const slots = {};
const timeoutId = setTimeout(() => {
const im = slots.immediate;
if (im !== undefined) {
clearImmediate(im);
}
(0, logger_1.log)('[STUN][telemetry] getIceServers ipc deadline — returning last snapshot');
resolve(c.peekLastServedIceServers());
}, stun_coordinator_1.GET_ICE_SERVERS_DEADLINE_MS);
slots.immediate = setImmediate(() => {
try {
const list = c.getIceServersForRenderer();
clearTimeout(timeoutId);
resolve(list);
}
catch (err) {
clearTimeout(timeoutId);
reject(err);
}
});
});
});
electron_2.ipcMain.handle('hub:reportStunCallOutcome', async (_event, urls, success) => {
if (feature_flags_1.isDisabledLegacy)
return { ok: false };
const c = (0, stun_coordinator_1.getStunCoordinator)();
if (!c)
return { ok: false };
if (!Array.isArray(urls))
return { ok: false };
const u = urls.filter((x) => typeof x === 'string');
c.recordCallStunBundleOutcome(u, success === true);
(0, logger_1.log)('[STUN][telemetry] call bundle outcome', {
urls: u.length,
success: success === true,
});
return { ok: true };
});
electron_2.ipcMain.handle('hub:reportObservedStunSources', async (_event, urls) => {
if (feature_flags_1.isDisabledLegacy)
return { ok: false };
const c = (0, stun_coordinator_1.getStunCoordinator)();
if (!c)
return { ok: false };
if (!Array.isArray(urls))
return { ok: false };
const u = urls.filter((x) => typeof x === 'string');
c.recordObservedStunSources(u);
return { ok: true };
});
// Handler for initiating a streaming file save
electron_2.ipcMain.handle('file:startStreamSave', async (_event, options) => {
try {
// Show save dialog
const result = await electron_2.dialog.showSaveDialog({
defaultPath: options.filename,
filters: options.mimeType
? [
{
name: 'File',
extensions: [options.filename.split('.').pop() || '*'],
},
]
: undefined,
});
if (result.canceled || !result.filePath) {
return { canceled: true };
}
return {
canceled: false,
filePath: result.filePath,
};
}
catch (err) {
(0, logger_1.error)('Error in file:startStreamSave', err);
throw err;
}
});
// Handler for writing chunks to a file
electron_2.ipcMain.handle('file:writeChunk', async (_event, filePath, chunk, append) => {
try {
const buffer = Buffer.from(chunk);
const mode = append ? 'append' : 'write';
(0, logger_1.log)(`[IPC] Writing chunk to ${filePath}: ${buffer.length} bytes (${mode} mode)`);
if (append) {
await fs.promises.appendFile(filePath, buffer);
}
else {
await fs.promises.writeFile(filePath, buffer);
}
// Get file size after write to verify
const stats = await fs.promises.stat(filePath);
(0, logger_1.log)(`[IPC] File size after write: ${stats.size} bytes`);
return true;
}
catch (err) {
(0, logger_1.error)('[IPC] Error writing chunk to', filePath, ':', err);
throw err;
}
});
// Handler for cleaning up failed downloads
electron_2.ipcMain.handle('file:deleteFile', async (_event, filePath) => {
try {
await fs.promises.unlink(filePath);
return true;
}
catch (err) {
(0, logger_1.error)('Error deleting file', filePath, err);
// Don't throw - file might not exist
return false;
}
});
const progressSubscribers = new Set();
electron_2.ipcMain.on('coreSetup:progress:subscribe', (e) => {
const wc = e.sender;
progressSubscribers.add(wc);
broadcastProgress('ready');
broadcastProgress({
type: 'osType',
osType: process.platform,
});
wc.once('destroyed', () => progressSubscribers.delete(wc));
});
electron_2.ipcMain.on('coreSetup:progress:unsubscribe', (e) => {
progressSubscribers.delete(e.sender);
});
function broadcastProgress(p) {
for (const wc of progressSubscribers) {
if (!wc.isDestroyed()) {
wc.send('coreSetup:progress', p);
}
}
}
electron_2.ipcMain.handle('coreSetup:isCoreRunning', async () => {
try {
try {
const customPath = await (0, core_1.customQortalInstalledDir)();
if (!customPath) {
broadcastProgress({
type: 'hasCustomPath',
hasCustomPath: false,
customPath: null,
});
}
else {
const isInstalledWithCustomPath = await (0, core_1.isCoreInstalled)();
if (isInstalledWithCustomPath) {
broadcastProgress({
type: 'hasCustomPath',
hasCustomPath: true,
customPath,
});
}
else {
await (0, core_1.removeCustomQortalPath)();
broadcastProgress({
type: 'hasCustomPath',
hasCustomPath: false,
customPath: null,
});
}
}
}
catch (error) {
(0, logger_1.error)(error);
}
const running = await (0, core_1.isCoreRunning)();
if (running) {
broadcastProgress({
step: 'coreRunning',
status: 'done',
progress: 100,
message: '',
});
broadcastProgress({
step: 'downloadedCore',
status: 'done',
progress: 100,
message: '',
});
broadcastProgress({
step: 'hasJava',
status: 'done',
progress: 100,
message: '',
});
}
else {
const javaVersion = await (0, core_1.determineJavaVersion)();
const hasCore = await (0, core_1.isCoreInstalled)();
if (javaVersion != false) {
broadcastProgress({
step: 'hasJava',
status: 'done',
progress: 100,
message: '',
});
}
else {
broadcastProgress({
step: 'hasJava',
status: 'off',
progress: 0,
message: '',
});
}
broadcastProgress({
step: 'coreRunning',
status: 'off',
progress: 0,
message: '',
});
if (hasCore) {
broadcastProgress({
step: 'downloadedCore',
status: 'done',
progress: 100,
message: '',
});
}
else {
broadcastProgress({
step: 'downloadedCore',
status: 'off',
progress: 0,
message: '',
});
}
}
return running;
}
catch (error) { }
});
electron_2.ipcMain.handle('coreSetup:isCoreRunningOnSystem', async () => {
try {
const running = await (0, core_1.isCoreRunning)(true);
return running;
}
catch (error) {
return false;
}
});
electron_2.ipcMain.handle('coreSetup:verifySteps', async () => {
try {
const javaVersion = await (0, core_1.determineJavaVersion)();
if (javaVersion != false) {
broadcastProgress({
step: 'hasJava',
status: 'done',
progress: 100,
message: '',
});
}
const hasCore = await (0, core_1.isCoreInstalled)();
if (hasCore) {
broadcastProgress({
step: 'downloadedCore',
status: 'done',
progress: 100,
message: '',
});
}
const running = await (0, core_1.isCorePortRunning)();
if (running) {
broadcastProgress({
step: 'coreRunning',
status: 'done',
progress: 100,
message: '',
});
}
}
catch (error) { }
});
electron_2.ipcMain.handle('coreSetup:isCoreInstalled', async () => {
try {
const isInstalled = await (0, core_1.isCoreInstalled)();
if (isInstalled) {
broadcastProgress({
step: 'downloadedCore',
status: 'done',
progress: 100,
message: '',
});
}
else {
broadcastProgress({
step: 'downloadedCore',
status: 'off',
progress: 0,
message: '',
});
}
return isInstalled;
}
catch (error) { }
});
electron_2.ipcMain.handle('coreSetup:isCoreInstalledOnSystem', async () => {
try {
const isInstalled = await (0, core_1.isCoreInstalled)();
return isInstalled;
}
catch (error) { }
});
electron_2.ipcMain.handle('coreSetup:installCore', async (event) => {
try {
const isInstalled = await (0, core_1.isCoreInstalled)();
const isRunning = await (0, core_1.isCoreRunning)();
if (isInstalled) {
broadcastProgress({
step: 'downloadedCore',
status: 'done',
progress: 100,
message: '',
});
}
if (isRunning) {
broadcastProgress({
step: 'coreRunning',
status: 'done',
progress: 100,
message: '',
});
}
if (isInstalled)
return true;
const wc = event.sender;
const sendProgress = (p) => {
wc.send('coreSetup:progress', { step: 'download', ...p });
};
const running = await (0, core_1.installCore)(sendProgress);
return running;
}
catch (error) {
console.error('Failed to install Qortal Core:', error);
broadcastProgress({
step: 'downloadedCore',
status: 'error',
progress: 0,
message: '010',
});
return false;
}
});
electron_2.ipcMain.handle('coreSetup:startCore', async () => {
try {
const running = await (0, core_1.startCore)();
return running;
}
catch (error) { }
});
electron_2.ipcMain.handle('coreSetup:deleteDB', async () => {
try {
const isDeleted = await (0, core_1.deleteDB)();
return isDeleted;
}
catch (error) { }
});
electron_2.ipcMain.handle('coreSetup:dbExists', async () => {
try {
const isDeleted = await (0, core_1.dbExists)();
return isDeleted;
}
catch (error) { }
});
electron_2.ipcMain.handle('coreSetup:getApiKey', async () => {
try {
const running = await (0, core_1.getApiKey)();
return running;
}
catch (error) { }
});
electron_2.ipcMain.handle('cert:ensureForBase', async (_event, baseUrl, apiKey) => {
const result = await (0, local_https_cert_1.ensureCertForBase)(baseUrl, apiKey);
if (result.success) {
(0, local_https_cert_1.setLocalNodeHttpsReady)(true);
electron_2.session.defaultSession.clearCache().catch(() => { });
const win = _1.myCapacitorApp.getMainWindow();
if (win && !win.isDestroyed()) {
win.webContents.session.clearCache().catch(() => { });
}
}
return result;
});
electron_2.ipcMain.handle('coreSetup:resetApikey', async () => {
try {
const running = await (0, core_1.resetApikey)();
return running;
}
catch (error) { }
});
electron_2.ipcMain.handle('coreSetup:removeCustomPath', async () => {
try {
await (0, core_1.removeCustomQortalPath)();
broadcastProgress({
type: 'hasCustomPath',
hasCustomPath: false,
customPath: null,
});
}
catch (error) { }
});
electron_2.ipcMain.handle('coreSetup:stopCore', async () => {
try {
return await (0, core_1.stopCore)();
}
catch (error) {
(0, logger_1.error)('error', error);
}
});
electron_2.ipcMain.handle('coreSetup:bootstrap', async () => {
try {
return await (0, core_1.bootstrap)();
}
catch (error) {
(0, logger_1.error)('error', error);
}
});
electron_2.ipcMain.handle('coreSetup:bootstrapOrClearChainAndStart', async () => {
try {
return await (0, core_1.bootstrapOrClearChainAndStart)();
}
catch (error) {
(0, logger_1.error)('error', error);
}
});
electron_2.ipcMain.handle('coreSetup:pickQortalDirectory', async () => {
try {
const { canceled, filePaths } = await electron_2.dialog.showOpenDialog({
properties: ['openDirectory'],
});
if (canceled || filePaths.length === 0)
return null;
const dir = filePaths[0];
const isInstalled = await (0, core_1.isCoreInstalled)(dir);
if (isInstalled) {
const filePath = await getSharedSettingsFilePath('wallet-storage.json');
const raw = await fs.promises
.readFile(filePath, 'utf-8')
.catch(() => null);
const data = raw ? JSON.parse(raw) : {};
data['qortalDirectory'] = dir;
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
broadcastProgress({
type: 'hasCustomPath',
hasCustomPath: true,
customPath: dir,
});
}
else
return false;
}
catch (error) {
return false;
}
});
// Video Server IPC Handlers
electron_2.ipcMain.handle('videoServer:start', async (_event, port) => {
try {
const serverPort = await (0, video_server_1.startVideoServer)(port);
return { success: true, port: serverPort };
}
catch (error) {
(0, logger_1.error)('Failed to start video server:', error);
return { success: false, error: error.message };
}
});
electron_2.ipcMain.handle('videoServer:stop', async () => {
try {
await (0, video_server_1.stopVideoServer)();
return { success: true };
}
catch (error) {
(0, logger_1.error)('Failed to stop video server:', error);
return { success: false, error: error.message };
}
});
electron_2.ipcMain.handle('videoServer:getPort', async () => {
return (0, video_server_1.getVideoServerPort)();
});
electron_2.ipcMain.handle('videoServer:isRunning', async () => {
return (0, video_server_1.isVideoServerRunning)();
});
// ── P2P Network IPC Handlers ─────────────────────────────────────────────────
const p2pMessageSubscribers = new Set();
const p2pPeerChangeSubscribers = new Set();
function broadcastToSet(subscribers, channel, payload) {
for (const wc of subscribers) {
if (wc.isDestroyed()) {
subscribers.delete(wc);
}
else {
wc.send(channel, payload);
}
}
}
function notifyPresenceTransportReady() {
broadcastToSet(presenceUpdateSubscribers, 'presence:started', {});
}
/** Stores the options used when P2P was last started so the IPC toggle can
* restart with the same ports, seeds, etc. */
let lastP2POptions = {};
function setLastP2POptions(opts) {
lastP2POptions = opts;
}
function attachP2PListeners(network) {
if (!network)
return;
network.on('message', (payload) => broadcastToSet(p2pMessageSubscribers, 'p2p:message', payload));
network.on('peer-connected', (payload) => broadcastToSet(p2pPeerChangeSubscribers, 'p2p:peerChange', {
type: 'connected',
...payload,
}));
network.on('peer-disconnected', (payload) => broadcastToSet(p2pPeerChangeSubscribers, 'p2p:peerChange', {
type: 'disconnected',
...payload,
}));
}
/** Start decentralized STUN (UDP server, probes, cache) after P2P is up. */
async function startDecentralizedStunAfterP2P(network, opts) {
if (feature_flags_1.isDisabledLegacy)
return;
const chatDb = opts.dbPath ?? (0, path_1.join)(electron_2.app.getPath('appData'), 'qortal-shared', 'chat.db');
const stunPath = (0, path_1.join)((0, path_1.dirname)(chatDb), 'stun-cache.db');
const settings = await readAppSettings();
await (0, stun_coordinator_1.startStunCoordinator)(network, {
initialPeers: opts.initialPeers ?? [],
stunCacheDbPath: stunPath,
legacyPublicStunFallback: settings.legacyPublicStunFallback === true,
});
if ((0, stun_coordinator_1.getStunCoordinator)()?.didBindStunUdp()) {
await network.mapOwnedStunUdpIfPossible();
}
}
async function ensureReticulumManagersStarted() {
let bridgeTransport = (0, reticulum_bridge_1.getReticulumBridge)();
if (bridgeTransport) {
try {
await bridgeTransport.start();
}
catch (err) {
(0, logger_1.error)('[ReticulumBridge] Failed to finish startup:', err);
registerLateReticulumBridgeRecovery();
}
}
else {
try {
bridgeTransport = await (0, reticulum_bridge_1.startReticulumBridge)();
}
catch (err) {
(0, logger_1.error)('[ReticulumBridge] Failed to start:', err);
bridgeTransport = (0, reticulum_bridge_1.getReticulumBridge)();
if (bridgeTransport) {
registerLateReticulumBridgeRecovery();
}
}
}
if (bridgeTransport && bridgeTransport.getState() !== 'ready') {
registerLateReticulumBridgeRecovery();
}
(0, reticulum_daemon_1.attachReticulumStatusBridgeEvents)(bridgeTransport);
let pm = (0, presence_1.getPresenceManager)();
const transports = bridgeTransport ? [bridgeTransport] : [];
if (pm) {
(0, presence_1.setPresenceManagerTransports)(transports);
void syncReticulumOverlayStateToBridge(pm);
}
else {
pm = (0, presence_1.startPresenceManager)(transports);
attachPresenceListeners(pm);
}
const callMgr = (0, call_1.getCallManager)();
if (callMgr) {
callMgr.setReticulumBridge(bridgeTransport);
}
else {
const startedCallMgr = (0, call_1.startCallManager)(pm, bridgeTransport);
attachCallListeners(startedCallMgr);
}
const gcallMgr = (0, group_call_1.getGroupCallManager)();
if (gcallMgr) {
gcallMgr.setReticulumBridge(bridgeTransport);
}
else {
const startedGcallMgr = (0, group_call_1.startGroupCallManager)(pm, bridgeTransport);
attachGroupCallListeners(startedGcallMgr);
}
(0, reticulum_mesh_1.stopReticulumMeshCoordinator)();
(0, reticulum_mesh_1.startReticulumMeshCoordinator)((0, reticulum_bridge_1.getReticulumBridge)());
startReticulumPresenceHealthWatchdog();
}
electron_2.ipcMain.handle('p2p:start', async (_event, options) => {
if (feature_flags_1.isDisabledLegacy) {
return { success: false, error: 'Legacy networking is disabled' };
}
try {
// Re-use the last known options if none supplied (e.g. from the settings toggle).
const opts = options && Object.keys(options).length > 0 ? options : lastP2POptions;
lastP2POptions = opts;
await ensureReticulumManagersStarted();
const network = await (0, p2p_network_1.startP2PNetwork)(opts);
attachP2PListeners(network);
await startDecentralizedStunAfterP2P(network, opts);
// (Re-)start the chat manager backed by the shared SQLite database.
(0, chat_1.stopChatManager)();
const sharedDbPath = (0, path_1.join)(electron_2.app.getPath('appData'), 'qortal-shared', 'chat.db');
const cm = await (0, chat_1.startChatManager)(network, sharedDbPath);
attachChatListeners(cm);
notifyPresenceTransportReady();
return {
success: true,
port: network.getPort(),
peerId: network.getPeerId(),
};
}
catch (err) {
(0, logger_1.error)('[P2P] Failed to start:', err);
return { success: false, error: err.message };
}
});
electron_2.ipcMain.handle('p2p:stop', async () => {
if (feature_flags_1.isDisabledLegacy) {
return { success: true };
}
try {
(0, p2p_network_1.stopP2PNetwork)();
(0, chat_1.stopChatManager)();
return { success: true };
}
catch (err) {
(0, logger_1.error)('[P2P] Failed to stop:', err);
return { success: false, error: err.message };
}
});
electron_2.ipcMain.handle('p2p:send', async (_event, to, data) => {
if (feature_flags_1.isDisabledLegacy) {
return { success: false, error: 'Legacy networking is disabled' };
}
const network = (0, p2p_network_1.getP2PNetwork)();
if (!network)
return { success: false, error: 'P2P network is not running' };
try {
const messageId = network.send(to, data);
return { success: true, messageId };
}
catch (err) {
return { success: false, error: err.message };
}
});
electron_2.ipcMain.handle('p2p:getPeers', async () => {
if (feature_flags_1.isDisabledLegacy)
return [];
const network = (0, p2p_network_1.getP2PNetwork)();
return network ? network.getPeers() : [];
});
electron_2.ipcMain.handle('p2p:getStatus', async () => {
if (feature_flags_1.isDisabledLegacy) {
return { running: false, port: null, peerId: null, connectedPeers: 0 };
}
const network = (0, p2p_network_1.getP2PNetwork)();
if (!network)
return { running: false, port: null, peerId: null, connectedPeers: 0 };
return {
running: network.isRunning(),
port: network.getPort(),
peerId: network.getPeerId(),
connectedPeers: network.connectedCount(),
};
});
electron_2.ipcMain.handle('p2p:addPeer', async (_event, addr) => {
if (feature_flags_1.isDisabledLegacy) {
return { success: false, error: 'Legacy networking is disabled' };
}
const network = (0, p2p_network_1.getP2PNetwork)();
if (!network)
return { success: false, error: 'P2P network is not running' };
network.addPeer(addr);
return { success: true };
});
electron_2.ipcMain.on('p2p:message:subscribe', (event) => {
p2pMessageSubscribers.add(event.sender);
});
electron_2.ipcMain.on('p2p:message:unsubscribe', (event) => {
p2pMessageSubscribers.delete(event.sender);
});
electron_2.ipcMain.on('p2p:peerChange:subscribe', (event) => {
p2pPeerChangeSubscribers.add(event.sender);
});
electron_2.ipcMain.on('p2p:peerChange:unsubscribe', (event) => {
p2pPeerChangeSubscribers.delete(event.sender);
});
// ── Presence IPC Handlers ────────────────────────────────────────────────────
const presenceUpdateSubscribers = new Set();
const queuedPresenceUpdates = new Map();
let presenceUpdateFlushTimer = null;
let lateReticulumRecoveryCleanup = null;
const RETICULUM_OVERLAY_SYNC_RETRY_DELAYS_MS = [1000, 3000, 10000, 30000];
const RETICULUM_HEALTH_CHECK_MS = 30000;
const RETICULUM_HEALTH_STALE_INBOUND_MS = 2 * 60000;
const RETICULUM_HEALTH_BRIDGE_RESTART_MS = 5 * 60000;
const RETICULUM_HEALTH_SOFT_COOLDOWN_MS = 2 * 60000;
const RETICULUM_HEALTH_BRIDGE_RESTART_COOLDOWN_MS = 5 * 60000;
const RETICULUM_HEALTH_TIMEOUT_THRESHOLD = 3;
let reticulumOverlaySyncRetryTimer = null;
let reticulumOverlaySyncSequence = 0;
let reticulumHealthTimer = null;
let reticulumHealthRecoveryInFlight = false;
let reticulumHealthLastSoftRecoveryAt = 0;
let reticulumHealthLastBridgeRestartAt = 0;
function flushPresenceUpdates() {
if (presenceUpdateFlushTimer) {
clearTimeout(presenceUpdateFlushTimer);
presenceUpdateFlushTimer = null;
}
if (queuedPresenceUpdates.size === 0)
return;
const payloads = Array.from(queuedPresenceUpdates.values());
queuedPresenceUpdates.clear();
(0, logger_1.log)(`[Presence] Flushing ${payloads.length} queued update(s) to ${presenceUpdateSubscribers.size} renderer subscriber(s)`);
broadcastToSet(presenceUpdateSubscribers, 'presence:update-batch', payloads);
}
function queuePresenceUpdate(payload) {
if (payload &&
typeof payload === 'object' &&
typeof payload.address === 'string') {
(0, logger_1.log)(`[Presence] Queueing renderer update for address=${payload.address}`);
queuedPresenceUpdates.set(payload.address, payload);
}
else {
(0, logger_1.log)('[Presence] Queueing renderer update without address key');
queuedPresenceUpdates.set(`${Date.now()}:${queuedPresenceUpdates.size}`, payload);
}
if (presenceUpdateFlushTimer)
return;
presenceUpdateFlushTimer = setTimeout(() => {
flushPresenceUpdates();
}, 16);
presenceUpdateFlushTimer.unref?.();
}
function broadcastPresenceUpdate(payload) {
(0, logger_1.log)('[Presence] Broadcasting presence update from manager to renderer queue');
queuePresenceUpdate(payload);
}
async function syncReticulumOverlayStateToBridge(manager, attempt = 0, sequence = ++reticulumOverlaySyncSequence) {
const bridge = (0, reticulum_bridge_1.getReticulumBridge)();
if (!bridge || bridge.getState() !== 'ready') {
scheduleReticulumOverlayStateSyncRetry(manager, attempt, sequence);
return;
}
const verifiedPeers = manager
.getReticulumVerifiedPeers()
.map((peer) => ({
destinationHash: peer.destinationHash,
address: peer.address,
lastSeen: peer.lastSeen,
}));
const activeNeighborHashes = manager.getReticulumActiveNeighborHashes();
const overlayNeighborHashes = activeNeighborHashes.length > 0
? activeNeighborHashes
: verifiedPeers.map((peer) => peer.destinationHash);
try {
const ok = await bridge.syncOverlayState(verifiedPeers, overlayNeighborHashes);
if (!ok) {
scheduleReticulumOverlayStateSyncRetry(manager, attempt, sequence);
return;
}
if (sequence === reticulumOverlaySyncSequence &&
reticulumOverlaySyncRetryTimer) {
clearTimeout(reticulumOverlaySyncRetryTimer);
reticulumOverlaySyncRetryTimer = null;
}
}
catch (err) {
(0, logger_1.warn)('[ReticulumOverlay] Failed to sync overlay state to bridge:', err);
scheduleReticulumOverlayStateSyncRetry(manager, attempt, sequence);
}
}
function scheduleReticulumOverlayStateSyncRetry(manager, attempt, sequence) {
if (sequence !== reticulumOverlaySyncSequence)
return;
if (reticulumOverlaySyncRetryTimer) {
clearTimeout(reticulumOverlaySyncRetryTimer);
reticulumOverlaySyncRetryTimer = null;
}
const delay = RETICULUM_OVERLAY_SYNC_RETRY_DELAYS_MS[Math.min(attempt, RETICULUM_OVERLAY_SYNC_RETRY_DELAYS_MS.length - 1)];
reticulumOverlaySyncRetryTimer = setTimeout(() => {
reticulumOverlaySyncRetryTimer = null;
if (sequence !== reticulumOverlaySyncSequence)
return;
void syncReticulumOverlayStateToBridge(manager, attempt + 1, sequence);
}, delay);
reticulumOverlaySyncRetryTimer.unref?.();
}
async function replayReticulumCachedPresence(reason, scheduleFollowup = false) {
const manager = (0, presence_1.getPresenceManager)();
const bridge = (0, reticulum_bridge_1.getReticulumBridge)();
if (!manager || !bridge || bridge.getState() !== 'ready') {
(0, logger_1.log)(`[ReticulumRecovery] Cached presence replay skipped reason=${reason} manager=${manager ? 'yes' : 'no'} bridge_state=${bridge?.getState() ?? 'missing'}`);
return false;
}
const cached = manager.getLastLocalEnvelope();
if (!cached) {
(0, logger_1.log)(`[ReticulumRecovery] Cached presence replay skipped reason=${reason} cached_presence=no`);
return false;
}
await syncReticulumOverlayStateToBridge(manager);
const ok = await bridge.publish(cached, {
force: true,
reason,
});
const address = typeof cached.payload?.address === 'string'
? cached.payload.address
: 'unknown';
(0, logger_1.log)(`[ReticulumRecovery] Cached presence replay reason=${reason} ok=${ok ? 'yes' : 'no'} type=${cached.type} peer_addr=${address} envelope_id=${cached.id ?? 'n/a'}`);
if (scheduleFollowup) {
const followup = setTimeout(() => {
void replayReticulumCachedPresence(`${reason}:followup`, false).catch((err) => {
(0, logger_1.warn)(`[ReticulumRecovery] Cached presence followup failed reason=${reason}:`, err);
});
}, 10000);
followup.unref?.();
}
return ok;
}
function startReticulumPresenceHealthWatchdog() {
if (reticulumHealthTimer)
return;
reticulumHealthTimer = setInterval(() => {
void checkReticulumPresenceHealth().catch((err) => {
(0, logger_1.warn)('[ReticulumHealth] Watchdog check failed:', err);
});
}, RETICULUM_HEALTH_CHECK_MS);
reticulumHealthTimer.unref?.();
(0, logger_1.log)('[ReticulumHealth] Presence watchdog started');
}
async function checkReticulumPresenceHealth() {
if (_1.isQuitting || reticulumHealthRecoveryInFlight)
return;
const manager = (0, presence_1.getPresenceManager)();
const bridge = (0, reticulum_bridge_1.getReticulumBridge)();
if (!manager || !bridge || bridge.getState() !== 'ready')
return;
if (!manager.getLastLocalEnvelope())
return;
const now = Date.now();
const health = bridge.getPresenceHealthSnapshot();
const verifiedCount = manager.getReticulumVerifiedPeers().length;
const fanoutCount = manager.getReticulumActiveNeighborHashes().length;
const inboundAge = health.lastInboundPresenceAt > 0
? now - health.lastInboundPresenceAt
: Number.POSITIVE_INFINITY;
const publishOkAge = health.lastPresencePublishOkAt > 0
? now - health.lastPresencePublishOkAt
: Number.POSITIVE_INFINITY;
const staleInbound = inboundAge >= RETICULUM_HEALTH_STALE_INBOUND_MS;
const zeroFanout = health.lastPresenceFanoutPeers === 0 ||
(verifiedCount > 0 && fanoutCount === 0);
const repeatedTimeouts = health.recentOverlayLinkTimeouts >= RETICULUM_HEALTH_TIMEOUT_THRESHOLD;
const neverPublished = health.lastPresencePublishOkAt <= 0;
const needsSoftRecovery = neverPublished ||
zeroFanout ||
repeatedTimeouts ||
(staleInbound && health.lastPresenceFanoutPeers === null);
if (!needsSoftRecovery)
return;
const hardStale = staleInbound &&
(zeroFanout || repeatedTimeouts) &&
publishOkAge >= RETICULUM_HEALTH_BRIDGE_RESTART_MS;
if (hardStale &&
now - reticulumHealthLastBridgeRestartAt >=
RETICULUM_HEALTH_BRIDGE_RESTART_COOLDOWN_MS &&
now - reticulumHealthLastSoftRecoveryAt >= 60000) {
reticulumHealthLastBridgeRestartAt = now;
reticulumHealthRecoveryInFlight = true;
(0, logger_1.warn)(`[ReticulumHealth] Restarting local bridge stale_inbound_ms=${Number.isFinite(inboundAge) ? inboundAge : 'never'} publish_ok_ms=${Number.isFinite(publishOkAge) ? publishOkAge : 'never'} fanout_peers=${health.lastPresenceFanoutPeers ?? 'n/a'} verified=${verifiedCount} active_fanout=${fanoutCount} link_timeouts=${health.recentOverlayLinkTimeouts}`);
try {
(0, reticulum_bridge_1.stopReticulumBridge)();
const restarted = await (0, reticulum_bridge_1.startReticulumBridge)();
(0, reticulum_daemon_1.attachReticulumStatusBridgeEvents)(restarted);
await ensureReticulumManagersStarted();
await replayReticulumCachedPresence('health:bridge-restart', true);
}
catch (err) {
(0, logger_1.warn)('[ReticulumHealth] Local bridge restart failed:', err);
registerLateReticulumBridgeRecovery();
}
finally {
reticulumHealthRecoveryInFlight = false;
}
return;
}
if (now - reticulumHealthLastSoftRecoveryAt <
RETICULUM_HEALTH_SOFT_COOLDOWN_MS) {
return;
}
reticulumHealthLastSoftRecoveryAt = now;
(0, logger_1.log)(`[ReticulumHealth] Soft replay stale_inbound_ms=${Number.isFinite(inboundAge) ? inboundAge : 'never'} fanout_peers=${health.lastPresenceFanoutPeers ?? 'n/a'} verified=${verifiedCount} active_fanout=${fanoutCount} link_timeouts=${health.recentOverlayLinkTimeouts}`);
await replayReticulumCachedPresence('health:soft', true);
}
function attachPresenceListeners(manager) {
if (!manager)
return;
(0, logger_1.log)('[Presence] Attaching manager listeners.');
manager.on('presence-updated', broadcastPresenceUpdate);
manager.on('reticulum-overlay-changed', () => {
void syncReticulumOverlayStateToBridge(manager);
});
manager.on('reticulum-candidate-failed', ({ destinationHash, reason, }) => {
const bridge = (0, reticulum_bridge_1.getReticulumBridge)();
if (!bridge || bridge.getState() !== 'ready')
return;
void bridge
.noteOverlayCandidateFailure(destinationHash, reason)
.catch(() => { });
});
manager.on('reticulum-envelope-accepted', ({ envelope, route, }) => {
if (route.kind !== 'reticulum')
return;
const hops = route.overlayHopsRemaining ?? 0;
if (hops <= 0)
return;
const bridge = (0, reticulum_bridge_1.getReticulumBridge)();
if (!bridge || bridge.getState() !== 'ready')
return;
void bridge
.forwardPresence(envelope, hops - 1, [route.viaDestinationHash ?? route.destinationHash], route.destinationHash)
.catch(() => { });
});
void syncReticulumOverlayStateToBridge(manager);
}
function clearLateReticulumBridgeRecovery() {
lateReticulumRecoveryCleanup?.();
lateReticulumRecoveryCleanup = null;
}
function registerLateReticulumBridgeRecovery() {
clearLateReticulumBridgeRecovery();
const bridge = (0, reticulum_bridge_1.getReticulumBridge)();
if (!bridge) {
(0, logger_1.warn)('[ReticulumBridge] Late recovery not registered: no bridge instance');
return;
}
let recovered = false;
const recoverManagers = () => {
if (recovered)
return;
recovered = true;
clearLateReticulumBridgeRecovery();
const currentBridge = (0, reticulum_bridge_1.getReticulumBridge)();
if (!currentBridge || currentBridge.getState() !== 'ready') {
(0, logger_1.warn)('[ReticulumBridge] Late recovery skipped: bridge missing or not ready');
return;
}
(0, reticulum_daemon_1.attachReticulumStatusBridgeEvents)(currentBridge);
(0, logger_1.log)('[ReticulumBridge] Bridge became ready after startup timeout; updating presence transport and rebinding call/group-call managers');
let pm = (0, presence_1.getPresenceManager)();
if (pm) {
(0, presence_1.setPresenceManagerTransports)([currentBridge]);
void syncReticulumOverlayStateToBridge(pm);
}
else {
pm = (0, presence_1.startPresenceManager)([currentBridge]);
attachPresenceListeners(pm);
}
const callMgr = (0, call_1.getCallManager)();
if (callMgr) {
callMgr.setReticulumBridge(currentBridge);
}
else {
attachCallListeners((0, call_1.startCallManager)(pm, currentBridge));
}
const gcallMgr = (0, group_call_1.getGroupCallManager)();
if (gcallMgr) {
gcallMgr.setReticulumBridge(currentBridge);
}
else {
attachGroupCallListeners((0, group_call_1.startGroupCallManager)(pm, currentBridge));
}
(0, reticulum_mesh_1.stopReticulumMeshCoordinator)();
(0, reticulum_mesh_1.startReticulumMeshCoordinator)(currentBridge);
startReticulumPresenceHealthWatchdog();
void replayReticulumCachedPresence('late-ready', true);
// Mirror the normal startup signal so an already-authenticated renderer can
// retry its initial presence announce after late Reticulum readiness.
notifyPresenceTransportReady();
};
if (bridge.getState() === 'ready') {
recoverManagers();
return;
}
bridge.once('ready', recoverManagers);
lateReticulumRecoveryCleanup = () => {
bridge.off('ready', recoverManagers);
};
(0, logger_1.log)('[ReticulumBridge] Registered late-ready recovery hook');
}
/** Validates a renderer-supplied envelope, applies it locally, then relays. */
async function handleLocalPresenceEnvelope(envelope) {
const pm = (0, presence_1.getPresenceManager)();
if (!pm) {
(0, logger_1.log)('[Presence] Local envelope dropped because manager is unavailable.');
return false;
}
(0, logger_1.log)('[Presence] Handling local renderer presence envelope.');
return (0, presence_1.publishPresenceEnvelope)(envelope);
}
electron_2.ipcMain.handle('presence:announce', async (_event, envelope) => {
try {
const ok = await handleLocalPresenceEnvelope(envelope);
return { success: ok };
}
catch (err) {
(0, logger_1.error)('[Presence] announce error:', err);
return { success: false, error: err.message };
}
});
electron_2.ipcMain.handle('presence:heartbeat', async (_event, envelope) => {
try {
const ok = await handleLocalPresenceEnvelope(envelope);
return { success: ok };
}
catch (err) {
(0, logger_1.error)('[Presence] heartbeat error:', err);
return { success: false, error: err.message };
}
});
electron_2.ipcMain.handle('presence:offline', async (_event, envelope) => {
try {
const ok = await handleLocalPresenceEnvelope(envelope);
return { success: ok };
}
catch (err) {
(0, logger_1.error)('[Presence] offline error:', err);
return { success: false, error: err.message };
}
});
electron_2.ipcMain.handle('presence:getStatus', async (_event, address) => {
const pm = (0, presence_1.getPresenceManager)();
if (!pm)
return { online: false, lastSeen: null, sessions: [] };
return pm.getStatus(address);
});
electron_2.ipcMain.handle('presence:getOnlineAddresses', async () => {
const pm = (0, presence_1.getPresenceManager)();
return pm ? pm.getOnlineAddresses() : [];
});
electron_2.ipcMain.handle('presence:getAllOnline', async () => {
const pm = (0, presence_1.getPresenceManager)();
return pm ? pm.getAllOnline() : [];
});
electron_2.ipcMain.on('presence:subscribe', (event) => {
presenceUpdateSubscribers.add(event.sender);
(0, logger_1.log)(`[Presence] Renderer subscribed. subscriber_count=${presenceUpdateSubscribers.size}`);
});
electron_2.ipcMain.on('presence:unsubscribe', (event) => {
presenceUpdateSubscribers.delete(event.sender);
(0, logger_1.log)(`[Presence] Renderer unsubscribed. subscriber_count=${presenceUpdateSubscribers.size}`);
});
// ── Chat IPC Handlers ─────────────────────────────────────────────────────────
const chatEventSubscribers = new Set();
const chatTypingSubscribers = new Set();
const chatReadSubscribers = new Set();
function attachChatListeners(manager) {
if (!manager)
return;
manager.on('chat:event', (payload) => broadcastToSet(chatEventSubscribers, 'chat:event', payload));
manager.on('chat:typing', (payload) => broadcastToSet(chatTypingSubscribers, 'chat:typing', payload));
manager.on('chat:typingStopped', (payload) => broadcastToSet(chatTypingSubscribers, 'chat:typingStopped', payload));
manager.on('chat:read', (payload) => broadcastToSet(chatReadSubscribers, 'chat:read', payload));
}
/**
* Send a signed ChatEventEnvelope from the local renderer.
* The renderer must have already signed the event before calling this.
*/
electron_2.ipcMain.handle('chat:sendEvent', async (_event, envelope) => {
const cm = (0, chat_1.getChatManager)();
if (!cm)
return { success: false, error: 'Chat manager is not running' };
try {
const ok = await cm.handleLocalEvent(envelope);
return { success: ok };
}
catch (err) {
(0, logger_1.error)('[Chat] sendEvent error:', err);
return { success: false, error: err.message };
}
});
/** Subscribe the local user to a chat and request sync from peers. */
electron_2.ipcMain.handle('chat:subscribe', async (_event, chatId) => {
const cm = (0, chat_1.getChatManager)();
if (!cm)
return { success: false, error: 'Chat manager is not running' };
cm.subscribeToChat(chatId);
return { success: true };
});
/** Unsubscribe the local user from a chat. */
electron_2.ipcMain.handle('chat:unsubscribe', async (_event, chatId) => {
const cm = (0, chat_1.getChatManager)();
if (!cm)
return { success: false, error: 'Chat manager is not running' };
cm.unsubscribeFromChat(chatId);
return { success: true };
});
/**
* Broadcast a typing indicator.
* authorAddress is the sender's Qortal address.
*/
electron_2.ipcMain.handle('chat:sendTyping', async (_event, chatId, authorAddress) => {
const cm = (0, chat_1.getChatManager)();
if (!cm)
return { success: false, error: 'Chat manager is not running' };
cm.sendTyping(chatId, authorAddress);
return { success: true };
});
/**
* Retrieve message history for a chat.
* `beforeTimestamp` enables reverse-scroll pagination.
*/
electron_2.ipcMain.handle('chat:getHistory', async (_event, chatId, limit, beforeTimestamp) => {
const cm = (0, chat_1.getChatManager)();
if (!cm)
return [];
return cm.getHistory(chatId, limit, beforeTimestamp);
});
/** Returns summaries of all known chats (last message + unread count). */
electron_2.ipcMain.handle('chat:getSummaries', async () => {
const cm = (0, chat_1.getChatManager)();
return cm ? cm.getChatSummaries() : [];
});
/**
* Advance the read watermark for a chat.
* All events with timestamp ≤ upToTimestamp are considered read.
*/
electron_2.ipcMain.handle('chat:markRead', async (_event, chatId, upToTimestamp) => {
const cm = (0, chat_1.getChatManager)();
cm?.markRead(chatId, upToTimestamp);
return { success: true };
});
/**
* Register the local user's Qortal address so the chat manager can
* auto-accept incoming DMs addressed to them.
* Call when the user logs in; call with [] when they log out.
*/
electron_2.ipcMain.handle('chat:setLocalAddresses', async (_event, addresses) => {
const cm = (0, chat_1.getChatManager)();
if (!cm)
return { success: false, error: 'Chat manager is not running' };
cm.setLocalAddresses(Array.isArray(addresses) ? addresses : []);
return { success: true };
});
/**
* Clear the support-queue rate-limit map.
* Called when an agent logs out so re-knocks are not silently dropped
* when the agent logs back in.
*/
electron_2.ipcMain.handle('chat:clearQueueRateLimit', async () => {
const cm = (0, chat_1.getChatManager)();
if (cm)
cm.clearQueueRateLimit();
return { success: true };
});
/** Returns the list of chatIds the local node is currently subscribed to. */
electron_2.ipcMain.handle('chat:getSubscriptions', async () => {
const cm = (0, chat_1.getChatManager)();
return cm ? cm.getLocalSubscriptions() : [];
});
electron_2.ipcMain.on('chat:event:subscribe', (event) => {
chatEventSubscribers.add(event.sender);
});
electron_2.ipcMain.on('chat:event:unsubscribe', (event) => {
chatEventSubscribers.delete(event.sender);
});
electron_2.ipcMain.on('chat:typing:subscribe', (event) => {
chatTypingSubscribers.add(event.sender);
});
electron_2.ipcMain.on('chat:typing:unsubscribe', (event) => {
chatTypingSubscribers.delete(event.sender);
});
/**
* Persist and broadcast a batch of read receipts.
* `eventIds` are the IDs of events the local user has seen.
*/
electron_2.ipcMain.handle('chat:sendReadReceipt', async (_event, chatId, eventIds, readerAddress) => {
const cm = (0, chat_1.getChatManager)();
if (!cm)
return { success: false, error: 'Chat manager is not running' };
if (typeof chatId !== 'string' ||
!Array.isArray(eventIds) ||
typeof readerAddress !== 'string') {
return { success: false, error: 'Invalid arguments' };
}
cm.sendReadReceipt(chatId, eventIds, readerAddress);
return { success: true };
});
/**
* Query-scoped receipt loading.
* Returns receipts only for the provided event IDs — callers pass the IDs
* currently held in renderer memory so the result is bounded by the
* history page size rather than the total message count.
* Returns Record<eventId, readerAddress[]>.
*/
electron_2.ipcMain.handle('chat:getReadReceipts', async (_event, chatId, eventIds) => {
const cm = (0, chat_1.getChatManager)();
if (!cm)
return {};
if (typeof chatId !== 'string' || !Array.isArray(eventIds))
return {};
return cm.store.getReadReceiptsForEvents(eventIds);
});
electron_2.ipcMain.on('chat:read:subscribe', (event) => {
chatReadSubscribers.add(event.sender);
});
electron_2.ipcMain.on('chat:read:unsubscribe', (event) => {
chatReadSubscribers.delete(event.sender);
});
/**
* Fetch the encrypted attachment blob for a given event.
* Returns the base64 ciphertext string, or null when the attachment is not
* present locally (event was received via sync without attachment data).
*/
electron_2.ipcMain.handle('chat:getAttachment', async (_event, eventId) => {
const cm = (0, chat_1.getChatManager)();
if (!cm)
return null;
if (typeof eventId !== 'string' || !eventId)
return null;
return cm.store.getAttachment(eventId);
});
// ── Call IPC Handlers ─────────────────────────────────────────────────────────
const callSubscribers = new Set();
function attachCallListeners(manager) {
if (!manager)
return;
const forward = (channel) => (payload) => broadcastToSet(callSubscribers, channel, payload);
manager.on('call:incoming', forward('call:incoming'));
manager.on('call:accepted', forward('call:accepted'));
manager.on('call:rejected', forward('call:rejected'));
manager.on('call:hangup', forward('call:hangup'));
}
electron_2.ipcMain.handle('call:initiate', async (_event, targetAddress, chatId, localAddress, signature, publicKey, callId, timestamp) => {
const mgr = (0, call_1.getCallManager)();
if (!mgr)
return { success: false, error: 'Call manager not running' };
const resultCallId = await mgr.initiateCall(targetAddress, chatId, localAddress, signature, publicKey, callId, timestamp);
return resultCallId
? { success: true, callId: resultCallId }
: { success: false, error: 'Target offline' };
});
electron_2.ipcMain.handle('call:accept', async (_event, callId, signature, publicKey, timestamp) => {
const mgr = (0, call_1.getCallManager)();
if (!mgr)
return { success: false, error: 'Call manager not running' };
mgr.acceptCall(callId, signature, publicKey, timestamp);
return { success: true };
});
electron_2.ipcMain.handle('call:reject', async (_event, callId, reason, signature, publicKey, timestamp) => {
const mgr = (0, call_1.getCallManager)();
if (!mgr)
return { success: false, error: 'Call manager not running' };
mgr.rejectCall(callId, reason, signature, publicKey, timestamp);
return { success: true };
});
electron_2.ipcMain.handle('call:hangup', async (_event, callId, signature, publicKey, timestamp) => {
const mgr = (0, call_1.getCallManager)();
if (!mgr)
return { success: false, error: 'Call manager not running' };
mgr.hangUp(callId, signature, publicKey, timestamp);
return { success: true };
});
electron_2.ipcMain.handle('call:setLocalAddresses', async (_event, addresses) => {
const mgr = (0, call_1.getCallManager)();
if (!mgr)
return { success: false, error: 'Call manager not running' };
mgr.setLocalAddresses(Array.isArray(addresses) ? addresses : []);
return { success: true };
});
electron_2.ipcMain.on('call:subscribe', (event) => {
callSubscribers.add(event.sender);
const mgr = (0, call_1.getCallManager)();
if (!mgr || event.sender.isDestroyed())
return;
for (const p of mgr.getPendingInboundRingingPayloads()) {
event.sender.send('call:incoming', p);
}
for (const p of mgr.getActiveOutboundAcceptedPayloads()) {
event.sender.send('call:accepted', p);
}
});
electron_2.ipcMain.on('call:unsubscribe', (event) => {
callSubscribers.delete(event.sender);
});
// ── Group Call IPC Handlers ───────────────────────────────────────────────────
const gcallSubscribers = new Set();
/** Sidebar / list: lightweight `gcall:qortal-group-call-activity` only (no full GC_* stream). */
const gcallActivitySubscribers = new Set();
/** Throttled [GCall:main] logs for gcall:audio (manager received → IPC forward). */
let gcallMainFirstAudio = false;
let gcallMainAudioCountWindow = 0;
let gcallMainAudioWindowT0 = 0;
const GCALL_MAIN_AUDIO_LOG_MS = 2000;
function gcallAudioPayloadBytes(data) {
if (data instanceof ArrayBuffer)
return data.byteLength;
if (ArrayBuffer.isView(data))
return data.byteLength;
return 0;
}
function withGcallAudioMainFanoutTimestamp(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return payload;
}
const record = payload;
const existingStage = record.audioStageTimestamps &&
typeof record.audioStageTimestamps === 'object' &&
!Array.isArray(record.audioStageTimestamps)
? record.audioStageTimestamps
: {};
return {
...record,
audioStageTimestamps: {
...existingStage,
bridgeReceivedAtWallMs: typeof record.bridgeReceivedAtWallMs === 'number' &&
record.bridgeReceivedAtWallMs > 0
? record.bridgeReceivedAtWallMs
: existingStage.bridgeReceivedAtWallMs,
mainFanoutAtWallMs: Date.now(),
},
};
}
function attachGroupCallListeners(manager) {
if (!manager)
return;
const forward = (channel) => (payload) => broadcastToSet(gcallSubscribers, channel, payload);
manager.on('gcall:participant-joined', forward('gcall:participant-joined'));
manager.on('gcall:participant-left', forward('gcall:participant-left'));
manager.on('gcall:topology', forward('gcall:topology'));
manager.on('gcall:cluster-heartbeat', forward('gcall:cluster-heartbeat'));
manager.on('gcall:heartbeat', forward('gcall:heartbeat'));
manager.on('gcall:audio', (payload) => {
gcallMainAudioCountWindow += 1;
const now = Date.now();
if (gcallMainAudioWindowT0 === 0)
gcallMainAudioWindowT0 = now;
if (!gcallMainFirstAudio) {
gcallMainFirstAudio = true;
const p0 = payload;
(0, logger_1.log)(`[GCall:main] gcall:audio first from manager roomId=${p0?.roomId} from=${p0?.fromAddress} bytes~=${gcallAudioPayloadBytes(p0?.data)}${gcallSubscribers.size} IPC subscriber(s)`);
}
if (now - gcallMainAudioWindowT0 >= GCALL_MAIN_AUDIO_LOG_MS) {
const p = payload;
(0, logger_1.log)(`[GCall:main] gcall:audio throttled: ${gcallMainAudioCountWindow} pkt in ~${now - gcallMainAudioWindowT0}ms roomId=${p?.roomId} from=${p?.fromAddress} bytes~=${gcallAudioPayloadBytes(p?.data)} subs=${gcallSubscribers.size}`);
gcallMainAudioCountWindow = 0;
gcallMainAudioWindowT0 = now;
}
broadcastToSet(gcallSubscribers, 'gcall:audio', withGcallAudioMainFanoutTimestamp(payload));
});
manager.on('gcall:key', (payload) => {
const p = payload;
(0, logger_1.log)(`[GCall:main] gcall:key from manager roomId=${p?.roomId} from=${p?.fromAddress} verified=${p?.verified}${gcallSubscribers.size} subscriber(s)`);
broadcastToSet(gcallSubscribers, 'gcall:key', payload);
});
manager.on('gcall:key-request', forward('gcall:key-request'));
manager.on('gcall:session-updated', forward('gcall:session-updated'));
manager.on('gcall:qortal-group-call-activity', (payload) => broadcastToSet(gcallActivitySubscribers, 'gcall:qortal-group-call-activity', payload));
}
electron_2.ipcMain.handle('gcall:join', async (_event, roomId, chatId, localAddress, signature, publicKey, timestamp, reticulumDestinationHash, joinGeneration, topologyEpochFloor, reticulumIdentityPublicKeyBase64, joinRkSignature) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
try {
const session = mgr.joinRoom(roomId, chatId, localAddress, signature, publicKey, timestamp, reticulumDestinationHash, joinGeneration, topologyEpochFloor, reticulumIdentityPublicKeyBase64, joinRkSignature);
return {
success: true,
callSessionId: session.callSessionId,
mediaSessionGeneration: session.mediaSessionGeneration,
};
}
catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err),
};
}
});
electron_2.ipcMain.handle('gcall:leave', async (_event, roomId, localAddress, signature, publicKey, timestamp) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
mgr.leaveRoom(roomId, localAddress, signature, publicKey, timestamp);
return { success: true };
});
electron_2.ipcMain.on('gcall:leaveSync', (event, roomId, localAddress, signature, publicKey, timestamp) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr) {
event.returnValue = {
success: false,
error: 'GroupCall manager not running',
};
return;
}
mgr.leaveRoom(roomId, localAddress, signature, publicKey, timestamp);
event.returnValue = { success: true };
});
electron_2.ipcMain.handle('gcall:broadcastTopology', async (_event, roomId, topology, signature, publicKey, timestamp) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
mgr.broadcastTopology(roomId, topology, signature, publicKey, timestamp);
return { success: true };
});
electron_2.ipcMain.handle('gcall:sendClusterHeartbeat', async (_event, roomId, payload, signature) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
mgr.sendClusterHeartbeat(roomId, payload, signature);
return { success: true };
});
electron_2.ipcMain.handle('gcall:sendAudio', async (_event, roomId, toAddress, data, timing) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
attachGroupAudioIpcTiming(buf, timing, {
channel: 'sendAudio',
roomId,
targetCount: 1,
});
const GCALL_IPC_SEND_AUDIO_MAX_BYTES = 12288;
if (buf.length > GCALL_IPC_SEND_AUDIO_MAX_BYTES) {
return { success: false, error: 'payload-too-large' };
}
const result = mgr.sendAudio(roomId, toAddress, buf);
if (result.success) {
return { success: true, diagnostics: result.diagnostics };
}
return {
success: false,
error: ('error' in result ? result.error : undefined) ?? 'relay-rejected',
diagnostics: result.diagnostics,
};
});
electron_2.ipcMain.handle('gcall:sendAudioBatch', async (_event, roomId, toAddresses, data, timing) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
attachGroupAudioIpcTiming(buf, timing, {
channel: 'sendAudioBatch',
roomId,
targetCount: Array.isArray(toAddresses) ? toAddresses.length : 0,
});
const GCALL_IPC_SEND_AUDIO_MAX_BYTES = 12288;
if (buf.length > GCALL_IPC_SEND_AUDIO_MAX_BYTES) {
return { success: false, error: 'payload-too-large' };
}
if (!Array.isArray(toAddresses) || toAddresses.length === 0) {
return { success: true, diagnostics: undefined };
}
const result = mgr.sendAudioBatch(roomId, toAddresses, buf);
if (result.success) {
return { success: true, diagnostics: result.diagnostics };
}
return {
success: false,
error: ('error' in result ? result.error : undefined) ?? 'relay-rejected',
diagnostics: result.diagnostics,
};
});
electron_2.ipcMain.handle('gcall:getAudioDataPlaneSession', async (_event, roomId, toAddresses) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { ok: false, reason: 'manager-unavailable' };
if (!Array.isArray(toAddresses) || toAddresses.length === 0) {
return { ok: false, reason: 'no-targets' };
}
const result = await mgr.getAudioDataPlaneSession(roomId, toAddresses);
return result;
});
electron_2.ipcMain.handle('gcall:requestPeerMediaRecovery', async (_event, roomId, address, reason) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
mgr.requestPeerMediaRecovery(roomId, address, reason);
return { success: true };
});
electron_2.ipcMain.handle('gcall:reportGcallAudioEscalation', async (_event, opts) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
mgr.reportGcallAudioEscalation(opts ?? {});
return { success: true };
});
electron_2.ipcMain.handle('gcall:getLinkStats', async (_event, roomId) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
return {
success: true,
stats: mgr.getReticulumAudioLinkStats(roomId),
};
});
electron_2.ipcMain.handle('gcall:sendKey', async (_event, roomId, toAddress, encryptedKey, fromAddress, signature, publicKey, timestamp, meta) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
return mgr.sendKey(roomId, toAddress, encryptedKey, fromAddress, signature, publicKey, timestamp, meta);
});
electron_2.ipcMain.handle('gcall:sendKeyRequest', async (_event, roomId, toAddress, fromAddress, signature, publicKey, timestamp, callSessionId, mediaSessionGeneration) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
mgr.sendKeyRequest(roomId, toAddress, fromAddress, signature, publicKey, timestamp, callSessionId, mediaSessionGeneration);
return { success: true };
});
electron_2.ipcMain.handle('gcall:requestSessionBreak', async (_event, roomId) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
const r = mgr.requestSessionBreak(roomId);
return r.ok
? { success: true }
: { success: false, error: r.error ?? 'rejected' };
});
electron_2.ipcMain.handle('gcall:setLocalAddresses', async (_event, addresses, source) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
mgr.setLocalAddresses(Array.isArray(addresses) ? addresses : [], typeof source === 'string' ? source : undefined);
return { success: true };
});
electron_2.ipcMain.handle('gcall:setQortalGroupReticulumTargets', async (_event, roomId, addresses) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
mgr.setQortalGroupReticulumTargets(typeof roomId === 'string' ? roomId : '', Array.isArray(addresses) ? addresses : []);
return { success: true };
});
electron_2.ipcMain.handle('gcall:getRoomParticipants', async (_event, roomId) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return [];
return mgr.getRoomParticipants(roomId);
});
electron_2.ipcMain.handle('gcall:getRoomBootstrapState', async (_event, roomId) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return null;
return mgr.getRoomBootstrapState(roomId);
});
electron_2.ipcMain.handle('gcall:reportTransportHealth', async (_event, roomId, healthyPeerAddresses) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
mgr.reportTransportHealth(roomId, Array.isArray(healthyPeerAddresses) ? healthyPeerAddresses : []);
return { success: true };
});
electron_2.ipcMain.handle('gcall:getPendingKeyMetrics', async () => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr) {
return {
pending_key_flush_success: 0,
pending_key_expired: 0,
pendingRooms: 0,
};
}
return mgr.getPendingKeyMetrics();
});
/**
* The hidden audio-surface cannot decrypt the wallet for `signPresenceMessage` (per-
* renderer in-memory key). Forward signing/decrypt to the main shell where the
* `background` message listener and keyPair are valid.
*/
electron_2.ipcMain.handle('gcall:proxySignPresenceMessage', async (event, payload) => {
if (!isAudioSurfaceHostSender(event.sender)) {
return { error: 'forbidden' };
}
const main = _1.myCapacitorApp.getMainWindow();
if (!main || main.isDestroyed()) {
return { error: 'main-window-unavailable' };
}
const pJson = JSON.stringify(payload ?? {});
try {
return await main.webContents.executeJavaScript(`(async () => {
const __p = ${pJson};
const result = await window.sendMessage('signPresenceMessage', __p, 10000);
if (result && typeof result === 'object' && result.error) {
return { error: String(result.error), message: result.message };
}
if (result && typeof result.signature === 'string') {
return { signature: result.signature };
}
return { error: 'signPresenceMessage returned no signature' };
})()`, true);
}
catch (e) {
return {
error: e instanceof Error ? e.message : 'gcall-proxy-sign-failed',
};
}
});
electron_2.ipcMain.handle('gcall:proxyDecryptBoxWithMyKey', async (event, payload) => {
if (!isAudioSurfaceHostSender(event.sender)) {
return { error: 'forbidden' };
}
const main = _1.myCapacitorApp.getMainWindow();
if (!main || main.isDestroyed()) {
return { error: 'main-window-unavailable' };
}
const pJson = JSON.stringify(payload ?? {});
try {
return await main.webContents.executeJavaScript(`(async () => {
const __p = ${pJson};
const result = await window.sendMessage('decryptBoxWithMyKey', __p, 10000);
if (result && typeof result === 'object' && result.error) {
return { error: String(result.error), message: result.message };
}
if (result && typeof result.decryptedKey === 'string') {
return { decryptedKey: result.decryptedKey };
}
return { error: 'decryptBoxWithMyKey returned no key' };
})()`, true);
}
catch (e) {
return {
error: e instanceof Error ? e.message : 'gcall-proxy-decrypt-failed',
};
}
});
electron_2.ipcMain.handle('audio-surface:ensure-ready', async (event) => {
if (!isMainShellSender(event.sender)) {
(0, logger_1.log)('[GCall:audio-surface] ensure-ready: rejected (not main shell)', {
senderId: event.sender.id,
});
return { success: false, error: 'audio-surface-main-shell-required' };
}
await _1.myCapacitorApp.ensureAudioSurfaceWindow();
await waitForAudioSurfaceHostReady();
const audioWindow = _1.myCapacitorApp.getAudioSurfaceWindow();
if (!audioWindow || audioWindow.isDestroyed() || !audioSurfaceHostReady) {
(0, logger_1.log)('[GCall:audio-surface] ensure-ready: window unavailable');
return { success: false, error: 'audio-surface-window-unavailable' };
}
(0, logger_1.log)('[GCall:audio-surface] ensure-ready: ok (audio window + host ready)');
return { success: true };
});
electron_2.ipcMain.handle('audio-surface:is-ready', async (event) => {
if (!isMainShellSender(event.sender)) {
return false;
}
const audioWindow = _1.myCapacitorApp.getAudioSurfaceWindow();
return Boolean(audioWindow && !audioWindow.isDestroyed() && audioSurfaceHostReady);
});
electron_2.ipcMain.handle('audio-surface:send-command', async (_event, command) => {
if (!isMainShellSender(_event.sender)) {
(0, logger_1.log)('[GCall:audio-surface] send-command: rejected (not main shell)', {
type: command.type,
});
return { ok: false, error: 'audio-surface-main-shell-required' };
}
if (command.type === 'join-group-call') {
(0, logger_1.log)('[GCall:audio-surface] send-command: join-group-call', {
roomId: command.roomId,
chatId: command.chatId,
});
}
const existingAudioWindow = _1.myCapacitorApp.getAudioSurfaceWindow();
const hasUsableAudioWindow = Boolean(existingAudioWindow && !existingAudioWindow.isDestroyed());
if (!hasUsableAudioWindow &&
(command.type === 'logout-cleanup' ||
command.type === 'leave-group-call' ||
command.type === 'stop-direct-voice-media' ||
command.type === 'stop-direct-voice-receive')) {
return { ok: true };
}
await _1.myCapacitorApp.ensureAudioSurfaceWindow();
await waitForAudioSurfaceHostReady();
const audioWindow = _1.myCapacitorApp.getAudioSurfaceWindow();
if (!audioWindow || audioWindow.isDestroyed() || !audioSurfaceHostReady) {
(0, logger_1.log)('[GCall:audio-surface] send-command: audio window missing/destroyed');
return { ok: false, error: 'audio-surface-window-unavailable' };
}
const commandId = `${Date.now()}:${Math.random().toString(16).slice(2)}`;
const envelope = { commandId, command };
const response = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pendingAudioSurfaceCommands.delete(commandId);
reject(new Error('audio-surface-command-timeout'));
}, 30000);
pendingAudioSurfaceCommands.set(commandId, {
resolve: (value) => {
clearTimeout(timeout);
resolve(value);
},
reject: (reason) => {
clearTimeout(timeout);
reject(reason);
},
});
audioWindow.webContents.send('audio-surface:host-command', envelope);
}).catch((error) => ({
ok: false,
error: error instanceof Error ? error.message : 'audio-surface-command-failed',
}));
if (command.type === 'join-group-call' ||
response.ok === false) {
(0, logger_1.log)('[GCall:audio-surface] send-command: response', {
type: command.type,
ok: response.ok,
error: response.ok === false
? response.error
: undefined,
});
}
const responsePayload = response.payload;
if (command.type === 'logout-cleanup') {
_1.myCapacitorApp.closeAudioSurfaceWindow('logout-cleanup');
}
else if (response.ok === true &&
responsePayload?.idle === true &&
(command.type === 'leave-group-call' ||
command.type === 'stop-direct-voice-media' ||
command.type === 'stop-direct-voice-receive')) {
_1.myCapacitorApp.scheduleAudioSurfaceIdleClose(command.type);
}
return response;
});
electron_2.ipcMain.on('audio-surface:subscribe', (event) => {
if (!isMainShellSender(event.sender)) {
(0, logger_1.warn)('[AudioSurface] rejecting subscribe from non-main-shell sender', {
senderId: event.sender.id,
});
return;
}
audioSurfaceSubscribers.add(event.sender);
if (audioSurfaceBridgeState.hostReady) {
event.sender.send('audio-surface:event', {
type: 'engine-ready',
bootstrapRevisionApplied: audioSurfaceBridgeState.bootstrapRevisionApplied,
});
}
if (audioSurfaceBridgeState.snapshot !== null) {
event.sender.send('audio-surface:event', {
type: 'snapshot',
snapshot: audioSurfaceBridgeState.snapshot,
});
}
});
electron_2.ipcMain.on('audio-surface:unsubscribe', (event) => {
audioSurfaceSubscribers.delete(event.sender);
});
electron_2.ipcMain.on('audio-surface:host-ready', (event) => {
if (!isAudioSurfaceHostSender(event.sender)) {
(0, logger_1.warn)('[AudioSurface] rejecting host-ready from unexpected sender', {
senderId: event.sender.id,
});
return;
}
markAudioSurfaceHostReady();
});
electron_2.ipcMain.on('audio-surface:host-event', (event, payload) => {
if (!isAudioSurfaceHostSender(event.sender)) {
(0, logger_1.warn)('[AudioSurface] rejecting host-event from unexpected sender', {
senderId: event.sender.id,
type: payload?.type ?? 'unknown',
});
return;
}
emitAudioSurfaceEvent(payload);
});
/**
* Audio surface must report command results via invoke (not one-way send) so the
* main process always pairs a reply with the pending `send-command` promise.
*/
electron_2.ipcMain.handle('audio-surface:command-result', (event, envelope) => {
if (!isAudioSurfaceHostSender(event.sender)) {
(0, logger_1.warn)('[AudioSurface] command-result: rejected sender', {
senderId: event.sender.id,
isolatedIds: [...isolatedAudioSurfaceContents],
});
return { ack: false, reason: 'bad-sender' };
}
const commandId = envelope?.commandId;
const response = envelope?.response;
if (typeof commandId !== 'string' || !commandId) {
(0, logger_1.warn)('[AudioSurface] command-result: missing commandId', {
envelope,
});
return { ack: false, reason: 'missing-command-id' };
}
const pending = pendingAudioSurfaceCommands.get(commandId);
if (!pending) {
(0, logger_1.warn)('[AudioSurface] command-result: no pending op', {
commandId,
pendingCount: pendingAudioSurfaceCommands.size,
sampleIds: [...pendingAudioSurfaceCommands.keys()].slice(0, 5),
});
return { ack: false, reason: 'unknown-command' };
}
pendingAudioSurfaceCommands.delete(commandId);
pending.resolve(response);
return { ack: true };
});
electron_2.ipcMain.on('gcall:subscribe', (event) => {
gcallSubscribers.add(event.sender);
const url = event.sender.isDestroyed()
? ''
: String(event.sender.getURL() ?? '');
(0, logger_1.log)(`[GCall:main] gcall:subscribe from sender (total gcall subscribers=${gcallSubscribers.size}) ${url ? `url=${url.slice(0, 80)}` : ''}`);
(0, group_call_1.getGroupCallManager)()?.replayRetainedVerifiedKeyStatesTo(event.sender);
});
electron_2.ipcMain.on('gcall:unsubscribe', (event) => {
gcallSubscribers.delete(event.sender);
(0, logger_1.log)(`[GCall:main] gcall:unsubscribe (remaining=${gcallSubscribers.size})`);
});
/**
* Audio-surface subscribes before `gcall:join`; retained keys may only exist after
* joinRoom finishes. Request a second replay so the hidden window receives keys
* that landed in the manager after the initial subscribe-time replay.
*/
electron_2.ipcMain.on('gcall:request-key-replay', (event) => {
if (event.sender.isDestroyed())
return;
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return;
mgr.replayRetainedVerifiedKeyStatesTo(event.sender);
});
electron_2.ipcMain.on('gcall:subscribe-activity', (event) => {
gcallActivitySubscribers.add(event.sender);
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr || event.sender.isDestroyed())
return;
const activeByGroupId = mgr.getQortalGroupCallActivitySnapshotForSidebar();
event.sender.send('gcall:qortal-group-call-activity', activeByGroupId);
});
electron_2.ipcMain.on('gcall:unsubscribe-activity', (event) => {
gcallActivitySubscribers.delete(event.sender);
});
electron_2.ipcMain.handle('gcall:setWatchedQortalGroupIds', async (_event, ids) => {
const mgr = (0, group_call_1.getGroupCallManager)();
if (!mgr)
return { success: false, error: 'GroupCall manager not running' };
const list = Array.isArray(ids) ? ids : [];
const activity = mgr.setWatchedQortalGroupIds(list);
return { success: true, ...activity };
});