Last active
November 29, 2025 00:43
-
-
Save virtuallyunknown/1262c01cb5298ecc10eb163a57badc37 to your computer and use it in GitHub Desktop.
Fix for shrinking game windows in Niri.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /* | |
| This script addresses a specific issue with Battle.net games where alt-tabbing or | |
| switching away from the game window causes it to shrink from fullscreen. | |
| This is recoverable if you use mod+alt+f (fullscreen-window) niri shortcut, but this | |
| script automates the process. Also, if you are using gamescope or other similar | |
| compositor to run your games, then it's likely that you're not encountering this issue | |
| to begin with, but for me those cause performance issues or input lag. | |
| The script works by calling niri's event-stream to monitor for window events. If the focused | |
| window matches the specified title and app-id, it will attempt to resize it to the specified | |
| width and height (fullscreen). | |
| You can get the app-id of a window by running `niri msg windows`. | |
| Usage: | |
| ./niri-window-fullscreen.ts --width <number> --height <number> --title <string> --app-id <string> [--silent] | |
| Example: | |
| ./niri-window-fullscreen.ts --width 1920 --height 1080 --title "My App" --app-id "com.example.myapp" | |
| Arguments: | |
| --width <number> (Required) Desired width for fullscreening the window. | |
| --height <number> (Required) Desired height for fullscreening the window. | |
| --title <string> (Required) Title of the window to watch for. | |
| --app-id <string> (Required) Application ID of the window to watch for. | |
| --silent (Optional) Disable logging. | |
| Requires node v24 or higher to run. | |
| */ | |
| import { exec, spawn } from 'node:child_process'; | |
| import { promisify } from 'node:util'; | |
| /** A new toplevel window was opened, or an existing toplevel window changed. */ | |
| type WindowOpenedOrChangedEvent = { | |
| WindowOpenedOrChanged: { | |
| /** | |
| * The new or updated window. | |
| * | |
| * If the window is focused, all other windows are no longer focused. | |
| */ | |
| window: NiriWindow; | |
| }; | |
| } | |
| /** The window configuration has changed. */ | |
| type WindowsChangedEvent = { | |
| WindowsChanged: { | |
| /** The new window configuration. | |
| * | |
| * This configuration completely replaces the previous configuration. I.e. if any windows are missing from here, then they were closed. | |
| */ | |
| windows: NiriWindow[]; | |
| }; | |
| } | |
| /** | |
| * Window focus changed. | |
| * | |
| * All other windows are no longer focused. | |
| */ | |
| type WindowFocusChangedEvent = { | |
| WindowFocusChanged: { | |
| /** Id of the newly focused window, or `null` if no window is now focused. */ | |
| id: number | null; | |
| }; | |
| } | |
| /** A toplevel window was closed. */ | |
| type WindowClosedEvent = { | |
| WindowClosed: { | |
| /** Id of the removed window. */ | |
| id: number; | |
| }; | |
| } | |
| /** A moment in time. */ | |
| type Timestamp = { | |
| /** Number of whole seconds. */ | |
| secs: number; | |
| /** Fractional part of the timestamp in nanoseconds (10^-9 seconds). */ | |
| nanos: number; | |
| }; | |
| /** | |
| * Position- and size-related properties of a `Window`. | |
| * | |
| * Optional properties will be unset for some windows, do not rely on them being present. Whether | |
| * some optional properties are present or absent for certain window types may change across niri | |
| * releases. | |
| * | |
| * All sizes and positions are in *logical pixels* unless stated otherwise. Logical sizes may be | |
| * fractional. For example, at 1.25 monitor scale, a 2-physical-pixel-wide window border is 1.6 | |
| * logical pixels wide. | |
| * | |
| * This struct contains positions and sizes both for full tiles (`tile_size`, | |
| * `tile_pos_in_workspace_view`) and the window geometry (`window_size`, | |
| * `window_offset_in_tile`). For visual displays, use the tile properties, as they | |
| * correspond to what the user visually considers "window". The window properties on the other | |
| * hand are mainly useful when you need to know the underlying Wayland window sizes, e.g. for | |
| * application debugging. | |
| */ | |
| type WindowLayout = { | |
| /** | |
| * Location of a tiled window within a workspace: (column index, tile index in column). | |
| * | |
| * The indices are 1-based, i.e. the leftmost column is at index 1 and the topmost tile in a | |
| * column is at index 1. This is consistent with `Action::FocusColumn` and | |
| * `Action::FocusWindowInColumn`. | |
| */ | |
| pos_in_scrolling_layout: [number, number] | null; | |
| /** Size of the tile this window is in, including decorations like borders. */ | |
| tile_size: [number, number]; | |
| /** | |
| * Size of the window's visual geometry itself. | |
| * | |
| * Does not include niri decorations like borders. | |
| * | |
| * Currently, Wayland toplevel windows can only be integer-sized in logical pixels, even | |
| * though it doesn't necessarily align to physical pixels. | |
| */ | |
| window_size: [number, number]; | |
| /** | |
| * Tile position within the current view of the workspace. | |
| * | |
| * This is the same "workspace view" as in gradients' `relative-to` in the niri config. | |
| */ | |
| tile_pos_in_workspace_view: [number, number] | null; | |
| /** | |
| * Location of the window's visual geometry within its tile. | |
| * | |
| * This includes things like border sizes. For fullscreened fixed-size windows this includes | |
| * the distance from the corner of the black backdrop to the corner of the (centered) window | |
| * contents. | |
| */ | |
| window_offset_in_tile: [number, number]; | |
| }; | |
| /** Toplevel window. */ | |
| type NiriWindow = { | |
| /** | |
| * Unique id of this window. | |
| * | |
| * This id remains constant while this window is open. | |
| * | |
| * Do not assume that window ids will always increase without wrapping, or start at 1. That is | |
| * an implementation detail subject to change. For example, ids may change to be randomly | |
| * generated for each new window. | |
| */ | |
| id: number; | |
| /** Title, if set. */ | |
| title: string | null; | |
| /** Application ID, if set. */ | |
| app_id: string | null; | |
| /** | |
| * Process ID that created the Wayland connection for this window, if known. | |
| * | |
| * Currently, windows created by xdg-desktop-portal-gnome will have a `null` PID, but this may | |
| * change in the future. | |
| */ | |
| pid: number | null; | |
| /** Id of the workspace this window is on, if any. */ | |
| workspace_id: number | null; | |
| /** | |
| * Whether this window is currently focused. | |
| * | |
| * There can be either one focused window or zero (e.g. when a layer-shell surface has focus). | |
| */ | |
| is_focused: boolean; | |
| /** | |
| * Whether this window is currently floating. | |
| * | |
| * If the window isn't floating then it is in the tiling layout. | |
| */ | |
| is_floating: boolean; | |
| /** Whether this window requests your attention. */ | |
| is_urgent: boolean; | |
| /** Position- and size-related properties of the window. */ | |
| layout: WindowLayout; | |
| /** | |
| * Timestamp when the window was most recently focused. | |
| * | |
| * This timestamp is intended for most-recently-used window switchers, i.e. Alt-Tab. It only | |
| * updates after some debounce time so that quick window switching doesn't mark intermediate | |
| * windows as recently focused. | |
| * | |
| * The timestamp comes from the monotonic clock. | |
| */ | |
| focus_timestamp: Timestamp | null; | |
| }; | |
| type NiriWindowEvent = | |
| | WindowsChangedEvent | |
| | WindowOpenedOrChangedEvent | |
| | WindowFocusChangedEvent | |
| | WindowClosedEvent | |
| type NiriBatchedEvent = NiriWindowEvent | Record<string, unknown>; | |
| type Config = { | |
| width: number; | |
| height: number; | |
| title: string; | |
| appId: string; | |
| silent: boolean; | |
| } | |
| function parseArgs() { | |
| const args = process.argv.slice(2); | |
| const config: Config = { | |
| width: 0, | |
| height: 0, | |
| title: '', | |
| appId: '', | |
| silent: false, | |
| }; | |
| for (const [index, value] of args.entries()) { | |
| switch (value) { | |
| case '--width': { | |
| config.width = Number(args[index + 1]); | |
| break; | |
| } | |
| case '--height': { | |
| config.height = Number(args[index + 1]); | |
| break; | |
| } | |
| case '--title': { | |
| config.title = args[index + 1]; | |
| break; | |
| } | |
| case '--app-id': { | |
| config.appId = args[index + 1]; | |
| break; | |
| } | |
| case '--silent': { | |
| config.silent = true; | |
| break; | |
| } | |
| default: break; | |
| } | |
| } | |
| if ( | |
| isNaN(config.width) || | |
| isNaN(config.height) || | |
| config.title === '' || | |
| config.appId === '' | |
| ) { | |
| logger('Invalid or missing arguments. Usage: niri-watch --width <number> --height <number> --title <string> --app-id <string> --verbose'); | |
| process.exit(1); | |
| } | |
| return config; | |
| } | |
| let appWindowId: number | null = null; | |
| const execAsync = promisify(exec); | |
| const config = parseArgs(); | |
| const stream = spawn('niri', ['msg', '--json', 'event-stream']); | |
| async function getWindowData(id: number) { | |
| const { stdout, stderr } = await execAsync('niri msg --json windows'); | |
| if (stderr) { | |
| logger(`Error getting window data, exiting.\n\n${stderr}`); | |
| process.exit(1); | |
| } | |
| const data = JSON.parse(stdout); | |
| if (!isWindowResponse(data)) { | |
| logger(`Invalid focused window data, exiting.\n\n${data}`); | |
| process.exit(1); | |
| } | |
| const windowData = data.find(window => window.id === id); | |
| if (!windowData) { | |
| logger(`Window with id ${id} not found, exiting.\n\n${data}`); | |
| process.exit(1); | |
| } | |
| return windowData; | |
| } | |
| async function fullscreenFocusedWindow() { | |
| const { stderr } = await execAsync('niri msg action fullscreen-window'); | |
| if (stderr) { | |
| logger(`error fullscreening focused window, exiting.\n\n${stderr}`); | |
| process.exit(1); | |
| } | |
| } | |
| function parseRows(data: unknown) { | |
| return String(data).split('\n').filter(Boolean).map(row => JSON.parse(row) as Record<string, unknown>); | |
| } | |
| function parseEvent(data: Record<string, unknown>[]): NiriBatchedEvent { | |
| return Object.assign({}, ...data); | |
| } | |
| function isWindowResponse(data: unknown): data is NiriWindow[] { | |
| return ( | |
| Array.isArray(data) && | |
| data.every(item => typeof item === 'object' && item !== null && 'id' in item && 'title' in item)) | |
| ? true | |
| : false; | |
| } | |
| function isWindowsChangedEvent(event: NiriBatchedEvent): event is WindowsChangedEvent { | |
| return ('WindowsChanged' in event) ? true : false; | |
| } | |
| function isWindowOpenedOrChangedEvent(event: NiriBatchedEvent): event is WindowOpenedOrChangedEvent { | |
| return ('WindowOpenedOrChanged' in event) ? true : false; | |
| } | |
| function isWindowClosedEvent(event: NiriBatchedEvent): event is WindowClosedEvent { | |
| return ('WindowClosed' in event) ? true : false; | |
| } | |
| function isWindowFocusChangedEvent(event: NiriBatchedEvent): event is WindowFocusChangedEvent { | |
| return ('WindowFocusChanged' in event) ? true : false; | |
| } | |
| function logger(value: string) { | |
| if (!config.silent) { | |
| console.log(`[${new Date().toISOString()}] ${value}`); | |
| } | |
| } | |
| stream.stdout.on('data', async (data) => { | |
| const rows = parseRows(data); | |
| const batchedEvent = parseEvent(rows); | |
| /** | |
| * Niri event-stream calls the WindowsChanged event once when initialized. | |
| * We attempt to use this event to acquire the window ID of the target window if it already exists. | |
| */ | |
| if (!appWindowId && isWindowsChangedEvent(batchedEvent)) { | |
| for (const window of batchedEvent.WindowsChanged.windows) { | |
| if (window.title === config.title && window.app_id === config.appId) { | |
| appWindowId = window.id; | |
| logger(`Acquired window id of ${appWindowId} for window with title "${config.title}" and app-id "${config.appId}".`); | |
| break; | |
| } | |
| } | |
| } | |
| if (!appWindowId && | |
| isWindowOpenedOrChangedEvent(batchedEvent) && | |
| batchedEvent.WindowOpenedOrChanged.window.title === config.title && | |
| batchedEvent.WindowOpenedOrChanged.window.app_id === config.appId | |
| ) { | |
| appWindowId = batchedEvent.WindowOpenedOrChanged.window.id; | |
| logger(`Acquired window id of ${appWindowId} for window with title "${config.title}" and app-id "${config.appId}".`); | |
| } | |
| if (appWindowId && | |
| isWindowClosedEvent(batchedEvent) && | |
| batchedEvent.WindowClosed.id === appWindowId | |
| ) { | |
| logger(`Window id ${appWindowId} with title "${config.title}" and app-id "${config.appId}" is closed.`); | |
| appWindowId = null; | |
| } | |
| if (appWindowId && | |
| isWindowFocusChangedEvent(batchedEvent) && | |
| batchedEvent.WindowFocusChanged.id === appWindowId | |
| ) { | |
| const MAX_ATTEMPTS = 10; | |
| let attempts = 0; | |
| while (attempts < MAX_ATTEMPTS) { | |
| const data = await getWindowData(appWindowId); | |
| if (!data.is_focused) { | |
| logger(`Attempted setting window with id "${appWindowId}" to fullscreen but it lost focus.`); | |
| break; | |
| } | |
| if (data.layout.window_size.at(0) === config.width || data.layout.window_size.at(1) === config.height) { | |
| break; | |
| } | |
| logger(`Attempt ${attempts + 1}: Setting window with id "${appWindowId}", title "${config.title}", app-id "${config.appId}" to fullscreen (${config.width}x${config.height}).`); | |
| await fullscreenFocusedWindow(); | |
| await new Promise(resolve => setTimeout(resolve, 50)); | |
| attempts++; | |
| } | |
| if (attempts >= MAX_ATTEMPTS) { | |
| logger(`Warning: Failed to resize window to ${config.width}x${config.height} after ${MAX_ATTEMPTS} attempts.`); | |
| } | |
| } | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment