diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f9ef84..038baea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 1010584..fbcd11d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ Packages/ xcuserdata/ DerivedData/ .netrc +CODE_REVIEW.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b49268..3542553 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] -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 @@ -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 @@ -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 diff --git a/Documentation/API.md b/Documentation/API.md index 7a8c9a1..d04fbd8 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -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** @@ -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 @@ -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 | @@ -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()` @@ -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. diff --git a/README.md b/README.md index 0d5ec09..70007bc 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/Sources/SwiftAppleScriptBridge/AppleScriptBridge.swift b/Sources/SwiftAppleScriptBridge/AppleScriptBridge.swift index aae341a..0308721 100644 --- a/Sources/SwiftAppleScriptBridge/AppleScriptBridge.swift +++ b/Sources/SwiftAppleScriptBridge/AppleScriptBridge.swift @@ -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. /// @@ -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. @@ -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() @@ -131,14 +135,17 @@ 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 } @@ -146,7 +153,7 @@ public class AppleScriptBridge: NSObject { 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() @@ -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. @@ -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 } } @@ -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 } } diff --git a/Sources/SwiftAppleScriptBridge/AppleScriptObject.swift b/Sources/SwiftAppleScriptBridge/AppleScriptObject.swift index 9481f0f..1ea6feb 100644 --- a/Sources/SwiftAppleScriptBridge/AppleScriptObject.swift +++ b/Sources/SwiftAppleScriptBridge/AppleScriptObject.swift @@ -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: @@ -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 @@ -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 @@ -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. diff --git a/Sources/SwiftAppleScriptBridge/StringExtensions.swift b/Sources/SwiftAppleScriptBridge/StringExtensions.swift index a998e90..dd671a1 100644 --- a/Sources/SwiftAppleScriptBridge/StringExtensions.swift +++ b/Sources/SwiftAppleScriptBridge/StringExtensions.swift @@ -26,15 +26,27 @@ extension String { /// Converts a POSIX path to an HFS-style path (e.g., `/Users/roger/Desktop` -> `Macintosh HD:Users:roger:Desktop`). /// - /// - Returns: An HFS-style path, or `nil` if the startup disk name cannot be read. + /// An HFS path is volume-relative: it starts with the name of the volume holding the file, not with the + /// startup disk. Both the volume name and the mount point are read from the file itself, so a path on an + /// external drive, a disk image or a network share converts correctly (`/Volumes/Backup/notes.txt` -> + /// `Backup:notes.txt`). + /// + /// - Returns: An HFS-style path, or `nil` if the volume cannot be determined. + /// + /// - Important: The path must exist. Volume information is read from the file system, so this returns + /// `nil` for a file that has not been created yet. /// /// - Note: Recent versions of most scriptable applications expect POSIX paths, which are better passed as /// `POSIX file` specifiers. This is kept for the applications and script dialects that still want HFS paths. public func toHFSPath() -> String? { let posixURL = URL(fileURLWithPath: self) - guard let volumeName = startupDiskName() else { return nil } - let hfsPath = ([volumeName] + posixURL.pathComponents.dropFirst()).joined(separator: ":") - return hfsPath + guard let values = try? posixURL.resourceValues(forKeys: [.volumeNameKey, .volumeURLKey]), + let volumeName = values.volumeName, + let volumeURL = values.volume else { return nil } + + // Drop the mount point's own components, so what remains is the path within the volume. + let relativeComponents = posixURL.pathComponents.dropFirst(volumeURL.pathComponents.count) + return ([volumeName] + relativeComponents).joined(separator: ":") } } @@ -44,27 +56,14 @@ extension URL { /// /// Delegates to `String.toHFSPath()` so both conversions cannot drift apart. /// - /// - Returns: An HFS-style path, or `nil` if the startup disk name cannot be read. + /// - Returns: An HFS-style path, or `nil` if the volume cannot be determined. + /// + /// - Important: The path must exist; see `String.toHFSPath()`. public func toHFSPath() -> String? { return self.path(percentEncoded: false).toHFSPath() } } -/// Retrieves the name of the macOS startup disk. -/// -/// Reads the volume name straight from the root URL's resource values, which needs no subprocess and no -/// Automation permission. -/// -/// - Returns: The name of the startup disk as a `String`, or `nil` if it cannot be read. -func startupDiskName() -> String? { - do { - return try URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName - } catch { - AppleScriptBridge.log("Error retrieving startup disk name: \(error)") - return nil - } -} - // MARK: - ESCAPING extension String { diff --git a/Tests/SwiftAppleScriptBridgeTests/SwiftAppleScriptBridgeTests.swift b/Tests/SwiftAppleScriptBridgeTests/SwiftAppleScriptBridgeTests.swift index 3ef7721..f05ceea 100644 --- a/Tests/SwiftAppleScriptBridgeTests/SwiftAppleScriptBridgeTests.swift +++ b/Tests/SwiftAppleScriptBridgeTests/SwiftAppleScriptBridgeTests.swift @@ -4,7 +4,8 @@ // // Tests cover the pure, deterministic parts of the bridge: escaping, placeholder substitution and result // parsing. Script execution itself is not tested, since it needs a scriptable application and a -// user-granted Automation permission. +// user-granted Automation permission. The one exception is `CommandLineExecutionTests`, which runs +// self-contained scripts through `osascript`: they drive no application, so they need no permission. // import XCTest @@ -85,6 +86,39 @@ final class PreparedScriptTests: XCTestCase { XCTAssertEqual(script.preparedScript(with: ["n": 2]), "2") } + /// Verifies that a substituted value is not itself scanned for placeholders. + func testSubstitutedValueIsNotRescanned() { + let script = AppleScriptBridge.AppleScriptObject( + name: "test", + variables: ["folderPath": "$name.txt", "name": "Roger"], + script: #"set p to "$folderPath""# + ) + XCTAssertEqual(script.preparedScript(), #"set p to "$name.txt""#) + } + + /// Verifies that a number arriving as `NSNumber` is rendered as its value, not as a boolean. + func testNSNumberIsNotRenderedAsBoolean() { + let script = AppleScriptBridge.AppleScriptObject( + name: "test", + variables: ["count": NSNumber(value: 1), "flag": NSNumber(value: true)], + script: "$count $flag" + ) + XCTAssertEqual(script.preparedScript(), "1 true") + } + + /// Verifies the same for numbers coming out of `JSONSerialization`, the common source of `NSNumber`. + func testJSONNumbersKeepTheirType() throws { + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(#"{"count":1,"flag":true,"ratio":1.5}"#.utf8)) as? [String: Any] + ) + let script = AppleScriptBridge.AppleScriptObject( + name: "test", + variables: json, + script: "$count $flag $ratio" + ) + XCTAssertEqual(script.preparedScript(), "1 true 1.5") + } + /// Verifies that an unforeseen value type is described and escaped, rather than injected as code. func testUnknownTypeFailsSafe() { let script = AppleScriptBridge.AppleScriptObject( @@ -154,21 +188,37 @@ final class JSONParsingTests: XCTestCase { } } +// MARK: - COMMAND-LINE EXECUTION + +final class CommandLineExecutionTests: XCTestCase { + + /// Verifies that a boolean result is read from the text osascript prints, rather than coerced from an + /// integer it never writes. + func testCommandLineBooleanResults() throws { + let yes = AppleScriptBridge.AppleScriptObject(name: "yes", returnType: .bool, script: "return true") + let no = AppleScriptBridge.AppleScriptObject(name: "no", returnType: .bool, script: "return false") + XCTAssertEqual(try AppleScriptBridge.executeAppleScriptViaCommandLine(yes) as? Bool, true) + XCTAssertEqual(try AppleScriptBridge.executeAppleScriptViaCommandLine(no) as? Bool, false) + } +} + // MARK: - PATH CONVERSION final class PathConversionTests: XCTestCase { /// Verifies that an HFS path round-trips to POSIX and back. func testHFSAndPOSIXRoundTrip() throws { - let posix = "/Users/roger/Desktop" + // An existing path: `toHFSPath()` reads the volume name and mount point from the file system. + let posix = NSHomeDirectory() let hfs = try XCTUnwrap(posix.toHFSPath()) - XCTAssertTrue(hfs.hasSuffix(":Users:roger:Desktop")) + let expectedSuffix = ":" + URL(fileURLWithPath: posix).pathComponents.dropFirst().joined(separator: ":") + XCTAssertTrue(hfs.hasSuffix(expectedSuffix), "\(hfs) should end with \(expectedSuffix)") XCTAssertEqual(hfs.toPOSIXPath(), posix) } /// Verifies that the `URL` overload agrees with the `String` one. func testURLOverloadMatchesStringOverload() { - let url = URL(fileURLWithPath: "/Users/roger/Desktop") - XCTAssertEqual(url.toHFSPath(), "/Users/roger/Desktop".toHFSPath()) + let url = URL(fileURLWithPath: NSHomeDirectory()) + XCTAssertEqual(url.toHFSPath(), NSHomeDirectory().toHFSPath()) } }