Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ jobs:
shell: bash
run: |
otool -l aw_watcher_window/aw-watcher-window-macos | grep -A4 'LC_BUILD_VERSION' | grep 'minos 12.0'
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Run tests
shell: bash
run: |
Expand Down
85 changes: 66 additions & 19 deletions aw_watcher_window/printAppStatus.jxa
Original file line number Diff line number Diff line change
Expand Up @@ -17,43 +17,90 @@ var appName = oProcess.displayedName();
// and the previously set values will be cached otherwise
var url = undefined, incognito = undefined, title = undefined;

// Reported when the frontmost app has no window we can inspect. Some apps are
// not scriptable via JXA at all, and some scriptable apps are open with no
// window (or only a devtools window). An uncaught throw here aborts the whole
// poll, so every window lookup below is guarded and falls back to this value.
var NO_WINDOW_TITLE = "No window";

// Best-effort title of the frontmost app's main window, read through the
// Accessibility API (which also works for apps that don't support JXA
// scripting). Returns undefined when no window title can be read.
function axWindowTitle(proc) {
Comment thread
TimeToBuildBob marked this conversation as resolved.
try {
var mainWindow = proc.windows().find(function(w) {
try {
return w.attributes.byName("AXMain").value() === true;
} catch (e) {
return false;
}
});
if (mainWindow) {
return mainWindow.attributes.byName("AXTitle").value();
}
} catch (e) {
// The app exposes no scriptable windows.
}
try {
// No window is flagged as AXMain (e.g. a dialog); take the first one.
return proc.windows()[0].attributes.byName("AXTitle").value();
} catch (e) {
return undefined;
}
}

// it's not possible to get the URL from firefox
// https://stackoverflow.com/questions/17846948/does-firefox-offer-applescript-support-to-get-url-of-windows

switch(appName) {
case "Safari":
// incognito is not available via safari applescript
url = Application(appName).documents[0].url();
title = Application(appName).documents[0].name();
try {
url = Application(appName).documents[0].url();
title = Application(appName).documents[0].name();
} catch (e) {
// Discard partial browser reads before using an Accessibility title.
url = undefined;
incognito = undefined;
// Safari can be open with no document (no window, or devtools only).
title = axWindowTitle(oProcess);
}
break;
case "Google Chrome":
case "Google Chrome Canary":
case "Chromium":
case "Brave Browser":
const activeWindow = Application(appName).windows[0];
const activeTab = activeWindow.activeTab();
try {
var activeWindow = Application(appName).windows[0];
var activeTab = activeWindow.activeTab();

url = activeTab.url();
title = activeTab.name();
incognito = activeWindow.mode() === 'incognito';
url = activeTab.url();
title = activeTab.name();
incognito = activeWindow.mode() === 'incognito';
} catch (e) {
// Discard partial browser reads before using an Accessibility title.
url = undefined;
incognito = undefined;
// The browser can be open with no window.
title = axWindowTitle(oProcess);
Comment thread
TimeToBuildBob marked this conversation as resolved.
}
break;
case "Firefox":
case "Firefox Developer Edition":
title = Application(appName).windows[0].name();
try {
title = Application(appName).windows[0].name();
} catch (e) {
title = axWindowTitle(oProcess);
}
break;
default:
mainWindow = oProcess.
windows().
find(w => w.attributes.byName("AXMain").value() === true)
title = axWindowTitle(oProcess);
}

// in some cases, the primary window of an application may not be found
// this occurs rarely and seems to be triggered by switching to a different application
if(mainWindow) {
title = mainWindow.
attributes.
byName("AXTitle").
value()
}
// The Python side requires a title key (it indexes current_window["title"]),
// so never leave it undefined.
if (title === undefined) {
title = NO_WINDOW_TITLE;
}

// key names must match expected names in lib.py
Expand Down
94 changes: 94 additions & 0 deletions tests/jxa_harness.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Control-flow regression tests for the real JXA source, not a replacement
// implementation. These stubs do not validate macOS Accessibility/JXA APIs.
const assert = require("assert");
const fs = require("fs");
const vm = require("vm");
const source = fs.readFileSync(process.argv[2], "utf8").replace(/^#![^\n]*\n/, "");

function fail() {
throw new Error("window disappeared or attribute unavailable");
}

function windowWithTitle(title, isMain = false) {
return {
attributes: {
byName: (name) => ({
value: () => name === "AXMain" ? isMain : title,
}),
},
};
}

function capture(appName, app = {}, windows = () => []) {
const proc = { displayedName: () => appName, windows };
const result = vm.runInNewContext(source, {
Application: (name) => name === "System Events"
? { processes: { whose: () => [proc] } }
: app,
}, { timeout: 1000 });
return JSON.parse(result);
}

let cases = 0;
function check(name, actual, expected) {
assert.deepStrictEqual(actual, expected, name);
cases++;
}

check("Safari document", capture("Safari", {
documents: [{ url: () => "https://example.com", name: () => "Example" }],
}), { app: "Safari", title: "Example", url: "https://example.com" });

check("Safari no document", capture("Safari", { documents: [] },
() => [windowWithTitle("DevTools", true)]),
{ app: "Safari", title: "DevTools" });

check("Safari no windows", capture("Safari", { documents: [] }),
{ app: "Safari", title: "No window" });

check("Safari title read fails after URL", capture("Safari", {
documents: [{ url: () => "https://stale.example", name: fail }],
}, () => [windowWithTitle("Fallback", true)]),
{ app: "Safari", title: "Fallback" });

for (const appName of ["Google Chrome", "Google Chrome Canary", "Chromium", "Brave Browser"]) {
for (const mode of ["normal", "incognito"]) {
check(`${appName} ${mode}`, capture(appName, { windows: [{
activeTab: () => ({ url: () => "https://example.com", name: () => "Example" }),
mode: () => mode,
}] }), { app: appName, title: "Example", url: "https://example.com", incognito: mode === "incognito" });
}
check(`${appName} no windows`, capture(appName, { windows: [] }),
{ app: appName, title: "No window" });
check(`${appName} title read fails after URL`, capture(appName, { windows: [{
activeTab: () => ({ url: () => "https://stale.example", name: fail }),
mode: () => "incognito",
}] }, () => [windowWithTitle("Fallback", true)]),
{ app: appName, title: "Fallback" });
check(`${appName} mode read fails after URL and title`, capture(appName, { windows: [{
activeTab: () => ({ url: () => "https://stale.example", name: () => "Stale" }),
mode: fail,
}] }, () => [windowWithTitle("Fallback", true)]),
{ app: appName, title: "Fallback" });
}

for (const appName of ["Firefox", "Firefox Developer Edition"]) {
check(`${appName} normal`, capture(appName, { windows: [{ name: () => "Example" }] }),
{ app: appName, title: "Example" });
check(`${appName} no windows`, capture(appName, { windows: [] }),
{ app: appName, title: "No window" });
}

check("unscriptable app", capture("ElectronApp", {}, fail),
{ app: "ElectronApp", title: "No window" });
check("throwing attributes", capture("ElectronApp", {}, () => [{ attributes: { byName: fail } }]),
{ app: "ElectronApp", title: "No window" });
check("main window preferred", capture("Terminal", {}, () => [
windowWithTitle("zsh"), windowWithTitle("vim", true),
]), { app: "Terminal", title: "vim" });
check("first window fallback", capture("Finder", {}, () => [windowWithTitle("Desktop")]),
{ app: "Finder", title: "Desktop" });
check("empty title preserved", capture("Terminal", {}, () => [windowWithTitle("", true)]),
{ app: "Terminal", title: "" });

console.log(`${cases} JXA control-flow cases passed`);
25 changes: 25 additions & 0 deletions tests/test_jxa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Exercise JXA control flow with stubs; native macOS API checks remain manual."""

from pathlib import Path
import shutil
import subprocess

import pytest


def test_jxa_fallbacks():
node = shutil.which("node")
if node is None:
pytest.skip("Node.js is required for the JXA control-flow harness")
root = Path(__file__).resolve().parents[1]
result = subprocess.run(
[
node,
str(root / "tests" / "jxa_harness.js"),
str(root / "aw_watcher_window" / "printAppStatus.jxa"),
],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
Loading