From 6b5e2856bbe5e2cfe4f4257c84c6fa3ccba7d216 Mon Sep 17 00:00:00 2001 From: fredsimard Date: Sat, 29 Aug 2026 21:35:09 -0400 Subject: [PATCH 1/5] Return the uncoerced result of a script as an AppleScriptValue Some application properties 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 when the cell is empty. Every return type coerced that distinction away before Swift saw it, since the caller had to declare one type up front and NSAppleScript's descriptor was discarded once it had. The new .wildCard return type switches on the descriptor's own type instead, and returns an AppleScriptValue carrying what it found: numbers, text, booleans, dates, files, missing value, application-defined constants, and lists and records, the last two decoded recursively. A type the enum does not decode arrives as .unknown, carrying the descriptor untouched, so nothing is lost and the caller can drill in. Integers are read at the width their descriptor declares, since int32Value answers zero for anything wider. Record keys need unpacking: an Apple event record is keyed by four-character code, so the keys a caller writes are packed into a keyASUserRecordFields list of alternating names and values. Keys AppleScript owns as terminology are compiled to codes before the script runs and come through as those, which is why {name:"Roger"} decodes as ["pnam": .string("Roger")]. Constants carry their raw code, as there is no general mapping from a code to a meaning: what 'autp' means is defined by the target application's dictionary. String's appleScriptFourCharCode conversions make one comparable without hand-assembling the bytes. osascript prints its result as text, so the descriptor is gone before the command-line method could read it. That method throws the new .unsupportedReturnType error before launching the process, so a script declaring .wildCard never runs and never touches the target application. Closes #5. Co-Authored-By: Claude Opus 5 --- .../AppleScriptBridge.swift | 46 ++-- .../AppleScriptError.swift | 11 +- .../AppleScriptObject.swift | 7 + .../AppleScriptValue.swift | 213 ++++++++++++++++++ .../StringExtensions.swift | 27 +++ 5 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 Sources/SwiftAppleScriptBridge/AppleScriptValue.swift diff --git a/Sources/SwiftAppleScriptBridge/AppleScriptBridge.swift b/Sources/SwiftAppleScriptBridge/AppleScriptBridge.swift index 0308721..2d59e8d 100644 --- a/Sources/SwiftAppleScriptBridge/AppleScriptBridge.swift +++ b/Sources/SwiftAppleScriptBridge/AppleScriptBridge.swift @@ -60,13 +60,16 @@ public class AppleScriptBridge: NSObject { /// - 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]`, - /// `[String: Any]`, or `nil`). + /// `[String: Any]`, `AppleScriptValue`, or `nil`). /// - Throws: /// - `AppleScriptError.failedToInitScript` if the script cannot be initialized. /// - `AppleScriptError.executionError` if execution fails and returns an error. /// /// - Note: For `.list` and `.record`, raw strings are returned and may need further parsing. /// + /// - Note: `.wildCard` skips the coercion entirely and returns an `AppleScriptValue` built from the Apple + /// event descriptor, so a script that answers with a different type depending on state can be switched on. + /// /// - Note: A script returning `0` yields `0`, not `nil`. Failure is reported by throwing, so callers can tell /// "the target app answered zero" from "the target app did not answer" — a distinction that open-document /// checks and similar state queries depend on. @@ -92,13 +95,14 @@ public class AppleScriptBridge: NSObject { if let error = error { throw AppleScriptError.executionError(String(describing: error)) } switch AppleScript.returnType { - case .int: return Int(result.int32Value) - case .string: return result.stringValue - case .bool: return result.int32Value != 0 ? true : false - case .list: return result.stringValue?.components(separatedBy: .newlines) - case .record: return result.stringValue?.parseSimpleAppleScriptRecord() - case .json: return result.stringValue?.parseJSONStringFromAppleScript() - case .none: return nil + case .int: return Int(result.int32Value) + case .string: return result.stringValue + case .bool: return result.int32Value != 0 ? true : false + case .list: return result.stringValue?.components(separatedBy: .newlines) + case .record: return result.stringValue?.parseSimpleAppleScriptRecord() + case .json: return result.stringValue?.parseJSONStringFromAppleScript() + case .wildCard: return AppleScriptValue(descriptor: result) + case .none: return nil } } @@ -116,15 +120,26 @@ public class AppleScriptBridge: NSObject { /// - Throws: /// - `AppleScriptError.executionError` if the process fails to launch or run. /// - `AppleScriptError.failedToReadOutput` if the output cannot be interpreted. + /// - `AppleScriptError.unsupportedReturnType` if the script declares `.wildCard`. /// /// - Note: For `.list` and `.record`, raw strings are returned and may need further parsing. /// + /// - Note: `.wildCard` is not available here. `osascript` prints its result as text, so the descriptor that + /// carries the actual type is gone before this method can read anything, and no honest value can be built + /// from what arrives. The script is not run at all in that case. Use `executeAppleScript(_:with:)` instead. + /// /// - 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? { + // Checked before the process is launched, so a script declaring a type this method cannot produce has + // no chance to act on the target application first. + if case .wildCard = AppleScript.returnType { + throw AppleScriptError.unsupportedReturnType(.wildCard) + } + let process = Process() let pipe = Pipe() @@ -151,13 +166,14 @@ public class AppleScriptBridge: NSObject { } switch AppleScript.returnType { - case .int: return Int(result) - case .string: return result - 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() - case .none: return nil + case .int: return Int(result) + case .string: return result + 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() + case .wildCard: return nil // Unreachable: rejected above, before the script ran. + case .none: return nil } } diff --git a/Sources/SwiftAppleScriptBridge/AppleScriptError.swift b/Sources/SwiftAppleScriptBridge/AppleScriptError.swift index 7304ba2..45e6a6a 100644 --- a/Sources/SwiftAppleScriptBridge/AppleScriptError.swift +++ b/Sources/SwiftAppleScriptBridge/AppleScriptError.swift @@ -19,6 +19,7 @@ extension AppleScriptBridge { /// - `failedToInitScript`: The script could not be initialized or compiled. /// - `failedToReadOutput`: The script executed, but its output could not be read or parsed. /// - `executionError(String)`: The script failed to execute properly, with an error message. + /// - `unsupportedReturnType(AppleScriptReturnType)`: The script declared a return type the execution method cannot produce. /// - `genericMessage(String)`: A catch-all error with a custom message. /// /// - Property: @@ -32,15 +33,17 @@ extension AppleScriptBridge { case failedToInitScript case failedToReadOutput case executionError(String) + case unsupportedReturnType(AppleScriptReturnType) case genericMessage(String) /// A human-readable, English explanation of the error, suitable for logging or developer-facing display. public var description: String { switch self { - case .failedToInitScript: return "Failed to initialize AppleScript script." - case .failedToReadOutput: return "Failed to read AppleScript script result." - case .executionError(let execErrorMessage): return "AppleScript execution error:\n" + execErrorMessage - case .genericMessage(let genericMessage): return "\(genericMessage)" + case .failedToInitScript: return "Failed to initialize AppleScript script." + case .failedToReadOutput: return "Failed to read AppleScript script result." + case .executionError(let execErrorMessage): return "AppleScript execution error:\n" + execErrorMessage + case .unsupportedReturnType(let returnType): return "The .\(returnType) return type is not supported by this execution method." + case .genericMessage(let genericMessage): return "\(genericMessage)" } } } diff --git a/Sources/SwiftAppleScriptBridge/AppleScriptObject.swift b/Sources/SwiftAppleScriptBridge/AppleScriptObject.swift index 045d81a..d99a72b 100644 --- a/Sources/SwiftAppleScriptBridge/AppleScriptObject.swift +++ b/Sources/SwiftAppleScriptBridge/AppleScriptObject.swift @@ -20,7 +20,13 @@ extension AppleScriptBridge { /// - `list`: A list of strings, typically separated by carriage returns in AppleScript. /// - `record`: A simple AppleScript record parsed into a Swift dictionary (limited to flat key-value pairs). /// - `json`: A more reliable and flexible format for complex or nested records, returned as a Swift dictionary. + /// - `wildCard`: Whatever the script returned, as an `AppleScriptValue` carrying its actual type rather than a declared one. /// - `none`: No return value is expected or needed. + /// + /// - Note: `.wildCard` is the one case that does not coerce. Use it for the properties that answer with a + /// different type depending on state — a number most of the time, a constant such as `missing value` + /// otherwise — where that type is itself the information. It needs the Apple event descriptor, which only + /// `executeAppleScript(_:with:)` has; `executeAppleScriptViaCommandLine(_:with:)` throws on it. public enum AppleScriptReturnType: Sendable { case int case string @@ -28,6 +34,7 @@ extension AppleScriptBridge { case list case record case json + case wildCard case none } diff --git a/Sources/SwiftAppleScriptBridge/AppleScriptValue.swift b/Sources/SwiftAppleScriptBridge/AppleScriptValue.swift new file mode 100644 index 0000000..39b8057 --- /dev/null +++ b/Sources/SwiftAppleScriptBridge/AppleScriptValue.swift @@ -0,0 +1,213 @@ +// +// AppleScriptValue.swift +// SwiftAppleScriptBridge +// +// Created by Frédéric Simard on 2026-08-29. +// + +import Foundation +import Carbon + +extension AppleScriptBridge { + + // MARK: - APPLESCRIPT VALUES + + /// A script result carrying the type the script actually returned, rather than the one the caller declared. + /// + /// Every other `AppleScriptReturnType` coerces: declare `.int` and an answer of `missing value` becomes `0`, + /// declare `.string` and a number becomes text. That is what you want almost always. Some properties, + /// though, 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` — and there the type is the information. Declaring + /// `.wildCard` returns one of these cases instead, built from what the Apple event descriptor carried. + /// + /// ### Example: + /// ```swift + /// let result = try AppleScriptBridge.executeAppleScript(cellValue) as? AppleScriptBridge.AppleScriptValue + /// + /// switch result { + /// case .double(let number): print("number: \(number)") + /// case .string(let text): print("text: \(text)") + /// case .missingValue: print("the cell is empty") + /// default: break + /// } + /// ``` + /// + /// - Note: `.unknown` carries the descriptor untouched, so a type this enum does not decode is still + /// reachable. Pass it back through `init(descriptor:)` after coercing it yourself, or read it directly. + public enum AppleScriptValue: Equatable { + + /// A floating-point number (`typeIEEE64BitFloatingPoint` and its 32- and 128-bit siblings). + /// + /// AppleScript answers with a real for anything its 32-bit integer cannot hold, so a large whole number + /// arrives here rather than in `.int`. Overflow yields `Double.infinity`, which is a real too. + case double(Double) + + /// An integer (`typeSInt32` and the other signed and unsigned integer types). + case int(Int) + + /// Text (`typeUnicodeText` and the other text types). + case string(String) + + /// A boolean (`typeBoolean`, `typeTrue`, `typeFalse`). + case bool(Bool) + + /// A date (`typeLongDateTime`). + case date(Date) + + /// A file (`typeFileURL`, `typeAlias`, `typeBookmarkData`), as the URL it resolves to. + case fileURL(URL) + + /// AppleScript's `missing value`, which arrives as `typeType` carrying `cMissingValue`. + case missingValue + + /// A constant (`typeEnumerated`, `typeType`, `typeProperty`, `typeKeyword`), as its raw four-character code. + /// + /// There is no general mapping from a code to a meaning: what `'autp'` means is defined by the + /// application's dictionary, not by AppleScript. The code is therefore handed over as it is, for the + /// caller to compare against the codes its target application documents — `String`'s + /// `appleScriptFourCharCode` turns `"autp"` into one. + case constant(FourCharCode) + + /// A list (`typeAEList`), decoded item by item. + case list([AppleScriptValue]) + + /// A record (`typeAERecord`), decoded field by field. + /// + /// Keys the caller wrote in the script come through by name. Keys AppleScript recognizes as its own + /// terminology are compiled to four-character codes before the script ever runs, and come through as + /// that code's text: `{name:"Roger"}` decodes as `["pnam": .string("Roger")]`, not as `["name": …]`. + /// The same applies to the properties an application returns, which are terminology throughout. + case record([String: AppleScriptValue]) + + /// A descriptor of a type this enum does not decode, preserved as it arrived. + case unknown(NSAppleEventDescriptor) + } +} + +// MARK: - DECODING DESCRIPTORS + +extension AppleScriptBridge.AppleScriptValue { + + /// Builds a value from an Apple event descriptor, recursively. + /// + /// The descriptor's own `descriptorType` decides the case, so nothing is coerced: a script answering + /// `missing value` yields `.missingValue`, not `0` or `""`. Lists and records are decoded eagerly, their + /// items going back through this initializer, so nesting works to any depth. A descriptor whose type is not + /// handled — or one that is, but whose value cannot be read — yields `.unknown`, carrying the descriptor + /// itself so nothing is lost. + /// + /// - Parameter descriptor: The descriptor to decode, as returned by `NSAppleScript.executeAndReturnError`. + public init(descriptor: NSAppleEventDescriptor) { + switch descriptor.descriptorType { + case typeIEEE32BitFloatingPoint, typeIEEE64BitFloatingPoint, type128BitFloatingPoint: + self = .double(descriptor.doubleValue) + + case typeSInt16, typeUInt16, typeSInt32, typeUInt32, typeSInt64, typeUInt64: + self = Self.integer(from: descriptor).map { .int($0) } ?? .unknown(descriptor) + + case typeBoolean, typeTrue, typeFalse: + self = .bool(descriptor.booleanValue) + + case typeUnicodeText, typeUTF8Text, typeUTF16ExternalRepresentation, typeChar, typeCString: + self = descriptor.stringValue.map { .string($0) } ?? .unknown(descriptor) + + case typeLongDateTime: + self = descriptor.dateValue.map { .date($0) } ?? .unknown(descriptor) + + case typeFileURL, typeAlias, typeBookmarkData: + self = descriptor.fileURLValue.map { .fileURL($0) } ?? .unknown(descriptor) + + case typeType, typeEnumerated, typeProperty, typeKeyword: + // `enumCodeValue` and `typeCodeValue` read the same four bytes; either serves all four types. + let code = descriptor.typeCodeValue + self = code == FourCharCode(cMissingValue) ? .missingValue : .constant(code) + + case typeAEList: + self = .list(Self.items(of: descriptor).map { AppleScriptBridge.AppleScriptValue(descriptor: $0) }) + + case typeAERecord: + self = .record(Self.fields(of: descriptor)) + + default: + self = .unknown(descriptor) + } + } + + /// Reads an integer descriptor as an `Int`, whatever its width and signedness. + /// + /// `int32Value` coerces to `typeSInt32` and answers `0` when the value does not fit, which turns a + /// `typeSInt64` or a large `typeUInt32` into a silent zero. The bytes are therefore decoded by the width + /// the descriptor's own type declares. They are laid out in the host's byte order, since the descriptor + /// was built in this process. + /// + /// - Parameter descriptor: The integer descriptor to read. + /// - Returns: The value as an `Int`, or `nil` if the data is short or the value is too large for an `Int`, + /// in which case the caller falls back to `.unknown` rather than reporting a wrong number. + private static func integer(from descriptor: NSAppleEventDescriptor) -> Int? { + let data = descriptor.data + + func value(_ type: Integer.Type) -> Int? { + guard data.count >= MemoryLayout.size else { return nil } + return Int(exactly: data.withUnsafeBytes { $0.loadUnaligned(as: Integer.self) }) + } + + switch descriptor.descriptorType { + case typeSInt16 : return value(Int16.self) + case typeUInt16 : return value(UInt16.self) + case typeSInt32 : return value(Int32.self) + case typeUInt32 : return value(UInt32.self) + case typeSInt64 : return value(Int64.self) + case typeUInt64 : return value(UInt64.self) + default : return nil + } + } + + /// Returns the items of a list descriptor, in order. + /// + /// Apple event lists are indexed from 1, and `numberOfItems` is signed, so the range is built defensively + /// rather than from `1...numberOfItems`, which would trap on an empty list. + /// + /// - Parameter descriptor: The list descriptor to read. + /// - Returns: Its item descriptors, skipping any index that answers `nil`. + private static func items(of descriptor: NSAppleEventDescriptor) -> [NSAppleEventDescriptor] { + (0.. [String: AppleScriptBridge.AppleScriptValue] { + var fields: [String: AppleScriptBridge.AppleScriptValue] = [:] + var userFields: NSAppleEventDescriptor? + + for index in 0.. `"msng"`). + /// + /// Codes are four bytes read as MacRoman, which maps every possible byte, so the conversion always succeeds. + /// + /// - Parameter appleScriptFourCharCode: The code to render, as carried by `AppleScriptValue.constant`. + public init(appleScriptFourCharCode code: FourCharCode) { + let bytes = (0..<4).map { UInt8(truncatingIfNeeded: code >> (8 * (3 - $0))) } + self = String(bytes: bytes, encoding: .macOSRoman) ?? "" + } + + /// The Apple event four-character code this text represents (e.g., `"msng"` -> `'msng'`). + /// + /// Use it to compare an `AppleScriptValue.constant` against a code from an application's dictionary: + /// `if case .constant("autp".appleScriptFourCharCode) = value`. + /// + /// - Returns: The code, or `nil` unless the text is exactly four MacRoman-encodable characters. + public var appleScriptFourCharCode: FourCharCode? { + guard let bytes = data(using: .macOSRoman), bytes.count == 4 else { return nil } + return bytes.reduce(0) { $0 << 8 | FourCharCode($1) } + } +} From f18e7a9053d8811a792d18d04f49c1b749922b3a Mon Sep 17 00:00:00 2001 From: fredsimard Date: Sat, 29 Aug 2026 21:35:21 -0400 Subject: [PATCH 2/5] Test the wildcard decoding against descriptors and real scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two layers, both permission-free. The first builds descriptors directly and covers what cannot be produced from AppleScript source: a 64-bit integer, whose int32Value is asserted to fail so the reason for reading the bytes is recorded; an unsigned value too large for an Int, which has to fall back to the descriptor rather than report a wrong number; an empty list, whose index range would trap if built naively; and a record carrying both a coded key and user fields, including the collision where a user field is named after a code. The second runs self-contained scripts through NSAppleScript. They drive no application, so they need no Automation permission, and they are the only way to check the decoding against what AppleScript actually emits — that {name:"Roger", age:42} really does arrive with name compiled to 'pnam', and that 3000000000 really does arrive as a real rather than an integer. Co-Authored-By: Claude Opus 5 --- .../SwiftAppleScriptBridgeTests.swift | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/Tests/SwiftAppleScriptBridgeTests/SwiftAppleScriptBridgeTests.swift b/Tests/SwiftAppleScriptBridgeTests/SwiftAppleScriptBridgeTests.swift index 2cd7b40..f58d2f6 100644 --- a/Tests/SwiftAppleScriptBridgeTests/SwiftAppleScriptBridgeTests.swift +++ b/Tests/SwiftAppleScriptBridgeTests/SwiftAppleScriptBridgeTests.swift @@ -9,6 +9,7 @@ // import XCTest +import Carbon @testable import SwiftAppleScriptBridge // MARK: - ESCAPING @@ -268,6 +269,158 @@ final class JSONParsingTests: XCTestCase { } } +// MARK: - WILDCARD VALUES + +final class WildCardValueTests: XCTestCase { + + private typealias Value = AppleScriptBridge.AppleScriptValue + + /// Verifies that the scalar types come back as themselves, rather than coerced into a declared type. + func testScalarDescriptorsKeepTheirType() { + XCTAssertEqual(Value(descriptor: NSAppleEventDescriptor(double: 1.5)), .double(1.5)) + XCTAssertEqual(Value(descriptor: NSAppleEventDescriptor(int32: 42)), .int(42)) + XCTAssertEqual(Value(descriptor: NSAppleEventDescriptor(string: "hello")), .string("hello")) + XCTAssertEqual(Value(descriptor: NSAppleEventDescriptor(boolean: true)), .bool(true)) + XCTAssertEqual(Value(descriptor: NSAppleEventDescriptor(boolean: false)), .bool(false)) + } + + /// Verifies that a date and a file arrive as `Date` and `URL`, not as the text they would coerce to. + func testDatesAndFilesAreDecoded() { + // Apple event dates carry whole seconds, so the reference date keeps the comparison exact. + let date = Date(timeIntervalSinceReferenceDate: 0) + XCTAssertEqual(Value(descriptor: NSAppleEventDescriptor(date: date)), .date(date)) + + let url = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + XCTAssertEqual(Value(descriptor: NSAppleEventDescriptor(fileURL: url)), .fileURL(url)) + } + + /// Verifies that a 64-bit integer is read from its own bytes, rather than coerced to 32 bits and zeroed. + func testWideIntegersAreReadAtTheirOwnWidth() throws { + var wide = Int64(9_000_000_000) + let descriptor = try XCTUnwrap( + NSAppleEventDescriptor(descriptorType: typeSInt64, bytes: &wide, length: MemoryLayout.size) + ) + XCTAssertEqual(descriptor.int32Value, 0, "int32Value fails on this descriptor; that is what the decoding works around") + XCTAssertEqual(Value(descriptor: descriptor), .int(9_000_000_000)) + } + + /// Verifies that a value too large for an `Int` yields the descriptor rather than a wrong number. + func testUnrepresentableIntegerFallsBackToTheDescriptor() throws { + var huge = UInt64.max + let descriptor = try XCTUnwrap( + NSAppleEventDescriptor(descriptorType: typeUInt64, bytes: &huge, length: MemoryLayout.size) + ) + XCTAssertEqual(Value(descriptor: descriptor), .unknown(descriptor)) + } + + /// Verifies that `missing value` is recognized, rather than handed over as a bare four-character code. + func testMissingValueIsItsOwnCase() { + XCTAssertEqual(Value(descriptor: NSAppleEventDescriptor(typeCode: FourCharCode(cMissingValue))), .missingValue) + } + + /// Verifies that an application-defined constant keeps its raw code, which is all the caller can compare. + func testConstantsCarryTheirFourCharacterCode() throws { + let code = try XCTUnwrap("autp".appleScriptFourCharCode) + XCTAssertEqual(Value(descriptor: NSAppleEventDescriptor(enumCode: code)), .constant(code)) + XCTAssertEqual(Value(descriptor: NSAppleEventDescriptor(typeCode: code)), .constant(code)) + } + + /// Verifies that a list is decoded item by item, and that nesting works. + func testListsAreDecodedRecursively() { + let inner = NSAppleEventDescriptor.list() + inner.insert(NSAppleEventDescriptor(string: "x"), at: 0) + + let list = NSAppleEventDescriptor.list() + list.insert(NSAppleEventDescriptor(double: 1.5), at: 0) + list.insert(NSAppleEventDescriptor(typeCode: FourCharCode(cMissingValue)), at: 0) + list.insert(inner, at: 0) + + XCTAssertEqual(Value(descriptor: list), .list([.double(1.5), .missingValue, .list([.string("x")])])) + } + + /// Verifies that an empty list yields an empty list rather than trapping on its index range. + func testEmptyListIsDecoded() { + XCTAssertEqual(Value(descriptor: NSAppleEventDescriptor.list()), .list([])) + } + + /// Verifies that both halves of a record are decoded: the keys AppleScript compiled to four-character + /// codes, and the user-defined ones it packed into `keyASUserRecordFields`. + func testRecordsDecodeCodedAndUserKeys() throws { + let userFields = NSAppleEventDescriptor.list() + userFields.insert(NSAppleEventDescriptor(string: "age"), at: 0) + userFields.insert(NSAppleEventDescriptor(int32: 42), at: 0) + + let record = NSAppleEventDescriptor.record() + record.setDescriptor(NSAppleEventDescriptor(string: "Roger"), forKeyword: try XCTUnwrap("pnam".appleScriptFourCharCode)) + record.setDescriptor(userFields, forKeyword: FourCharCode(keyASUserRecordFields)) + + XCTAssertEqual(Value(descriptor: record), .record(["pnam": .string("Roger"), "age": .int(42)])) + } + + /// Verifies that a user-defined key wins over a coded one of the same text, since it is the name the + /// caller wrote and therefore the one they will look for. + func testUserKeysWinOverCodedKeys() throws { + let userFields = NSAppleEventDescriptor.list() + userFields.insert(NSAppleEventDescriptor(string: "pnam"), at: 0) + userFields.insert(NSAppleEventDescriptor(string: "user"), at: 0) + + let record = NSAppleEventDescriptor.record() + record.setDescriptor(NSAppleEventDescriptor(string: "coded"), forKeyword: try XCTUnwrap("pnam".appleScriptFourCharCode)) + record.setDescriptor(userFields, forKeyword: FourCharCode(keyASUserRecordFields)) + + XCTAssertEqual(Value(descriptor: record), .record(["pnam": .string("user")])) + } + + /// Verifies that an undecoded type is handed over as the descriptor itself, so nothing is lost. + func testUnhandledTypeYieldsTheDescriptor() { + let descriptor = NSAppleEventDescriptor(eventClass: kCoreEventClass, eventID: kAEOpenApplication, targetDescriptor: nil, returnID: 0, transactionID: 0) + XCTAssertEqual(Value(descriptor: descriptor), .unknown(descriptor)) + } +} + +// MARK: - WILDCARD EXECUTION + +final class WildCardExecutionTests: XCTestCase { + + private typealias Value = AppleScriptBridge.AppleScriptValue + + /// Runs a self-contained script through `NSAppleScript`, which drives no application and so needs no + /// Automation permission, and returns what `.wildCard` decoded. + private func wildCardResult(of source: String) throws -> Value? { + let script = AppleScriptBridge.AppleScriptObject(name: "wildCard", returnType: .wildCard, script: source) + return try AppleScriptBridge.executeAppleScript(script) as? Value + } + + /// Verifies that one script returning different types, as several application properties do, is decoded + /// as those types rather than coerced into one. + func testTheSameScriptCanAnswerWithDifferentTypes() throws { + XCTAssertEqual(try wildCardResult(of: "return 1.5"), .double(1.5)) + XCTAssertEqual(try wildCardResult(of: "return 42"), .int(42)) + XCTAssertEqual(try wildCardResult(of: #"return "hello""#), .string("hello")) + XCTAssertEqual(try wildCardResult(of: "return true"), .bool(true)) + XCTAssertEqual(try wildCardResult(of: "return missing value"), .missingValue) + } + + /// Verifies that a whole number too large for AppleScript's 32-bit integer arrives as the real it is, + /// rather than as the zero a `.int` script would report. + func testLargeWholeNumbersArriveAsReals() throws { + XCTAssertEqual(try wildCardResult(of: "return 3000000000"), .double(3_000_000_000)) + } + + /// Verifies that a record written in the script decodes with its user-defined keys, and that a key + /// AppleScript owns as terminology arrives as the four-character code it was compiled to. + func testRecordKeysDecodeAsWrittenOrAsTheirCode() throws { + let result = try wildCardResult(of: #"return {name:"Roger", age:42, ratio:1.5}"#) + XCTAssertEqual(result, .record(["pnam": .string("Roger"), "age": .int(42), "ratio": .double(1.5)])) + } + + /// Verifies that a list keeps one type per item, which is the whole point of the case. + func testListItemsKeepTheirOwnTypes() throws { + let result = try wildCardResult(of: #"return {1, "two", 3.5, missing value}"#) + XCTAssertEqual(result, .list([.int(1), .string("two"), .double(3.5), .missingValue])) + } +} + // MARK: - COMMAND-LINE EXECUTION final class CommandLineExecutionTests: XCTestCase { @@ -296,6 +449,17 @@ final class CommandLineExecutionTests: XCTestCase { ) XCTAssertEqual(try AppleScriptBridge.executeAppleScriptViaCommandLine(script) as? String, "Roger R") } + + /// Verifies that `.wildCard` is refused here, since osascript prints text and the descriptor carrying the + /// actual type never reaches this method. + func testWildCardIsRefused() { + let script = AppleScriptBridge.AppleScriptObject(name: "wildCard", returnType: .wildCard, script: "return 1.5") + XCTAssertThrowsError(try AppleScriptBridge.executeAppleScriptViaCommandLine(script)) { error in + guard case AppleScriptBridge.AppleScriptError.unsupportedReturnType(.wildCard) = error else { + return XCTFail("Expected .unsupportedReturnType(.wildCard), got \(error)") + } + } + } } // MARK: - PATH CONVERSION From 51f1b54ae5ae6860dbea8697f1b15be7749a14c2 Mon Sep 17 00:00:00 2001 From: fredsimard Date: Sat, 29 Aug 2026 21:35:30 -0400 Subject: [PATCH 3/5] Document the wildcard return type The README gets the Numbers cell example the request came from, a table of what each AppleScriptValue case carries, and the three things that surprise: constants arrive as raw codes, record keys arrive as written only when AppleScript did not claim them as terminology, and the command-line method refuses the type outright. API.md gets a section of its own, mapping each case to the descriptor types it decodes. The example file gets a Finder script, so it stays runnable on any Mac without extra software: a disk's format is an edfm enumeration, and its constants are compared at the call site against the codes Finder's dictionary documents, which is what working with .constant looks like. Every snippet in both documents was compiled against the package before being written down. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 15 ++++++- Documentation/API.md | 80 ++++++++++++++++++++++++++++++++++++- Examples/AppleScripts.swift | 55 +++++++++++++++++++++++++ README.md | 44 ++++++++++++++++++++ 4 files changed, 191 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24f941d..7092e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] -Nothing yet. +### 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. +- `AppleScriptError` gained a case. Code that switches over it exhaustively has to handle the new one. ## [1.1.0] — 2026-08-19 diff --git a/Documentation/API.md b/Documentation/API.md index 318e340..ca0e02d 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -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 @@ -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** @@ -82,7 +83,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 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** @@ -90,6 +91,7 @@ Runs the same script through `/usr/bin/osascript` in a subprocess instead. Usefu |---|---| | `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. @@ -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 @@ -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` @@ -248,6 +307,7 @@ public enum AppleScriptError: Error { case failedToInitScript case failedToReadOutput case executionError(String) + case unsupportedReturnType(AppleScriptReturnType) case genericMessage(String) } ``` @@ -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` @@ -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 diff --git a/Examples/AppleScripts.swift b/Examples/AppleScripts.swift index 504ff6a..124f35f 100644 --- a/Examples/AppleScripts.swift +++ b/Examples/AppleScripts.swift @@ -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. @@ -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 diff --git a/README.md b/README.md index 9985f7e..23cab8b 100644 --- a/README.md +++ b/README.md @@ -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. From b5715d0e724dddd33082858925f219fb89d760be Mon Sep 17 00:00:00 2001 From: fredsimard Date: Sat, 29 Aug 2026 21:36:52 -0400 Subject: [PATCH 4/5] Cut 1.2.0 in the changelog A minor bump rather than a patch: the release adds public API. Consumers switching exhaustively over AppleScriptError or AppleScriptReturnType have a new case to handle, which the release notes call out. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7092e04..adf881c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +Nothing yet. + +## [1.2.0] — 2026-08-29 + ### 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). @@ -83,7 +87,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 From 4d8552ac60508ec2c6e3a714b8328a1f78d7868c Mon Sep 17 00:00:00 2001 From: fredsimard Date: Sat, 29 Aug 2026 21:43:41 -0400 Subject: [PATCH 5/5] Warn about the added enum cases up front The caveat was the last bullet of the release's notes and named only AppleScriptError. It now opens the release, where an upgrader reads it before anything else, and names both enums that gained a case. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adf881c..dd2f176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ 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). @@ -23,7 +25,6 @@ Nothing yet. ### 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. -- `AppleScriptError` gained a case. Code that switches over it exhaustively has to handle the new one. ## [1.1.0] — 2026-08-19