From c087f96effc9ec5dfb2847c65f9a31d126749500 Mon Sep 17 00:00:00 2001 From: Jason Novich Date: Tue, 26 May 2026 12:44:38 +0300 Subject: [PATCH] added second screen support --- .../controllers/main-window.controller.ts | 173 +++++++++++++++++- .../controllers/overlay/overlay.controller.ts | 17 ++ src/preload/preload.ts | 6 + .../components/app-settings/app-settings.tsx | 7 + .../app-settings/display-settings.tsx | 85 +++++++++ src/renderer/app/styles/settings-page.ts | 28 ++- 6 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 src/renderer/app/components/app-settings/display-settings.tsx diff --git a/src/browser/controllers/main-window.controller.ts b/src/browser/controllers/main-window.controller.ts index b06437b..12cb4a3 100644 --- a/src/browser/controllers/main-window.controller.ts +++ b/src/browser/controllers/main-window.controller.ts @@ -25,6 +25,100 @@ const owElectronApp = electronApp as overwolf.OverwolfApp; */ export class MainWindowController { private browserWindow: BrowserWindow = null; + private selectedDisplayId: number | null = null; + + private get settingsFilePath(): string { + return path.join(electronApp.getPath('userData'), 'window-settings.json'); + } + + private loadDisplaySettings(): void { + try { + if (fs.existsSync(this.settingsFilePath)) { + const data = JSON.parse(fs.readFileSync(this.settingsFilePath, 'utf-8')); + this.selectedDisplayId = typeof data.selectedDisplayId === 'number' + ? data.selectedDisplayId + : null; + } + } catch { + this.selectedDisplayId = null; + } + } + + private saveDisplaySettings(): void { + try { + fs.writeFileSync( + this.settingsFilePath, + JSON.stringify({ selectedDisplayId: this.selectedDisplayId }), + 'utf-8', + ); + } catch (error) { + console.error('Failed to save display settings:', error); + } + } + + private resolveTargetDisplay(): Electron.Display { + const all = screen.getAllDisplays(); + const primary = screen.getPrimaryDisplay(); + if (this.selectedDisplayId !== null) { + const saved = all.find((d) => d.id === this.selectedDisplayId); + if (saved) return saved; + this.selectedDisplayId = null; + this.saveDisplaySettings(); + } + return primary; + } + + // Base window size was designed for a 1920×1080 physical screen. + private static readonly REF_PHYSICAL_WIDTH = 1920; + private static readonly REF_PHYSICAL_HEIGHT = 1080; + private static readonly BASE_WIDTH = 1504; + private static readonly BASE_HEIGHT = 972; + + private calculateWindowSize(display: Electron.Display): { width: number; height: number } { + const { workArea, scaleFactor } = display; + + // Use physical pixel dimensions so the window occupies the same screen + // fraction on every monitor regardless of its DPI scale setting. + const physW = workArea.width * scaleFactor; + const physH = workArea.height * scaleFactor; + + const ratio = Math.min( + physW / MainWindowController.REF_PHYSICAL_WIDTH, + physH / MainWindowController.REF_PHYSICAL_HEIGHT, + ); + + // Scale base physical size, then convert back to logical pixels for this display. + let width = Math.round((MainWindowController.BASE_WIDTH * ratio) / scaleFactor); + let height = Math.round((MainWindowController.BASE_HEIGHT * ratio) / scaleFactor); + + // Never overflow the work area (4% breathing room). + width = Math.min(width, Math.floor(workArea.width * 0.96)); + height = Math.min(height, Math.floor(workArea.height * 0.96)); + + return { width: Math.max(width, 800), height: Math.max(height, 600) }; + } + + private moveWindowToDisplay(display: Electron.Display): void { + if (!this.browserWindow || this.browserWindow.isDestroyed()) return; + + // Maximized windows ignore setBounds — unmaximize first. + if (this.browserWindow.isMaximized()) { + this.browserWindow.unmaximize(); + } + + const { width, height } = this.calculateWindowSize(display); + const { x, y, width: wa, height: wah } = display.workArea; + const cx = Math.round(x + (wa - width) / 2); + const cy = Math.round(y + (wah - height) / 2); + + // Step 1: move a single point onto the target display so Windows switches + // the window's per-monitor DPI context to the target monitor. + this.browserWindow.setPosition(Math.round(x + wa / 2), Math.round(y + wah / 2)); + + // Step 2: now that the DPI context matches the target display, apply the + // correct size and final centered position. + this.browserWindow.setBounds({ x: cx, y: cy, width, height }); + } /** * @@ -73,6 +167,43 @@ export class MainWindowController { // this.lolListener.onGameExit(info.classId); } + private onGameScreenDetected(gameDisplay: Electron.Display): void { + if (!this.browserWindow || this.browserWindow.isDestroyed()) return; + + const allDisplays = screen.getAllDisplays(); + if (allDisplays.length <= 1) return; + + // Find which display the app window currently sits on + const { x, y, width, height } = this.browserWindow.getBounds(); + const appDisplay = screen.getDisplayNearestPoint({ + x: Math.round(x + width / 2), + y: Math.round(y + height / 2), + }); + + // Already on a different screen — nothing to do + if (appDisplay.id !== gameDisplay.id) return; + + // Pick the first available display that isn't the game's + const alternative = allDisplays.find((d) => d.id !== gameDisplay.id); + if (!alternative) return; + + this.printLogMessage( + `[display] Game launched on display ${gameDisplay.id} — moving app to display ${alternative.id}`, + ); + + // Move without touching selectedDisplayId so the user's saved preference + // is preserved and restored when the game exits. + this.moveWindowToDisplay(alternative); + } + + private onGameExit(): void { + if (!this.browserWindow || this.browserWindow.isDestroyed()) return; + // Restore to user's preferred display (or primary if none saved) + const target = this.resolveTargetDisplay(); + this.moveWindowToDisplay(target); + this.printLogMessage(`[display] Game exited — restored app to display ${target.id}`); + } + private onRecorderStatusChanged(status: RecordingStatus) { this.browserWindow?.webContents?.send('recording-status-changed', status); } @@ -108,15 +239,16 @@ export class MainWindowController { * */ public createAndShow(showDevTools: boolean) { - const desiredWidth = 1504, - desiredHeight = 972, - primaryDisplay = screen.getPrimaryDisplay(), - screenWidth = primaryDisplay.workArea.width, - shouldFullscreen = screenWidth < desiredWidth; + this.loadDisplaySettings(); + const target = this.resolveTargetDisplay(); + const { width: desiredWidth, height: desiredHeight } = this.calculateWindowSize(target); + const shouldFullscreen = target.workArea.width < desiredWidth; this.browserWindow = new BrowserWindow({ width: desiredWidth, height: desiredHeight, + x: Math.round(target.workArea.x + (target.workArea.width - desiredWidth) / 2), + y: Math.round(target.workArea.y + (target.workArea.height - desiredHeight) / 2), show: true, frame: false, fullscreenable: false, @@ -265,6 +397,35 @@ export class MainWindowController { } }); //---------------------------------------------------------------------------- + + //---------------------------------------------------------------------------- + ipcMain.handle('get-displays', () => { + const primaryDisplay = screen.getPrimaryDisplay(); + const displays = screen.getAllDisplays().map((display, index) => ({ + id: display.id, + label: display.label || `Display ${index + 1}`, + width: display.bounds.width, + height: display.bounds.height, + scaleFactor: display.scaleFactor, + isPrimary: display.id === primaryDisplay.id, + x: display.bounds.x, + y: display.bounds.y, + displayFrequency: display.displayFrequency, + })); + return { displays, selectedDisplayId: this.selectedDisplayId }; + }); + //---------------------------------------------------------------------------- + + //---------------------------------------------------------------------------- + ipcMain.handle('set-window-display', (_, displayId: number) => { + const display = screen.getAllDisplays().find((d) => d.id === displayId); + if (!display) return false; + this.selectedDisplayId = displayId; + this.saveDisplaySettings(); + this.moveWindowToDisplay(display); + return true; + }); + //---------------------------------------------------------------------------- } private registerListeners() { @@ -274,6 +435,8 @@ export class MainWindowController { this.overlayController.on('log', this.printLogMessage.bind(this)); this.overlayController.on('game-exit', this.gepOnExit.bind(this)); + this.overlayController.on('game-exit', this.onGameExit.bind(this)); + this.overlayController.on('game-screen-detected', this.onGameScreenDetected.bind(this)); this.overlayController.on('show-hide-desktop-window', () => { this.handleShowHideDesktopWindow(); }); diff --git a/src/browser/controllers/overlay/overlay.controller.ts b/src/browser/controllers/overlay/overlay.controller.ts index df41086..074c102 100644 --- a/src/browser/controllers/overlay/overlay.controller.ts +++ b/src/browser/controllers/overlay/overlay.controller.ts @@ -20,6 +20,8 @@ export class OverlayController extends PackageControllerBase { private _ingameWindowsController: IngameWindowsController; private _exclusiveModeWindowController: ExclusiveModeWindowController; private _initializedControllers: boolean = false; + // Tracks whether we've already emitted the screen for the current game session + private _gameScreenDetected: boolean = false; /** * Constructor for the OverlayController. * @param overlayService - The service responsible for managing overlay windows. @@ -109,6 +111,13 @@ export class OverlayController extends PackageControllerBase { this._overlayApi.on('game-injected', async (gameInfo) => { this.log('Game Injected', gameInfo); + // Emit the game's display as soon as we have it after injection + const activeInfo = this._overlayApi.getActiveGameInfo(); + if (!this._gameScreenDetected && activeInfo?.gameWindowInfo?.screen) { + this._gameScreenDetected = true; + this.emit('game-screen-detected', activeInfo.gameWindowInfo.screen); + } + // Create an in-game window as an example try { //don't create in-game window for 109021 (LoL Launcher) @@ -127,6 +136,13 @@ export class OverlayController extends PackageControllerBase { this._overlayApi.on('game-window-changed', (window, game, reason) => { this.log('Game Window Changed', reason, window, game); + // Fallback: emit game screen from the first window-changed event if + // getActiveGameInfo() didn't have screen info yet at injection time. + if (!this._gameScreenDetected && window.screen) { + this._gameScreenDetected = true; + this.emit('game-screen-detected', window.screen); + } + // Update exclusive mode window size if needed this._exclusiveModeWindowController.onGameWindowChanged(); }); @@ -153,6 +169,7 @@ export class OverlayController extends PackageControllerBase { this._overlayApi.on('game-exit', (info) => { this.log('Game Exit', info); + this._gameScreenDetected = false; this.emit('game-exit', info); // Close all in-game windows diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 02ce6f3..269ddc1 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -47,6 +47,12 @@ contextBridge.exposeInMainWorld('app', { checkForUpdates: () => { return ipcRenderer.invoke('check-for-updates'); }, + getDisplays: () => { + return ipcRenderer.invoke('get-displays'); + }, + setWindowDisplay: (displayId: number) => { + return ipcRenderer.invoke('set-window-display', displayId); + }, }); contextBridge.exposeInMainWorld('privacyApi', { diff --git a/src/renderer/app/components/app-settings/app-settings.tsx b/src/renderer/app/components/app-settings/app-settings.tsx index cc16838..754c9c7 100644 --- a/src/renderer/app/components/app-settings/app-settings.tsx +++ b/src/renderer/app/components/app-settings/app-settings.tsx @@ -6,6 +6,7 @@ import SectionHeader from '../layout/section-header'; import HotKeysSettings from '../hotkeys/hotkeys-settings'; import CheckForUpdates from '../check-for-updates/CheckForUpdates'; import AppContext from '../../context/app-context'; +import DisplaySettings from './display-settings'; const AppSettings: React.FC = () => { const { availablePackages } = useContext(AppContext); @@ -38,6 +39,12 @@ const AppSettings: React.FC = () => { content: , isDisabled: !availablePackages.isPackageAvailable('overlay'), }, + { + type: 'expanded-type', + title: 'Display Settings', + description: 'Detect connected displays, resolutions, and scaling', + content: , + }, { type: 'simple-type', title: 'Check for updates', diff --git a/src/renderer/app/components/app-settings/display-settings.tsx b/src/renderer/app/components/app-settings/display-settings.tsx new file mode 100644 index 0000000..f0f3740 --- /dev/null +++ b/src/renderer/app/components/app-settings/display-settings.tsx @@ -0,0 +1,85 @@ +import React, { FC, useEffect, useState } from 'react'; + +interface DisplayInfo { + id: number; + label: string; + width: number; + height: number; + scaleFactor: number; + isPrimary: boolean; + x: number; + y: number; + displayFrequency: number; +} + +const DisplaySettings: FC = () => { + const [displays, setDisplays] = useState([]); + const [selectedId, setSelectedId] = useState(null); + + useEffect(() => { + window.app.getDisplays().then( + ({ displays: all, selectedDisplayId }: { displays: DisplayInfo[]; selectedDisplayId: number | null }) => { + setDisplays(all); + const initialId = selectedDisplayId ?? all.find((d) => d.isPrimary)?.id ?? null; + setSelectedId(initialId); + }, + ); + }, []); + + const handleChange = (e: React.ChangeEvent) => { + const id = Number(e.target.value); + setSelectedId(id); + window.app.setWindowDisplay(id); + }; + + const selected = displays.find((d) => d.id === selectedId); + + const displayLabel = (d: DisplayInfo, index: number) => { + const name = d.label || (d.isPrimary ? 'Primary Display' : `Display ${index + 1}`); + return `${name} — ${d.width}×${d.height} @ ${Math.round(d.scaleFactor * 100)}%`; + }; + + return ( +
+
+ + +
+ + {selected && ( +
+
Resolution
+
{selected.width} × {selected.height} px
+ +
Scale
+
{Math.round(selected.scaleFactor * 100)}%
+ + {selected.displayFrequency > 0 && ( + <> +
Refresh rate
+
{selected.displayFrequency} Hz
+ + )} + +
Position
+
{selected.x}, {selected.y}
+ +
Primary
+
{selected.isPrimary ? 'Yes' : 'No'}
+
+ )} +
+ ); +}; + +export default DisplaySettings; diff --git a/src/renderer/app/styles/settings-page.ts b/src/renderer/app/styles/settings-page.ts index 4f69086..41b4765 100644 --- a/src/renderer/app/styles/settings-page.ts +++ b/src/renderer/app/styles/settings-page.ts @@ -98,10 +98,36 @@ const settingsPageStyles = ` } } - .overlay-settings, .hotkeys-settings { + .overlay-settings, .hotkeys-settings, .display-settings { padding: var(--space-600); } + .display-settings { + select { + min-width: 320px; + } + + .display-info-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 24px; + margin: 16px 0 0; + padding: 16px; + background-color: var(--color-surface-primary); + border-radius: 4px; + + dt { + color: var(--color-text-tertiary); + font-size: var(--font-size-350); + } + + dd { + font-size: var(--font-size-350); + margin: 0; + } + } + } + .settings-title { font-size: var(--font-size-400); line-height: var(--font-line-height-600);