Skip to content
Closed
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
102 changes: 102 additions & 0 deletions scripts/test-aerospace-workspace.mjs
Original file line number Diff line number Diff line change
@@ -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`,
);
});
});
146 changes: 146 additions & 0 deletions src/main/aerospace-workspace.ts
Original file line number Diff line number Diff line change
@@ -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<string>;

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<void>;
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<string> {
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<void> | 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<void> {
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<void> {
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<void> {
return inFlightPromise ?? Promise.resolve();
}

function getState(): AerospaceWorkspaceMoverState {
return { available, inFlight, queued };
}

return {
requestMove,
whenIdle,
getState,
};
}
47 changes: 6 additions & 41 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading