From c8b93180d221c411d1246a00b3d0290465bb7008 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:16:09 +0200 Subject: [PATCH] Make AeroSpace workspace moves async --- scripts/test-aerospace-workspace.mjs | 102 +++++++++++++++++++ src/main/aerospace-workspace.ts | 146 +++++++++++++++++++++++++++ src/main/main.ts | 47 ++------- 3 files changed, 254 insertions(+), 41 deletions(-) create mode 100644 scripts/test-aerospace-workspace.mjs create mode 100644 src/main/aerospace-workspace.ts diff --git a/scripts/test-aerospace-workspace.mjs b/scripts/test-aerospace-workspace.mjs new file mode 100644 index 00000000..39f8598c --- /dev/null +++ b/scripts/test-aerospace-workspace.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const { createAerospaceWorkspaceMover } = await importTs(path.join(root, 'src/main/aerospace-workspace.ts')); + +test('AeroSpace workspace mover', async (t) => { + await t.test('coalesces concurrent move requests into one follow-up run', async () => { + const calls = []; + const mover = createAerospaceWorkspaceMover({ + platform: 'darwin', + runCommand: async (args) => { + calls.push(args); + await delay(10); + if (args[0] === 'list-workspaces') return 'work\n'; + if (args[0] === 'list-windows') return '42 other\n'; + return ''; + }, + }); + + mover.requestMove(); + mover.requestMove(); + mover.requestMove(); + + await mover.whenIdle(); + + assert.deepEqual( + calls.map((args) => args[0]), + [ + 'list-workspaces', + 'list-windows', + 'move-node-to-workspace', + 'list-workspaces', + 'list-windows', + 'move-node-to-workspace', + ], + ); + assert.deepEqual(mover.getState(), { available: true, inFlight: false, queued: false }); + }); + + await t.test('marks missing aerospace binary unavailable and skips later requests', async () => { + let calls = 0; + const mover = createAerospaceWorkspaceMover({ + platform: 'darwin', + runCommand: async () => { + calls += 1; + const error = new Error('spawn aerospace ENOENT'); + error.code = 'ENOENT'; + throw error; + }, + }); + + mover.requestMove(); + await mover.whenIdle(); + mover.requestMove(); + await delay(0); + + assert.equal(calls, 1); + assert.deepEqual(mover.getState(), { available: false, inFlight: false, queued: false }); + }); + + await t.test('does not block the event loop while slow commands are in flight', async () => { + const slowCommandDelayMs = 80; + const calls = []; + const mover = createAerospaceWorkspaceMover({ + platform: 'darwin', + runCommand: async (args) => { + calls.push(args); + await delay(slowCommandDelayMs); + if (args[0] === 'list-workspaces') return 'work\n'; + if (args[0] === 'list-windows') return '42 other\n'; + return ''; + }, + }); + + const startedAt = performance.now(); + let zeroDelayTimerFiredAfterMs = Number.POSITIVE_INFINITY; + const timerPromise = new Promise((resolve) => { + setTimeout(() => { + zeroDelayTimerFiredAfterMs = performance.now() - startedAt; + resolve(); + }, 0); + }); + + mover.requestMove(); + await timerPromise; + await mover.whenIdle(); + + assert.ok(calls.length >= 1, 'first command should start'); + assert.ok( + zeroDelayTimerFiredAfterMs < slowCommandDelayMs, + `zero-delay timer fired after ${zeroDelayTimerFiredAfterMs}ms`, + ); + }); +}); diff --git a/src/main/aerospace-workspace.ts b/src/main/aerospace-workspace.ts new file mode 100644 index 00000000..5aaf1a17 --- /dev/null +++ b/src/main/aerospace-workspace.ts @@ -0,0 +1,146 @@ +import { execFile } from 'child_process'; + +const DEFAULT_AEROSPACE_TIMEOUT_MS = 500; +const DEFAULT_SUPERCMD_BUNDLE_ID = 'com.supercmd.app'; + +export type AerospaceCommandRunner = (args: string[]) => Promise; + +export type AerospaceWorkspaceMoverOptions = { + platform?: NodeJS.Platform; + bundleId?: string; + timeoutMs?: number; + shouldRun?: () => boolean; + runCommand?: AerospaceCommandRunner; +}; + +export type AerospaceWorkspaceMoverState = { + available: boolean | null; + inFlight: boolean; + queued: boolean; +}; + +export type AerospaceWorkspaceMover = { + requestMove: () => void; + whenIdle: () => Promise; + getState: () => AerospaceWorkspaceMoverState; +}; + +function isAerospaceUnavailableError(error: unknown): boolean { + return (error as { code?: unknown } | null)?.code === 'ENOENT'; +} + +export function execAerospaceCommand(args: string[], timeoutMs = DEFAULT_AEROSPACE_TIMEOUT_MS): Promise { + return new Promise((resolve, reject) => { + execFile( + 'aerospace', + args, + { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + timeout: timeoutMs, + }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(String(stdout || '')); + }, + ); + }); +} + +export function createAerospaceWorkspaceMover(options: AerospaceWorkspaceMoverOptions = {}): AerospaceWorkspaceMover { + const platform = options.platform ?? process.platform; + const bundleId = options.bundleId ?? DEFAULT_SUPERCMD_BUNDLE_ID; + const timeoutMs = options.timeoutMs ?? DEFAULT_AEROSPACE_TIMEOUT_MS; + const runCommand = options.runCommand ?? ((args) => execAerospaceCommand(args, timeoutMs)); + + let available: boolean | null = null; + let inFlight = false; + let queued = false; + let inFlightPromise: Promise | null = null; + + function canAttemptMove(): boolean { + if (available === false || platform !== 'darwin') return false; + try { + return options.shouldRun ? options.shouldRun() : true; + } catch { + return false; + } + } + + async function reconcileOnce(): Promise { + if (!canAttemptMove()) return; + + try { + const focusedWs = String(await runCommand(['list-workspaces', '--focused'])).trim(); + if (!focusedWs) return; + available = true; + + const windowsRaw = String( + await runCommand([ + 'list-windows', + '--all', + '--app-bundle-id', + bundleId, + '--format', + '%{window-id} %{workspace}', + ]), + ).trim(); + if (!windowsRaw) return; + + for (const line of windowsRaw.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length < 2) continue; + const [windowId, currentWs] = parts; + if (currentWs === focusedWs) continue; + await runCommand(['move-node-to-workspace', focusedWs, '--window-id', windowId]); + } + } catch (error) { + // ENOENT means the binary is unavailable; skip future attempts. + if (isAerospaceUnavailableError(error)) { + available = false; + } + // Other failures are transient (server not running, timeout, bad output). + } + } + + async function runQueuedReconciliations(): Promise { + try { + do { + queued = false; + await reconcileOnce(); + } while (queued && canAttemptMove()); + } finally { + inFlight = false; + inFlightPromise = null; + } + } + + function requestMove(): void { + if (!canAttemptMove()) return; + if (inFlight) { + queued = true; + return; + } + + inFlight = true; + inFlightPromise = runQueuedReconciliations(); + void inFlightPromise.catch(() => {}); + } + + function whenIdle(): Promise { + return inFlightPromise ?? Promise.resolve(); + } + + function getState(): AerospaceWorkspaceMoverState { + return { available, inFlight, queued }; + } + + return { + requestMove, + whenIdle, + getState, + }; +} diff --git a/src/main/main.ts b/src/main/main.ts index cf510437..e5a8248d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -18,6 +18,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; import { fork, execFileSync, type ChildProcess } from 'child_process'; +import { createAerospaceWorkspaceMover } from './aerospace-workspace'; import { getNativeBinaryPath, resolvePackagedUnpackedPath } from './native-binary'; import { getAvailableCommands, executeCommand, invalidateCache, initCommandsCache, getInflightDiscovery, refreshCommandsNow } from './commands'; import { @@ -3197,48 +3198,12 @@ function setLauncherOverlayTopmost(enabled: boolean): void { * This is fire-and-forget — failures are silently ignored so we never * block or delay the launcher for users who don't use AeroSpace. */ -let aerospaceAvailable: boolean | null = null; // null = not yet checked +const aerospaceWorkspaceMover = createAerospaceWorkspaceMover({ + shouldRun: () => !!mainWindow && !mainWindow.isDestroyed(), +}); + function moveWindowToCurrentAerospaceWorkspace(): void { - if (aerospaceAvailable === false || process.platform !== 'darwin' || !mainWindow || mainWindow.isDestroyed()) return; - try { - const { execFileSync } = require('child_process'); - // Quick check: is AeroSpace running? list-workspaces --focused - // exits 0 only when the server is up. - const focusedWs = String( - execFileSync('aerospace', ['list-workspaces', '--focused'], { - timeout: 500, - stdio: ['ignore', 'pipe', 'ignore'], - }) || '' - ).trim(); - if (!focusedWs) return; - aerospaceAvailable = true; - - // Find our window(s) by bundle-id - const windowsRaw = String( - execFileSync('aerospace', ['list-windows', '--all', '--app-bundle-id', 'com.supercmd.app', '--format', '%{window-id} %{workspace}'], { - timeout: 500, - stdio: ['ignore', 'pipe', 'ignore'], - }) || '' - ).trim(); - if (!windowsRaw) return; - - for (const line of windowsRaw.split('\n')) { - const parts = line.trim().split(/\s+/); - if (parts.length < 2) continue; - const [windowId, currentWs] = parts; - if (currentWs === focusedWs) continue; // already on the right workspace - execFileSync('aerospace', ['move-node-to-workspace', focusedWs, '--window-id', windowId], { - timeout: 500, - stdio: 'ignore', - }); - } - } catch (err: any) { - // ENOENT = `aerospace` binary not found — will never appear, so skip future calls. - if (err?.code === 'ENOENT') { - aerospaceAvailable = false; - } - // Other errors (server not running, command failed) are transient — retry next time. - } + aerospaceWorkspaceMover.requestMove(); } function clearOAuthBlurHideSuppression(): void {