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

Nothing yet.

## [1.2.0] — 2026-08-29

New API, all of it opt-in: the default path is unchanged. One caveat before you upgrade — `AppleScriptReturnType` and `AppleScriptError` each gained a case, so code that switches over either exhaustively, without a `default`, stops compiling until the new case is handled.

### Added

- A `.wildCard` return type, for the properties that answer with a different *type* depending on state — the `value` of a cell in Numbers is a number, a date, text, a boolean, or `missing value`, the last one meaning the cell is empty. Every other return type coerces that distinction away before Swift sees it. `.wildCard` returns an `AppleScriptValue` built from the Apple event descriptor instead, so the type can be switched on. Closes [#5](https://github.com/fredsimard/SwiftAppleScriptBridge/issues/5).
- `AppleScriptValue` decodes numbers, text, booleans, dates, files, `missing value`, application-defined constants, and lists and records, the last two recursively. Anything else arrives as `.unknown`, carrying the descriptor untouched so nothing is lost. Its `init(descriptor:)` is public, for descriptors obtained some other way.
- Constants carry their raw `FourCharCode`: what `'autp'` means is defined by the target application's dictionary, not by AppleScript, so there is no general mapping to hand the caller. `missing value` is the exception and has a case of its own.
- Record keys come through as written only when AppleScript did not recognize them as its own terminology. `{name:"Roger", age:42}` decodes as `["pnam": .string("Roger"), "age": .int(42)]`, since `name` compiles to the `'pnam'` property before the script ever runs.
- Integers are read at the width their descriptor declares rather than through `int32Value`, which answers `0` for anything wider than 32 bits.
- `String.appleScriptFourCharCode` and `String.init(appleScriptFourCharCode:)`, converting between a four-character code and its text, so an `AppleScriptValue.constant` can be compared against a code from an application's dictionary without building it by hand.
- `AppleScriptError.unsupportedReturnType(AppleScriptReturnType)`, thrown when an execution method cannot produce the declared type.

### Notes

- `.wildCard` works through `executeAppleScript(_:with:)` only. `osascript` prints its result as text, so the descriptor carrying the actual type is gone before `executeAppleScriptViaCommandLine(_:with:)` could read it; that method throws `.unsupportedReturnType` before launching the process, so the script does not run.

## [1.1.0] — 2026-08-19

### Added
Expand Down Expand Up @@ -70,7 +88,8 @@ 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.1.0...HEAD
[Unreleased]: https://github.com/fredsimard/SwiftAppleScriptBridge/compare/v1.2.0...HEAD
[1.2.0]: https://github.com/fredsimard/SwiftAppleScriptBridge/compare/v1.1.0...v1.2.0
[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
80 changes: 78 additions & 2 deletions Documentation/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Every public type, method and property in the package, grouped by the file it li

- [AppleScriptBridge](#applescriptbridge) — executing scripts, logging, permissions
- [AppleScriptObject](#applescriptobject) — the script model, return types, raw values
- [AppleScriptValue](#applescriptvalue) — the uncoerced result of a `.wildCard` script
- [AppleScriptError](#applescripterror) — error cases
- [String and URL extensions](#string-and-url-extensions) — escaping, paths, result parsing

Expand Down Expand Up @@ -58,7 +59,7 @@ Compiles and runs a script with `NSAppleScript`, and interprets the result accor
| `AppleScript` | The script object to execute. |
| `runtimeVariables` | Variables resolved at call time, overriding the object's predefined ones of the same name. Defaults to `nil`. |

**Returns** the script's result cast to the declared type — `Int`, `String`, `Bool`, `[String]`, `[String: Any]`, or `nil` for `.none`.
**Returns** the script's result cast to the declared type — `Int`, `String`, `Bool`, `[String]`, `[String: Any]`, [`AppleScriptValue`](#applescriptvalue) for `.wildCard`, or `nil` for `.none`.

**Throws**

Expand All @@ -82,14 +83,15 @@ public static func executeAppleScriptViaCommandLine(
) throws -> Any?
```

Runs the same script through `/usr/bin/osascript` in a subprocess instead. Useful if you hit an `NSAppleScript` quirk, or want a long script isolated from your process. Parameters and return values match `executeAppleScript(_:with:)`, with two differences: `.list` splits on `\r` rather than on newlines, and a failing script does not throw — only a launch failure or undecodable output does.
Runs the same script through `/usr/bin/osascript` in a subprocess instead. Useful if you hit an `NSAppleScript` quirk, or want a long script isolated from your process. Parameters and return values match `executeAppleScript(_:with:)`, with three differences: `.list` splits on `\r` rather than on newlines, a failing script does not throw — only a launch failure or undecodable output does — and `.wildCard` is refused.

**Throws**

| Error | When |
|---|---|
| `AppleScriptError.executionError` | The process failed to launch. |
| `AppleScriptError.failedToReadOutput` | The output was not valid UTF-8. |
| `AppleScriptError.unsupportedReturnType` | The script declared `.wildCard`. Checked before the process is launched, so the script does not run. |

> [!WARNING]
> Script errors are **not** detected. `stderr` is piped into the same pipe as `stdout` and the exit status is ignored, so a failing script returns osascript's error text as if it were a result — a `.string` call yields the error message, an `.int` call yields `nil`. Validate what comes back if you use this method.
Expand Down Expand Up @@ -155,8 +157,11 @@ How a script's result should be interpreted.
| `.list` | `[String]` |
| `.record` | `[String: Any]` — flat records only |
| `.json` | `[String: Any]` — for nested structures; build the JSON inside the script |
| `.wildCard` | [`AppleScriptValue`](#applescriptvalue) — the result with its actual type, uncoerced |
| `.none` | `nil` |

`.wildCard` is the only case that does not coerce, and the only one `executeAppleScriptViaCommandLine(_:with:)` refuses.

### `AppleScriptRawValue`

```swift
Expand Down Expand Up @@ -239,6 +244,60 @@ A human-readable, multi-line dump of the script's name, return type, variables a

---

## AppleScriptValue

`Sources/SwiftAppleScriptBridge/AppleScriptValue.swift`

```swift
public enum AppleScriptValue: Equatable {
case double(Double)
case int(Int)
case string(String)
case bool(Bool)
case date(Date)
case fileURL(URL)
case missingValue
case constant(FourCharCode)
case list([AppleScriptValue])
case record([String: AppleScriptValue])
case unknown(NSAppleEventDescriptor)
}
```

What a `.wildCard` script returns. Built from the Apple event descriptor's own type, so nothing is coerced: a property that answers with a number most of the time and `missing value` otherwise can be told apart at the call site.

| Case | Descriptor types | Carries |
|---|---|---|
| `.double` | `'doub'`, `'sing'`, `'ldbl'` | `Double`. AppleScript answers with a real for any whole number its 32-bit integer cannot hold, and for overflow, which is `Double.infinity`. |
| `.int` | `'shor'`, `'ushr'`, `'long'`, `'magn'`, `'comp'`, `'ucom'` | `Int`, read at the descriptor's own width rather than coerced to 32 bits. |
| `.string` | `'utxt'`, `'utf8'`, `'ut16'`, `'TEXT'`, `'cstr'` | `String`. |
| `.bool` | `'bool'`, `'true'`, `'fals'` | `Bool`. |
| `.date` | `'ldt '` | `Date`. |
| `.fileURL` | `'furl'`, `'alis'`, `'bmrk'` | `URL`. |
| `.missingValue` | `'type'` carrying `'msng'` | Nothing — AppleScript's `missing value`. |
| `.constant` | `'enum'`, `'type'`, `'prop'`, `'keyw'` | `FourCharCode`, raw. |
| `.list` | `'list'` | `[AppleScriptValue]`, decoded recursively. |
| `.record` | `'reco'` | `[String: AppleScriptValue]`, decoded recursively. |
| `.unknown` | anything else | The `NSAppleEventDescriptor`, untouched. |

> [!NOTE]
> Record keys come through as the caller wrote them only when AppleScript did not recognize them as terminology. `{name:"Roger", age:42}` decodes as `["pnam": .string("Roger"), "age": .int(42)]`, because `name` compiles to the `'pnam'` property. Application-returned records are terminology throughout, so expect codes there.

> [!NOTE]
> A constant carries no meaning of its own: what `'autp'` means comes from the application's dictionary. Compare it against the codes your target documents.

### `init(descriptor:)`

```swift
public init(descriptor: NSAppleEventDescriptor)
```

Decodes a descriptor into a value, recursively. `executeAppleScript(_:with:)` calls this for a `.wildCard` script; call it yourself to decode a descriptor you got some other way, or one you coerced out of an `.unknown`.

A descriptor whose type is not decoded — or one that is, but whose value cannot be read, such as an unsigned 64-bit integer larger than `Int.max` — yields `.unknown` rather than a wrong value.

---

## AppleScriptError

`Sources/SwiftAppleScriptBridge/AppleScriptError.swift`
Expand All @@ -248,6 +307,7 @@ public enum AppleScriptError: Error {
case failedToInitScript
case failedToReadOutput
case executionError(String)
case unsupportedReturnType(AppleScriptReturnType)
case genericMessage(String)
}
```
Expand All @@ -257,6 +317,7 @@ public enum AppleScriptError: Error {
| `failedToInitScript` | The script could not be compiled. |
| `failedToReadOutput` | It ran, but the output could not be read. |
| `executionError(String)` | It ran and returned an error, carried as text. |
| `unsupportedReturnType(AppleScriptReturnType)` | The execution method cannot produce the declared type. Today that is `.wildCard` through `executeAppleScriptViaCommandLine(_:with:)`. |
| `genericMessage(String)` | A catch-all with your own message, for your code to throw. |

### `description`
Expand Down Expand Up @@ -306,6 +367,21 @@ public func parseJSONStringFromAppleScript() -> [String: Any]?

Decodes a JSON object built inside AppleScript — by string concatenation, typically — into a dictionary. Returns `nil` if the text isn't valid JSON, or is valid JSON that isn't an object. Used automatically by the `.json` return type. This is the route for nested structures that `parseSimpleAppleScriptRecord()` can't handle.

### `String.appleScriptFourCharCode` / `String.init(appleScriptFourCharCode:)`

```swift
public var appleScriptFourCharCode: FourCharCode?
public init(appleScriptFourCharCode code: FourCharCode)
```

Convert between an Apple event four-character code and its text. `"msng".appleScriptFourCharCode` gives the code `'msng'`; `String(appleScriptFourCharCode:)` gives `"msng"` back. The property returns `nil` unless the text is exactly four MacRoman-encodable characters.

Use the property to compare an `AppleScriptValue.constant` against a code from an application's dictionary:

```swift
if case .constant("autp".appleScriptFourCharCode) = value { … }
```

### `String.toPOSIXPath()`

```swift
Expand Down
55 changes: 55 additions & 0 deletions Examples/AppleScripts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,30 @@ enum AppleScripts {
"""
)

// MARK: - WILDCARD

/// Reads a property that answers with a constant rather than with a value, without coercing it.
///
/// A disk's `format` is one of the constants in the Finder's `edfm` enumeration — `APFS format`
/// is `'dfap'`, `ExFAT format` is `'dfxf'` — and none of the coercing return types can carry
/// one: `.string` yields `nil` and `.int` yields `0`. `.wildCard` hands over what the Apple event
/// actually carried, so the constant arrives as `.constant` with its four-character code intact.
///
/// The same applies to any property whose *type* depends on state, which is the case this return
/// type exists for: a number most of the time, `missing value` when there is nothing to report.
///
/// - Parameter diskName: **SET AT RUNTIME**: Name of the disk to inspect, without a colon.
/// - Returns: An `AppleScriptValue`, `.constant` for any disk the Finder can describe.
static let diskFormat = AppleScriptBridge.AppleScriptObject(
name: "diskFormat",
returnType: .wildCard,
script: """
tell application id "\(targetApp)"
return format of disk "$diskName"
end tell
"""
)

// MARK: - VARIABLES AND RAW VALUES

/// Creates a folder inside a parent folder, showing how the three placeholder forms differ.
Expand Down Expand Up @@ -399,6 +423,37 @@ enum FinderTasks {
}
}

/// Reads the filesystem format of a disk.
///
/// The shape of a `.wildCard` call site: cast the result once, then switch on the type the script
/// actually answered with rather than on the one you hoped for. Constants carry no meaning of their
/// own — what `'dfap'` means comes from the Finder's dictionary — so the codes are compared here
/// against the ones that dictionary documents.
///
/// - Parameter name: The disk to inspect, for example `"Macintosh HD"`.
/// - Returns: The format's name, or a description of what came back instead.
static func format(ofDisk name: String) -> String {
let result = try? AppleScriptBridge.executeAppleScript(
AppleScripts.diskFormat,
with: ["diskName": name]
)

switch result as? AppleScriptBridge.AppleScriptValue {
case .constant(let code):
let format = String(appleScriptFourCharCode: code)
switch format {
case "dfap": return "APFS"
case "dfh+": return "Mac OS Extended"
case "dfxf": return "ExFAT"
case "dfsm": return "SMB"
default: return "other (\(format))"
}
case .missingValue: return "no format reported"
case .none: return "the script failed"
default: return "unexpected type"
}
}

/// Moves several items to a destination folder.
///
/// Shows a Swift collection crossing over as an AppleScript list: the `[String]` is rendered as
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,54 @@ Two things to know. AppleScript writes an empty list and an empty record identic
| `.list` | `[String]` |
| `.record` | `[String: Any]` — flat records only |
| `.json` | `[String: Any]` — for nested structures; build the JSON inside the script |
| `.wildCard` | `AppleScriptValue` — whatever the script returned, with its actual type |
| `.none` | `nil` |

`.record` handles a flat `{name:"John", age:42}` and correctly leaves colons and commas that appear *inside* quoted values alone. For anything nested, build a JSON string in AppleScript and use `.json`.

#### When the type is the answer

Every return type above coerces: you declare one, and whatever comes back is read as that. Some application properties don't work that way — they answer with a *different type* depending on state. The `value` of a cell in Numbers is documented as "number, date, text, boolean, or `missing value`", the last one meaning the cell is empty. Declare `.int` there and an empty cell is indistinguishable from a cell holding zero.

`.wildCard` skips the coercion. The result comes back as an `AppleScriptValue` built from what the Apple event actually carried, so you switch on the type instead of assuming it:

```swift
let script = AppleScriptBridge.AppleScriptObject(
name: "cellValue",
returnType: .wildCard,
script: """
tell application "Numbers" to tell document 1 to tell sheet 1
return value of cell "$cell" of table 1
end tell
"""
)

switch try AppleScriptBridge.executeAppleScript(script, with: ["cell": "B2"]) as? AppleScriptBridge.AppleScriptValue {
case .double(let number): print("number: \(number)")
case .string(let text): print("text: \(text)")
case .date(let date): print("date: \(date)")
case .bool(let flag): print("boolean: \(flag)")
case .missingValue: print("the cell is empty")
default: break
}
```

| `AppleScriptValue` case | What it carries |
|---|---|
| `.double` / `.int` / `.string` / `.bool` | The scalar, as its own type |
| `.date` | `Date` |
| `.fileURL` | `URL`, for an alias or a file URL |
| `.missingValue` | AppleScript's `missing value` |
| `.constant` | An application-defined constant, as its raw `FourCharCode` |
| `.list` / `.record` | `[AppleScriptValue]` / `[String: AppleScriptValue]`, decoded recursively |
| `.unknown` | The `NSAppleEventDescriptor` itself, for anything not decoded above |

A few things to know:

- **Constants come as raw codes.** There is no general mapping from a four-character code to a meaning — what `'autp'` means is defined by the application's dictionary, not by AppleScript. Compare against the codes your target documents: `if case .constant("autp".appleScriptFourCharCode) = value`. `missing value` is the one exception, and gets a case of its own.
- **Record keys are as written, or as compiled.** A key you wrote yourself comes through by name, but a key AppleScript recognizes as its own terminology is compiled to a four-character code before the script runs. `{name:"Roger", age:42}` decodes as `["pnam": .string("Roger"), "age": .int(42)]`.
- **`NSAppleScript` only.** `executeAppleScriptViaCommandLine` throws `.unsupportedReturnType` on a `.wildCard` script, without running it. `osascript` prints its result as text, so the type is already gone by the time that method could read anything.

### Escaping, and how to opt out

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.
Expand Down
Loading