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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7.0.1
- name: Untrust unused runner tap
run: brew untap aws/tap || true
- name: Install SwiftLint
run: brew install swiftlint
- name: Lint
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ Packages/
xcuserdata/
DerivedData/
.netrc
CODE_REVIEW.md
19 changes: 17 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

No functional change so far: the public API, and what every method does, are identical to 1.0.0.
Nothing yet.

## [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.

### Fixed

- `executeAppleScriptViaCommandLine(_:with:)` returned `false` for every `.bool` script. `osascript` prints booleans as `true` and `false`, which the integer parsing read as zero. Both forms are now accepted.
- `executeAppleScriptViaCommandLine(_:with:)` deadlocked on output larger than the pipe buffer, around 64 KB — a `.json` listing of a large folder reaches that easily. The pipe is now drained before the process is waited on.
- Variable substitution rescanned text it had already substituted, so a value containing a placeholder name was rewritten by a later variable: a file named `$folderPath.txt` passed as `folderPath` had that variable's value spliced into its name. The script is now walked once.
- Numbers arriving as `NSNumber`, from `JSONSerialization` or from Objective-C, were substituted as `true` rather than as their value, because an `NSNumber` bridges to `Bool` whatever it holds. `Float` and `UInt` now render as numbers too, instead of as escaped text.
- `toHFSPath()` prepended the startup disk name to every path, so a file on any other volume converted to a path pointing at nothing. The volume name and mount point are now read from the path itself, which also covers disk images and network shares. That requires the path to exist; `nil` comes back otherwise.
- `resetAutomationPermission(for:)` reported success whenever `tccutil` launched, ignoring its exit status.

### Added

Expand All @@ -19,6 +32,7 @@ No functional change so far: the public API, and what every method does, are ide

- Dropped four redundant `?? nil` coalescings in `executeAppleScript(_:with:)`, where the expression was already optional and the return type is `Any?`. Same results, same types.
- SwiftLint now fails the build on any violation, with the rules that conflict with the codebase's deliberate style disabled and annotated.
- Documentation corrections across `README.md`, `Documentation/API.md` and the in-code doc comments: the throws list for `executeAppleScript(_:with:)`, the omitted `[String: Any]` return case, the record parser's key rule, how `.bool` results are read, and a warning that the command-line method passes the script as an argument visible to `ps`.

## [1.0.0] — 2026-08-16

Expand All @@ -42,5 +56,6 @@ 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.0...HEAD
[Unreleased]: https://github.com/fredsimard/SwiftAppleScriptBridge/compare/v1.0.1...HEAD
[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
11 changes: 7 additions & 4 deletions Documentation/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ 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 only a launch failure throws.
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.

**Throws**

Expand All @@ -94,6 +94,9 @@ Runs the same script through `/usr/bin/osascript` in a subprocess instead. Usefu
> [!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.

> [!WARNING]
> The fully substituted script is passed to `osascript` as a command-line argument, so any local process can read it with `ps`. If your variables carry sensitive strings, prefer `executeAppleScript(_:with:)`.

### `requestAutomationPermission(for:activating:)`

```swift
Expand Down Expand Up @@ -148,7 +151,7 @@ How a script's result should be interpreted.
|---|---|
| `.int` | `Int` |
| `.string` | `String` |
| `.bool` | `Bool` — non-zero is `true` |
| `.bool` | `Bool` — the script's `true`/`false`, or any non-zero number |
| `.list` | `[String]` |
| `.record` | `[String: Any]` — flat records only |
| `.json` | `[String: Any]` — for nested structures; build the JSON inside the script |
Expand Down Expand Up @@ -289,7 +292,7 @@ Parses a flat AppleScript record such as `{name:"John Doe", age:42, active:true}

The record is scanned character by character rather than rewritten into JSON, so colons and commas *inside* quoted values — common in file paths and times — are correctly treated as content.

**Limitations:** keys must be alphanumeric, string values must be double-quoted, and nested records or lists are not supported. Use `.json` for anything nested.
**Limitations:** a key is everything up to the first colon and cannot be empty, string values must be double-quoted, and nested records or lists are not supported. Use `.json` for anything nested.

### `String.parseJSONStringFromAppleScript()`

Expand All @@ -313,7 +316,7 @@ Converts an HFS-style path (`Macintosh HD:Users:roger:Desktop`) to POSIX (`/User
public func toHFSPath() -> String?
```

The reverse: POSIX to HFS-style. Returns `nil` if the startup disk name can't be read. The `URL` version delegates to the `String` one, so the two can't drift apart.
The reverse: POSIX to HFS-style. The result starts with the name of the volume holding the file, so paths on external drives, disk images and network shares convert correctly. The path must exist — the volume is read from the file system — and `nil` comes back otherwise. The `URL` version delegates to the `String` one, so the two can't drift apart.

> [!NOTE]
> Most scriptable applications now expect POSIX paths, which are better passed as text and wrapped in the script with `POSIX file "$path"`. These conversions are here for the applications and script dialects that still want HFS paths.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ try AppleScriptBridge.executeAppleScript(script, with: [
|---|---|
| `.int` | `Int` |
| `.string` | `String` |
| `.bool` | `Bool` (non-zero == `true`) |
| `.bool` | `Bool` (the script's `true`/`false`, or any non-zero number) |
| `.list` | `[String]` |
| `.record` | `[String: Any]` — flat records only |
| `.json` | `[String: Any]` — for nested structures; build the JSON inside the script |
Expand Down
31 changes: 22 additions & 9 deletions Sources/SwiftAppleScriptBridge/AppleScriptBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ public class AppleScriptBridge: NSObject {
/// - AppleScript: The `AppleScriptObject` to execute.
/// - runtimeVariables: Variables resolved at call time, overriding the object's predefined ones of the
/// same name. Defaults to `nil`.
/// - Returns: The result of the script, cast to the expected type (`Int`, `String`, `Bool`, `[String]`, or `nil`).
/// - Returns: The result of the script, cast to the expected type (`Int`, `String`, `Bool`, `[String]`,
/// `[String: Any]`, or `nil`).
/// - Throws:
/// - `AppleScriptError.failedToInitScript` if the script cannot be initialized.
/// - `AppleScriptError.executionError` if execution fails and returns an error.
/// - `AppleScriptError.failedToReadOutput` if the result can't be parsed.
///
/// - Note: For `.list` and `.record`, raw strings are returned and may need further parsing.
///
Expand Down Expand Up @@ -111,7 +111,8 @@ public class AppleScriptBridge: NSObject {
/// - AppleScript: The `AppleScriptObject` to execute.
/// - runtimeVariables: Variables resolved at call time, overriding the object's predefined ones of the
/// same name. Defaults to `nil`.
/// - Returns: The result of the script, cast to the expected type (`Int`, `String`, `Bool`, `[String]`, or `nil`).
/// - Returns: The result of the script, cast to the expected type (`Int`, `String`, `Bool`, `[String]`,
/// `[String: Any]`, or `nil`).
/// - Throws:
/// - `AppleScriptError.executionError` if the process fails to launch or run.
/// - `AppleScriptError.failedToReadOutput` if the output cannot be interpreted.
Expand All @@ -120,6 +121,9 @@ public class AppleScriptBridge: NSObject {
///
/// - Note: Spawning `/usr/bin/osascript` is blocked by the App Sandbox. Use this only from a
/// non-sandboxed application.
///
/// - Note: The fully substituted script is passed to `osascript` as a command-line argument, where any
/// local process can read it with `ps`. Prefer `executeAppleScript(_:with:)` if variables carry secrets.
public static func executeAppleScriptViaCommandLine(_ AppleScript: AppleScriptObject, with runtimeVariables: [String: Any]? = nil) throws -> Any? {
let process = Process()
let pipe = Pipe()
Expand All @@ -131,22 +135,25 @@ public class AppleScriptBridge: NSObject {
process.standardOutput = pipe
process.standardError = pipe

let data: Data
do {
try process.run()
// Read before waiting: osascript blocks writing once the pipe buffer fills,
// and waitUntilExit() would then never return.
data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
} catch {
throw AppleScriptError.executionError(String(describing: error))
}

let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let result = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
throw AppleScriptError.failedToReadOutput
}

switch AppleScript.returnType {
case .int: return Int(result)
case .string: return result
case .bool: return (Int(result) ?? 0) != 0
case .bool: return result.caseInsensitiveCompare("true") == .orderedSame || (Int(result) ?? 0) != 0
case .list: return result.components(separatedBy: "\r")
case .record: return result.parseSimpleAppleScriptRecord()
case .json: return result.parseJSONStringFromAppleScript()
Expand All @@ -165,8 +172,8 @@ public class AppleScriptBridge: NSObject {
/// - Parameter bundleIdentifier: The bundle identifier of the application whose authorization is reset.
/// Defaults to the host application's own identifier. If it cannot be read, the reset is skipped rather
/// than run without a target, so that other applications' authorizations are never affected.
/// - Returns: `true` if `tccutil` ran to completion, `false` if the identifier was missing or the process
/// failed to launch.
/// - Returns: `true` if `tccutil` reset the authorization, `false` if the identifier was missing, the
/// process failed to launch, or `tccutil` exited with a non-zero status.
///
/// - Note: Spawning `/usr/bin/tccutil` is blocked by the App Sandbox, so this is only usable from a
/// non-sandboxed application.
Expand All @@ -183,10 +190,16 @@ public class AppleScriptBridge: NSObject {
do {
try process.run()
process.waitUntilExit()

guard process.terminationStatus == 0 else {
log("Failed to reset automation permissions: tccutil exited with status \(process.terminationStatus).")
return false
}

log("Automation permissions reset successfully.")
return true
} catch {
log("Failed to reset automation permissions: " + error.localizedDescription)
log("Failed to reset automation permissions: " + String(describing: error))
return false
}
}
Expand Down Expand Up @@ -229,7 +242,7 @@ public class AppleScriptBridge: NSObject {
log(error.description)
return false
} catch {
log("Unexpected error:\n" + error.localizedDescription)
log("Unexpected error:\n" + String(describing: error))
return false
}
}
Expand Down
49 changes: 39 additions & 10 deletions Sources/SwiftAppleScriptBridge/AppleScriptObject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ extension AppleScriptBridge {
/// - Properties:
/// - `name`: Mandatory. A unique name identifying the script. Useful for logging or display.
/// - `returnType`: The expected return type from the AppleScript execution. Defaults to `.none` if no value is provided.
/// - `variables`: A optional dictionary of predefined variables used to replace placeholders in the script. `String` values are escaped on substitution; wrap AppleScript source in `AppleScriptRawValue` to insert it verbatim.
/// - `variables`: An optional dictionary of predefined variables used to replace placeholders in the script. `String` values are escaped on substitution; wrap AppleScript source in `AppleScriptRawValue` to insert it verbatim.
/// - `script`: Mandatory. The raw AppleScript source, with optional `$key` placeholders for dynamic substitution with elements in `variables` when using the `preparedScript` function. A placeholder holding text must be written inside a quoted literal (`"$key"`); one holding a number or a boolean must be written bare (`$key`).
///
/// - Initializer:
Expand Down Expand Up @@ -117,21 +117,32 @@ extension AppleScriptBridge {
/// - Parameter runtimeVariables: Variables resolved at call time, overriding predefined ones of the same name.
/// - Returns: The AppleScript source, ready to compile.
public func preparedScript(with runtimeVariables: [String: Any]? = nil) -> String {
var preparedScript = script

// Combine predefined variables with runtime variables
let combinedVariables = (variables ?? [:]).merging(runtimeVariables ?? [:]) { _, runtimeValue in
runtimeValue // Runtime variable overrides predefined one
}

// Replace variables in the script, longest key first: `$page` would otherwise also match the
// start of `$pageNumber` and leave a stray "Number" behind, depending on dictionary order.
for key in combinedVariables.keys.sorted(by: { $0.count > $1.count }) {
guard let value = combinedVariables[key] else { continue }
preparedScript = preparedScript.replacingOccurrences(
of: "$\(key)",
with: Self.substitution(for: value)
)
// Longest key first: `$page` would otherwise also match the start of `$pageNumber` and leave
// a stray "Number" behind, depending on dictionary order.
let keys = combinedVariables.keys.sorted(by: { $0.count > $1.count })

// Single pass over the original script, so a substituted value is never itself scanned for
// placeholders: a filename like `$folderPath.txt` stays intact.
var preparedScript = ""
var index = script.startIndex

while index < script.endIndex {
let afterDollar = script.index(after: index)
guard script[index] == "$",
let key = keys.first(where: { script[afterDollar...].hasPrefix($0) }),
let value = combinedVariables[key] else {
preparedScript.append(script[index])
index = afterDollar
continue
}
preparedScript += Self.substitution(for: value)
index = script.index(afterDollar, offsetBy: key.count)
}

return preparedScript
Expand All @@ -147,6 +158,8 @@ extension AppleScriptBridge {
/// - Parameter value: The variable value to render.
/// - Returns: The text to insert in place of the placeholder.
private static func substitution(for value: Any) -> String {
let value = unwrappedNumber(value)

switch value {
case let raw as AppleScriptRawValue : return raw.source
case let string as String : return string.appleScriptStringEscaped
Expand All @@ -157,6 +170,22 @@ extension AppleScriptBridge {
}
}

/// Unwraps an `NSNumber` into the Swift type it actually carries, leaving any other value untouched.
///
/// An `NSNumber` bridges to `Bool` whatever it holds: `NSNumber(value: 1) as? Bool` is `true`. A count
/// coming from `JSONSerialization` or from Objective-C would therefore match the boolean case in
/// `substitution(for:)` and render as `true` instead of `1`. Only the CoreFoundation type tells a
/// boolean from a number, so the check happens here, once, before any type matching.
///
/// - Parameter value: The variable value to unwrap.
/// - Returns: `Bool` for a CoreFoundation boolean, `Double` for a floating-point number, `Int` for any
/// other number, or the value unchanged if it is not a number.
private static func unwrappedNumber(_ value: Any) -> Any {
guard let number = value as? NSNumber else { return value }
if CFGetTypeID(number) == CFBooleanGetTypeID() { return number.boolValue }
return CFNumberIsFloatType(number as CFNumber) ? number.doubleValue : Int(number.int64Value)
}

/// Returns a human-readable, multi-line description of the script.
///
/// - Returns: A string listing the script's name, return type, predefined variables and source.
Expand Down
Loading