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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 94 additions & 3 deletions opennow-stable/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { app, BrowserWindow, ipcMain, dialog, systemPreferences, session } from "electron";
import { fileURLToPath } from "node:url";
import { app, BrowserWindow, ipcMain, dialog, session, systemPreferences } from "electron";import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { existsSync, readFileSync } from "node:fs";

Expand Down Expand Up @@ -191,7 +191,51 @@ function emitToRenderer(event: MainToRendererSignalingEvent): void {
}
}

async function createMainWindow(): Promise<void> {
function setupWebHidPermissions(): void {
const ses = session.defaultSession;

ses.setDevicePermissionHandler((details) => {
if (details.deviceType === "hid") {
return true;
}
return true;
});

ses.setPermissionCheckHandler((_webContents, permission) => {
if (permission === "hid" || permission === "media" || permission === "keyboardLock") {
return true;
}
return true;
});

ses.setPermissionRequestHandler((_webContents, permission, callback) => {
if (permission === "media" || permission === "keyboardLock") {
callback(true);
return;
}
callback(true);
});

ses.on("select-hid-device", (event, details, callback) => {
event.preventDefault();
const ungranted = details.deviceList.find((d) => !grantedHidDeviceIds.has(d.deviceId));
const selected = ungranted ?? details.deviceList[0];
if (selected) {
grantedHidDeviceIds.add(selected.deviceId);
callback(selected.deviceId);
} else {
callback("");
}
});

ses.on("hid-device-added", (_event, _details) => {
// WebHID connect event handled in renderer via navigator.hid
});

ses.on("hid-device-removed", (_event, _details) => {
// WebHID disconnect event handled in renderer via navigator.hid
});
}async function createMainWindow(): Promise<void> {
const preloadMjsPath = join(__dirname, "../preload/index.mjs");
const preloadJsPath = join(__dirname, "../preload/index.js");
const preloadPath = existsSync(preloadMjsPath) ? preloadMjsPath : preloadJsPath;
Expand Down Expand Up @@ -498,8 +542,46 @@ function registerIpcHandlers(): void {
// Logs export IPC handler
ipcMain.handle(IPC_CHANNELS.LOGS_EXPORT, async (_event, format: "text" | "json" = "text"): Promise<string> => {
return exportLogs(format);
ipcMain.handle(IPC_CHANNELS.DISCORD_UPDATE_PRESENCE, async (_event, payload: DiscordPresencePayload) => {
await discordService.updatePresence(payload);
});

ipcMain.handle(IPC_CHANNELS.DISCORD_CLEAR_PRESENCE, async () => {
await discordService.clearPresence();
});

ipcMain.handle(IPC_CHANNELS.FLIGHT_GET_PROFILE, (_event, vidPid: string, gameId?: string) => {
return flightProfileManager.getProfile(vidPid, gameId);
});

ipcMain.handle(IPC_CHANNELS.FLIGHT_SET_PROFILE, (_event, profile: FlightProfile) => {
flightProfileManager.setProfile(profile);
});

ipcMain.handle(IPC_CHANNELS.FLIGHT_DELETE_PROFILE, (_event, vidPid: string, gameId?: string) => {
flightProfileManager.deleteProfile(vidPid, gameId);
});

ipcMain.handle(IPC_CHANNELS.FLIGHT_GET_ALL_PROFILES, () => {
return flightProfileManager.getAllProfiles();
});

ipcMain.handle(IPC_CHANNELS.FLIGHT_RESET_PROFILE, (_event, vidPid: string) => {
return flightProfileManager.resetProfile(vidPid);
});

ipcMain.handle(IPC_CHANNELS.HDR_GET_OS_INFO, () => {
return getOsHdrInfo();
});

ipcMain.handle(IPC_CHANNELS.MIC_ENUMERATE_DEVICES, async () => {
return [];
});

ipcMain.handle(IPC_CHANNELS.APP_RELAUNCH, () => {
app.relaunch();
app.exit(0); });

// Save window size when it changes
mainWindow?.on("resize", () => {
if (mainWindow && !mainWindow.isDestroyed()) {
Expand Down Expand Up @@ -569,7 +651,16 @@ app.whenReady().then(async () => {
return allowedPermissions.has(permission);
});

registerIpcHandlers();
if (process.platform === "darwin") {
const micAccess = systemPreferences.getMediaAccessStatus("microphone");
console.log(`[Main] macOS microphone access status: ${micAccess}`);
if (micAccess !== "granted") {
const granted = await systemPreferences.askForMediaAccess("microphone");
console.log(`[Main] macOS microphone access prompt result: ${granted}`);
}
}

setupWebHidPermissions(); registerIpcHandlers();
await createMainWindow();

app.on("activate", async () => {
Expand Down
44 changes: 41 additions & 3 deletions opennow-stable/src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { app } from "electron";
import { join } from "node:path";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import type { VideoCodec, ColorQuality, VideoAccelerationPreference, MicrophoneMode } from "@shared/gfn";

import type { VideoCodec, ColorQuality, VideoAccelerationPreference, FlightSlotConfig, HdrStreamingMode, MicMode } from "@shared/gfn";
import { defaultFlightSlots } from "@shared/gfn";
export interface Settings {
/** Video resolution (e.g., "1920x1080") */
resolution: string;
Expand Down Expand Up @@ -48,7 +49,32 @@ export interface Settings {
windowWidth: number;
/** Window height */
windowHeight: number;
}
/** Enable Discord Rich Presence */
discordPresenceEnabled: boolean;
/** Discord Application Client ID */
discordClientId: string;
/** Enable flight controls (HOTAS/joystick) */
flightControlsEnabled: boolean;
/** Controller slot for flight controls (0-3) — legacy, kept for compat */
flightControlsSlot: number;
/** Per-slot flight configurations */
flightSlots: FlightSlotConfig[];
/** HDR streaming mode: off, auto, on */
hdrStreaming: HdrStreamingMode;
/** Microphone mode: off, on, push-to-talk */
micMode: MicMode;
/** Selected microphone device ID (empty = default) */
micDeviceId: string;
/** Microphone input gain 0.0 - 2.0 */
micGain: number;
/** Enable noise suppression */
micNoiseSuppression: boolean;
/** Enable automatic gain control */
micAutoGainControl: boolean;
/** Enable echo cancellation */
micEchoCancellation: boolean;
/** Toggle mic on/off shortcut (works in-stream) */
shortcutToggleMic: string;}

const defaultStopShortcut = "Ctrl+Shift+Q";
const defaultAntiAfkShortcut = "Ctrl+Shift+K";
Expand Down Expand Up @@ -79,7 +105,19 @@ const DEFAULT_SETTINGS: Settings = {
sessionClockShowDurationSeconds: 30,
windowWidth: 1400,
windowHeight: 900,
};
discordPresenceEnabled: false,
discordClientId: "",
flightControlsEnabled: false,
flightControlsSlot: 3,
flightSlots: defaultFlightSlots(),
hdrStreaming: "off",
micMode: "off",
micDeviceId: "",
micGain: 1.0,
micNoiseSuppression: true,
micAutoGainControl: true,
micEchoCancellation: true,
shortcutToggleMic: "Ctrl+Shift+M",};

export class SettingsManager {
private settings: Settings;
Expand Down
28 changes: 26 additions & 2 deletions opennow-stable/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import type {
IceCandidatePayload,
Settings,
SubscriptionFetchRequest,
} from "@shared/gfn";
DiscordPresencePayload,
FlightProfile,
MicDeviceInfo,} from "@shared/gfn";

// Extend the OpenNowApi interface for internal preload use
type PreloadApi = OpenNowApi;
Expand Down Expand Up @@ -74,6 +76,28 @@ const api: PreloadApi = {
ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_SET, key, value),
resetSettings: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_RESET),
exportLogs: (format?: "text" | "json") => ipcRenderer.invoke(IPC_CHANNELS.LOGS_EXPORT, format),
};
updateDiscordPresence: (state: DiscordPresencePayload) =>
ipcRenderer.invoke(IPC_CHANNELS.DISCORD_UPDATE_PRESENCE, state),
clearDiscordPresence: () => ipcRenderer.invoke(IPC_CHANNELS.DISCORD_CLEAR_PRESENCE),
flightGetProfile: (vidPid: string, gameId?: string) =>
ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_GET_PROFILE, vidPid, gameId),
flightSetProfile: (profile: FlightProfile) =>
ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_SET_PROFILE, profile),
flightDeleteProfile: (vidPid: string, gameId?: string) =>
ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_DELETE_PROFILE, vidPid, gameId),
flightGetAllProfiles: () => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_GET_ALL_PROFILES),
flightResetProfile: (vidPid: string) => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_RESET_PROFILE, vidPid),
getOsHdrInfo: () => ipcRenderer.invoke(IPC_CHANNELS.HDR_GET_OS_INFO),
relaunchApp: () => ipcRenderer.invoke(IPC_CHANNELS.APP_RELAUNCH),
micEnumerateDevices: () => ipcRenderer.invoke(IPC_CHANNELS.MIC_ENUMERATE_DEVICES),
onMicDevicesChanged: (listener: (devices: MicDeviceInfo[]) => void) => {
const wrapped = (_event: Electron.IpcRendererEvent, devices: MicDeviceInfo[]) => {
listener(devices);
};
ipcRenderer.on(IPC_CHANNELS.MIC_DEVICES_CHANGED, wrapped);
return () => {
ipcRenderer.off(IPC_CHANNELS.MIC_DEVICES_CHANGED, wrapped);
};
},};

contextBridge.exposeInMainWorld("openNow", api);
97 changes: 92 additions & 5 deletions opennow-stable/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type StreamDiagnostics,
type StreamTimeWarning,
} from "./gfn/webrtcClient";
import { MicAudioService } from "./gfn/micAudioService";
import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut } from "./shortcuts";
import { useControllerNavigation } from "./controllerNavigation";

Expand Down Expand Up @@ -66,7 +67,7 @@ const DEFAULT_SHORTCUTS = {
shortcutStopStream: "Ctrl+Shift+Q",
shortcutToggleAntiAfk: "Ctrl+Shift+K",
shortcutToggleMicrophone: "Ctrl+Shift+M",
} as const;
shortcutToggleMic: "Ctrl+Shift+M",} as const;

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
Expand Down Expand Up @@ -286,7 +287,19 @@ export function App(): JSX.Element {
sessionClockShowDurationSeconds: 30,
windowWidth: 1400,
windowHeight: 900,
});
discordPresenceEnabled: false,
discordClientId: "",
flightControlsEnabled: false,
flightControlsSlot: 3,
flightSlots: defaultFlightSlots(),
hdrStreaming: "off",
micMode: "off",
micDeviceId: "",
micGain: 1.0,
micNoiseSuppression: true,
micAutoGainControl: true,
micEchoCancellation: true,
shortcutToggleMic: DEFAULT_SHORTCUTS.shortcutToggleMic, });
const [settingsLoaded, setSettingsLoaded] = useState(false);
const [regions, setRegions] = useState<StreamRegion[]>([]);
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(null);
Expand Down Expand Up @@ -347,6 +360,7 @@ export function App(): JSX.Element {
const regionsRequestRef = useRef(0);
const launchInFlightRef = useRef(false);
const exitPromptResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
const micServiceRef = useRef<MicAudioService | null>(null);

// Session ref sync
useEffect(() => {
Expand Down Expand Up @@ -536,13 +550,14 @@ export function App(): JSX.Element {
const toggleAntiAfk = parseWithFallback(settings.shortcutToggleAntiAfk, DEFAULT_SHORTCUTS.shortcutToggleAntiAfk);
const toggleMicrophone = parseWithFallback(settings.shortcutToggleMicrophone, DEFAULT_SHORTCUTS.shortcutToggleMicrophone);
return { toggleStats, togglePointerLock, stopStream, toggleAntiAfk, toggleMicrophone };
}, [
const toggleMic = parseWithFallback(settings.shortcutToggleMic, DEFAULT_SHORTCUTS.shortcutToggleMic);
return { toggleStats, togglePointerLock, stopStream, toggleAntiAfk, toggleMic }; }, [
settings.shortcutToggleStats,
settings.shortcutTogglePointerLock,
settings.shortcutStopStream,
settings.shortcutToggleAntiAfk,
settings.shortcutToggleMicrophone,
]);
settings.shortcutToggleMic, ]);

const requestEscLockedPointerCapture = useCallback(async (target: HTMLVideoElement) => {
if (!document.fullscreenElement) {
Expand Down Expand Up @@ -1328,11 +1343,39 @@ export function App(): JSX.Element {
clientRef.current?.toggleMicrophone();
}
}

if (isShortcutMatch(e, shortcuts.toggleMic)) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (streamStatus === "streaming" && micServiceRef.current) {
const svc = micServiceRef.current;
if (svc.getMode() === "push-to-talk") {
svc.setPttActive(true);
} else {
svc.toggleMute();
}
}
}
};

// Use capture phase so app shortcuts run before stream input capture listeners.
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);

const handleKeyUp = (e: KeyboardEvent): void => {
if (streamStatus !== "streaming") return;
if (!micServiceRef.current) return;
const svc = micServiceRef.current;
if (svc.getMode() === "push-to-talk" && isShortcutMatch(e, shortcuts.toggleMic)) {
svc.setPttActive(false);
}
};
window.addEventListener("keyup", handleKeyUp, true);

return () => {
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
};
}, [
exitPrompt.open,
handleExitPromptCancel,
Expand All @@ -1344,6 +1387,50 @@ export function App(): JSX.Element {
streamStatus,
]);

useEffect(() => {
if (streamStatus === "streaming") {
if (!micServiceRef.current) {
micServiceRef.current = new MicAudioService();
}
const svc = micServiceRef.current;

if (clientRef.current) {
clientRef.current.setMicService(svc);
}

void svc.configure({
mode: settings.micMode,
deviceId: settings.micDeviceId,
gain: settings.micGain,
noiseSuppression: settings.micNoiseSuppression,
autoGainControl: settings.micAutoGainControl,
echoCancellation: settings.micEchoCancellation,
});
} else {
if (micServiceRef.current) {
micServiceRef.current.dispose();
micServiceRef.current = null;
}
}
}, [
streamStatus,
settings.micMode,
settings.micDeviceId,
settings.micGain,
settings.micNoiseSuppression,
settings.micAutoGainControl,
settings.micEchoCancellation,
]);

useEffect(() => {
return () => {
if (micServiceRef.current) {
micServiceRef.current.dispose();
micServiceRef.current = null;
}
};
}, []);

// Filter games by search
const filteredGames = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
Expand Down
Loading