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
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

Nothing yet.

## [1.1.0] — 2026-08-19

### Added

- Swift collections are now rendered as their AppleScript equivalents on substitution: an `Array` becomes a list, a `Dictionary` becomes a record, and the two nest, so an array of dictionaries arrives as a list of records. Elements are escaped by the same rules as any other value, so building AppleScript source by hand and wrapping it in `AppleScriptRawValue` is no longer the only way to pass a list. Both render as bare literals, written `$key` rather than `"$key"`. Closes [#1](https://github.com/fredsimard/SwiftAppleScriptBridge/issues/1).
- Record keys that are not plain AppleScript identifiers — keys holding spaces or punctuation, and AppleScript's reserved words — are vertical-bar quoted (`{|first name|:"Roger"}`), which is the only way such a key can be written. Keys that *are* plain identifiers are left bare, so a term the target application defines keeps its meaning.
- Dictionary keys are sorted when rendered, so the same dictionary always produces the same script source. A key that is not a `String` is described first, which is what a dictionary bridged from Objective-C or from `JSONSerialization` needs.
- An empty dictionary renders as `{}`, which is also how AppleScript writes an empty list. The script decides which it is.

### Changed

- An `Array` or `Dictionary` passed as a variable used to fall through to `String(describing:)` and arrive as text such as `["a", "b"]`. It now arrives as a real AppleScript list or record. The public API is unchanged and nothing stops compiling on the Swift side, but a script written against the old text form has to be updated — that output was almost certainly a bug rather than something to depend on.
- A collection substituted into a *quoted* placeholder (`"$key"`) now produces a syntax error rather than text, since the rendered literal carries its own quotation marks: `"{"a", "b"}"`. Write these placeholders bare, as `$key`. The failure is loud and happens at compile time, before the script runs.

## [1.0.1] — 2026-08-16

Six fixes from a full audit of the package. The public API is unchanged, but three of them change what an existing call returns in cases where the old result was wrong.
Expand Down Expand Up @@ -56,6 +70,7 @@ First public release.
- Ships in Swift 5 language mode. `AppleScriptObject` carries `[String: Any]`, which is not `Sendable`; a strict-concurrency redesign is planned for 2.0.
- An app using this package cannot be sandboxed and so cannot ship on the Mac App Store. Developer ID signing and notarization are unaffected.

[Unreleased]: https://github.com/fredsimard/SwiftAppleScriptBridge/compare/v1.0.1...HEAD
[Unreleased]: https://github.com/fredsimard/SwiftAppleScriptBridge/compare/v1.1.0...HEAD
[1.1.0]: https://github.com/fredsimard/SwiftAppleScriptBridge/compare/v1.0.1...v1.1.0
[1.0.1]: https://github.com/fredsimard/SwiftAppleScriptBridge/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/fredsimard/SwiftAppleScriptBridge/releases/tag/v1.0.0
6 changes: 5 additions & 1 deletion Documentation/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ public struct AppleScriptRawValue: Sendable {
}
```

Wraps a value that is already valid AppleScript source, so it is substituted verbatim instead of being escaped. Use it for lists and records you generated yourself.
Wraps a value that is already valid AppleScript source, so it is substituted verbatim instead of being escaped. Arrays and dictionaries are converted for you, so this is for the fragments no type mapping covers: an expression, a call, terminology you assembled yourself.

> [!WARNING]
> Never wrap unvalidated input in this type — anything wrapped here is inserted unchanged and executed as code. Escape every value you interpolate into that source with `appleScriptStringEscaped`.
Expand Down Expand Up @@ -220,8 +220,12 @@ Values are rendered like this:
| `String` | escaped text, for use inside `"..."` |
| `Bool` | `true` / `false` |
| `Int`, `Double` | the bare number |
| `Array` | an AppleScript list, `{"a", "b"}`, each element rendered by these same rules |
| `Dictionary` | an AppleScript record, `{name:"Roger", age:42}`, keys sorted and quoted only where AppleScript needs it |
| anything else | escaped `String(describing:)`, so an unforeseen type fails safe |

Collections nest, so an array of dictionaries renders as a list of records. Both render as bare literals: write the placeholder as `$key`, not `"$key"`. A record key that is not a plain identifier, or that is one of AppleScript's reserved words, is vertical-bar quoted — `{|first name|:"Roger"}` — and has to be read back out of the record the same way. An empty dictionary renders as `{}`, which is also how AppleScript writes an empty list.

> [!NOTE]
> Keys are substituted longest-first, so `$page` never eats the front of `$pageNumber`.

Expand Down
27 changes: 14 additions & 13 deletions Examples/AppleScripts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -295,12 +295,12 @@ enum AppleScripts {

/// Moves several items to a destination folder in a single script.
///
/// The list of paths has no Swift equivalent the bridge can render on its own, so the caller
/// builds the AppleScript list itself and passes it as an `AppleScriptRawValue`, which opts
/// out of escaping. Building that list is the caller's job — and so is escaping every path
/// inside it, with `appleScriptStringEscaped`. See `moveItems(_:to:)` below.
/// The paths are passed as a Swift `[String]`, which the bridge renders as an AppleScript list
/// with every element escaped. The placeholder is written bare — `$itemPaths`, not
/// `"$itemPaths"` — because it renders as a list literal rather than as text. See
/// `moveItems(_:to:)` below.
///
/// - Parameter itemPaths: **SET AT RUNTIME**: Raw AppleScript list of quoted POSIX paths.
/// - Parameter itemPaths: **SET AT RUNTIME**: POSIX paths of the items to move, as `[String]`.
/// - Parameter destinationPath: **SET AT RUNTIME**: POSIX path of the destination folder.
/// - Returns: An `Int` with the number of items moved.
static let moveItems = AppleScriptBridge.AppleScriptObject(
Expand Down Expand Up @@ -401,23 +401,24 @@ enum FinderTasks {

/// Moves several items to a destination folder.
///
/// Shows the one case where escaping is the caller's responsibility: the AppleScript list is
/// built here, so every path in it must go through `appleScriptStringEscaped` before being
/// wrapped in an `AppleScriptRawValue`. Wrapping unescaped text would let a filename containing
/// a quote break out of its literal and run as code.
/// Shows a Swift collection crossing over as an AppleScript list: the `[String]` is rendered as
/// `{"...", "..."}` with each path escaped on the way in, so a filename containing a quote
/// cannot break out of its literal. Dictionaries cross over the same way, as records, and the
/// two nest — an `[[String: Any]]` arrives as a list of records.
///
/// `AppleScriptRawValue` is still there for source no type mapping covers, such as an
/// expression or a terminology fragment the caller assembled:
/// `"target": AppleScriptBridge.AppleScriptRawValue("front window")`.
///
/// - Parameters:
/// - urls: The items to move.
/// - destination: The folder to move them into.
/// - Returns: The number of items actually moved.
static func moveItems(_ urls: [URL], to destination: URL) -> Int {
let quotedPaths = urls.map { "\"\($0.path(percentEncoded: false).appleScriptStringEscaped)\"" }
let appleScriptList = "{" + quotedPaths.joined(separator: ", ") + "}"

let result = try? AppleScriptBridge.executeAppleScript(
AppleScripts.moveItems,
with: [
"itemPaths": AppleScriptBridge.AppleScriptRawValue(appleScriptList),
"itemPaths": urls.map { $0.path(percentEncoded: false) },
"destinationPath": destination.path(percentEncoded: false)
]
)
Expand Down
37 changes: 32 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ A placeholder inside quotes — i.e. `"$key"` — always yields AppleScript **te
> [!NOTE]
> Keys are substituted longest-first, so `$page` never eats the front of `$pageNumber`.

Strings are escaped on the way in (backslashes and double quotes), which is why they must be quoted: a bare `$name` holding `hello` would compile as an undefined identifier. Numbers and booleans render as bare literals, so a bare `$count` arrives as a real integer with nothing left to do. Quote one of those and it becomes text, and you have to coerce it at the top of the script: `set pageNumber to pageNumber as integer`. Wrapping a value in `AppleScriptRawValue` skips escaping entirely and inserts it as source, which is how you pass lists and records.
Strings are escaped on the way in (backslashes and double quotes), which is why they must be quoted: a bare `$name` holding `hello` would compile as an undefined identifier. Numbers, booleans and collections render as bare literals, so a bare `$count` arrives as a real integer with nothing left to do. Quote one of those and it becomes text, and you have to coerce it at the top of the script: `set pageNumber to pageNumber as integer`. Wrapping a value in `AppleScriptRawValue` skips escaping entirely and inserts it as source, which is the escape hatch for script fragments no type mapping covers.

| Swift value | Rendered as | Write it as | AppleScript receives | Coercion in AppleScript |
|---|---|---|---|---|
Expand All @@ -122,6 +122,8 @@ Strings are escaped on the way in (backslashes and double quotes), which is why
| `Double` | `1.5` | `"$key"` | `text` | `as real` |
| `Bool` | `true` / `false` | `$key` | `boolean` | — |
| `Bool` | `true` / `false` | `"$key"` | `text` | `as boolean` |
| `Array` | `{"a", "b"}` | `$key` | `list` | — |
| `Dictionary` | `{name:"Roger", age:42}` | `$key` | `record` | — |
| `AppleScriptRawValue` | verbatim source | `$key` | whatever it evaluates to | — |
| HFS path (a `String`, from `toHFSPath()`) | escaped text | `"$path"` | `text` | `as alias`, or `as «class furl»` |
| POSIX path (a `String`) | escaped text | `POSIX file "$path"` | `file` specifier | — or `as alias` if the app wants one |
Expand Down Expand Up @@ -172,6 +174,30 @@ try AppleScriptBridge.executeAppleScript(script, with: [
])
```

#### Lists and records

Arrays become AppleScript lists and dictionaries become records, with every element escaped on the way in. They nest, so an array of dictionaries arrives as a list of records — the shape most scripts actually want:

```swift
let script = AppleScriptBridge.AppleScriptObject(
name: "tagFiles",
variables: ["items": [["path": "/tmp/a.txt", "tags": ["draft", "q3"]],
["path": "/tmp/b.txt", "tags": [] as [String]]]],
script: """
repeat with anItem in $items
set thePath to path of anItem -- text
set theTags to tags of anItem -- list
end repeat
"""
)
```

The placeholder is written bare — `$items`, not `"$items"` — since it renders as a literal, not as text.

Record keys are AppleScript identifiers, not strings, so a key that isn't a plain identifier (`first name`, `2nd`) or that is one of AppleScript's reserved words (`set`, `end`, `to`) is written vertical-bar quoted: `{|first name|:"Roger"}`. Read it back out of the record the same way, with the bars. Keys that *are* plain identifiers are left bare, so a term the target application defines keeps its meaning. Dictionary keys are sorted when rendered, so the same dictionary always produces the same script source.

Two things to know. AppleScript writes an empty list and an empty record identically, as `{}`, so an empty dictionary renders as `{}` and the script decides what it is. And a dictionary key that isn't a `String` is described first, which is what a dictionary bridged from Objective-C or from `JSONSerialization` needs.

### Return types

| `returnType` | Swift result |
Expand All @@ -190,7 +216,7 @@ try AppleScriptBridge.executeAppleScript(script, with: [

Every `String` value is escaped on substitution. That's what you want almost all of the time — a path or a filename should arrive as *data*, not as code.

But sometimes the thing you're substituting really is AppleScript source. There's no automatic conversion for a Swift array, for instance, so if you want AppleScript to receive a list you have to write that list out yourself and tell the bridge not to escape it. That's what `AppleScriptRawValue` is for:
But sometimes the thing you're substituting really is AppleScript source — an expression, a call, a fragment that no type mapping covers. Wrapping it in `AppleScriptRawValue` tells the bridge to insert it unchanged instead of escaping it:

```swift
"filesArray": AppleScriptBridge.AppleScriptRawValue(records) // inserted as code
Expand All @@ -199,7 +225,10 @@ But sometimes the thing you're substituting really is AppleScript source. There'

#### Example

Say you want to hand AppleScript a list of file paths. You build the list in Swift:
Say you want to hand AppleScript a list of file paths, and you build the list yourself:

> [!NOTE]
> You no longer have to: passing the `[String]` straight through renders the same list, escaped for you. The example is kept because it shows what building source by hand involves, and what the escaping is protecting you from.

```swift
let paths = urls.map { "\"\($0.path(percentEncoded: false).appleScriptStringEscaped)\"" }
Expand Down Expand Up @@ -299,8 +328,6 @@ The package's own messages are English only, by design. Applications that show e

Semantic versioning. `1.x` ships in Swift 5 language mode: `AppleScriptObject` carries `[String: Any]`, which is not `Sendable`, and the API is synchronous. A future `2.0` is planned to adopt Swift 6 strict concurrency with a typed, `Sendable` variable representation.

Also on the list: converting Swift arrays, dictionaries and tuples straight to AppleScript lists and records, so you don't have to build that source by hand and escape it yourself — see [#1](https://github.com/fredsimard/SwiftAppleScriptBridge/issues/1).

## License

MIT. See [LICENSE](LICENSE).
Loading