SPB Git

Repository Public Research preview

how-it-works.md 5.1 KB

# How it works, and why

Everything below was observed on Safari 27.0 (macOS 27.0) while building this skill's test suite.

# Two channels

  • AppleScript talks to Safari itself: windows, tabs, URLs, titles. tell application "Safari" to get URL of tab 2 of window id 1234.
  • do JavaScript … in tab runs code inside the page, in the page's own world, with its cookies. It needs Safari's "Allow JavaScript from Apple Events" setting.

Both need the macOS Automation permission (the calling app controlling Safari). Error -1743 means it was refused: System Settings → Privacy & Security → Automation.

# Windows have ids; use them

id of window is a stable integer for the life of the window. window 1 is "frontmost", which changes the moment the user clicks elsewhere — a script addressing window 1 will eventually act on the wrong page. Tabs have no id, only an index inside their window, which shifts when tabs before them close. Hence: one dedicated window per automation, addressed by id.

A closed window can linger as an invisible object with zero tabs. When listing windows, keep those that are visible and have at least one tab.

The window id is also the system's window number, so screencapture -x -o -l <id> out.png captures exactly that window, even when it is behind others (not when minimised).

# Knowing when a page is ready

This is the part most scripts get wrong.

When a navigation starts, Safari immediately sets the tab's URL property to the destination. The old document keeps answering do JavaScript until the new one commits. During that interval:

value
URL of tab (AppleScript) the new address
location.href (JavaScript) the old address
document.readyState "complete" — of the old page

So "wait until readyState is complete" returns instantly and you act on the page you were leaving. A newly created tab behaves the same way: it answers as about:blank / complete first.

The reliable test: the document's URL equals the tab's URL and readyState is complete, twice in a row. That is what wait-for-load.applescript and the tool do. A navigation that never commits (a download, an unreachable server) ends in a timeout, reported as such.

readyState says nothing about content loaded by scripts afterwards. For that, wait for the thing you need: a selector, or a piece of text.

# Getting values in and out

  • do JavaScript returns strings, numbers and booleans. An object, undefined, or a promise yields no value at all (an AppleScript "variable not defined" error if you use it). Return JSON.stringify(...).
  • It cannot wait for a promise. For asynchronous work, start it, store the outcome on a window property, and poll with further calls. For a simple authenticated GET, a synchronous XMLHttpRequest avoids the polling (see fetch-base64.applescript).
  • Never build JavaScript by concatenating user text into source. Pass the code as an argument (on run argv) and pass values percent-encoded, decoding them inside the page. The recipes do this; the test suite feeds them quotes, backslashes and '); sequences.
  • Inside a tell application "Safari" block the word tab is a browser tab, not the tab character. Use character id 9.
  • Results arrive on standard output, so large values (a megabyte of base64) are fine; arguments are limited to roughly 1 MB, so large inputs must be sent in chunks.

# Forms

  • Frameworks replace the value property of inputs to track changes. Assigning el.value goes through their replacement and is ignored on the next render. Call the native setter (Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set) and dispatch input and change with bubbles: true.
  • Checkboxes and radio buttons: click() them when their state differs from what you want.
  • <select>: set the value through the native setter, then dispatch change.
  • Submit with form.requestSubmit(), which runs validation and submit handlers; form.submit() skips both.
  • A file input cannot be given a path. Build a File from bytes, put it in a DataTransfer, and assign input.files = dt.files, then dispatch change.

# Reaching into components

document.querySelector does not see inside shadow roots or iframes. Walk them: for each element with an open shadowRoot, search it too; for each iframe, try contentDocument (it throws or is null across origins). Closed shadow roots and cross-origin frames stay out of reach.

# Dialogs

A JavaScript alert, confirm or prompt halts the page's scripts — including yours — until a person dismisses it. Replace the three functions in the page before triggering anything that may raise one, recording the message and returning a fixed answer. The tool does this each time it touches a page.

# What Apple events cannot do

Private windows, Reader mode, a PDF displayed in a tab, browser settings, extensions, and the address bar are not scriptable this way. UI scripting through System Events can reach some of them, but it needs Accessibility permission, depends on the interface language and layout, and takes over the keyboard and pointer; it is not used here.

Files main
Menu

Theme