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 tabruns 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 JavaScriptreturns 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). ReturnJSON.stringify(...).- It cannot wait for a promise. For asynchronous work, start it, store the outcome on a
windowproperty, and poll with further calls. For a simple authenticated GET, a synchronousXMLHttpRequestavoids the polling (seefetch-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 wordtabis a browser tab, not the tab character. Usecharacter 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
valueproperty of inputs to track changes. Assigningel.valuegoes through their replacement and is ignored on the next render. Call the native setter (Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set) and dispatchinputandchangewithbubbles: true. - Checkboxes and radio buttons:
click()them when their state differs from what you want. <select>: set the value through the native setter, then dispatchchange.- Submit with
form.requestSubmit(), which runs validation and submit handlers;form.submit()skips both. - A file input cannot be given a path. Build a
Filefrom bytes, put it in aDataTransfer, and assigninput.files = dt.files, then dispatchchange.
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.