diff --git a/Packages/CmuxFoundation/Package.swift b/Packages/CmuxFoundation/Package.swift new file mode 100644 index 000000000000..35bcc7161c28 --- /dev/null +++ b/Packages/CmuxFoundation/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxFoundation", + platforms: [ + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxFoundation", + targets: ["CmuxFoundation"] + ), + ], + targets: [ + .target( + name: "CmuxFoundation", + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxFoundationTests", + dependencies: ["CmuxFoundation"] + ), + ] +) diff --git a/Packages/CmuxFoundation/README.md b/Packages/CmuxFoundation/README.md new file mode 100644 index 000000000000..a72f8e6a4743 --- /dev/null +++ b/Packages/CmuxFoundation/README.md @@ -0,0 +1,38 @@ +# CmuxFoundation + +Shared low-level primitives for cmux with no internal package dependencies. This is the +bottom of the package dependency graph: encoding/text helpers, value types, and other +cross-cutting utilities that several domains need, with nothing in here depending on AppKit, +SwiftUI, or another cmux package. + +It exists as the leaf every other package and the app target can depend on without creating +a cycle. Keep it dependency-free. + +Foundation helpers are exposed as extensions on existing types rather than free functions, +so call sites read naturally (`value.javaScriptStringLiteral`, not `f(value)`). + +## Contents + +- `String.javaScriptStringLiteral` — the string encoded as a quoted JavaScript string literal. + +## Usage + +```swift +import CmuxFoundation + +let literal = userText?.javaScriptStringLiteral ?? "null" +webView.evaluateJavaScript("setValue(\(literal))") +``` + +## Testing + +Everything here is a pure value transform, so tests need no app, no AppKit, and no filesystem: + +```swift +import Testing +import CmuxFoundation + +@Test func plainStringIsQuoted() { + #expect("hello".javaScriptStringLiteral == "\"hello\"") +} +``` diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/String+JavaScriptStringLiteral.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/String+JavaScriptStringLiteral.swift new file mode 100644 index 000000000000..d3b1c07feb3c --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/String+JavaScriptStringLiteral.swift @@ -0,0 +1,26 @@ +import Foundation + +public extension String { + /// This string encoded as a quoted JavaScript string literal, ready to splice into JS source. + /// + /// The value includes the surrounding double quotes and escapes any characters (quotes, + /// backslashes, control characters) that would otherwise break out of the literal. Encoding + /// goes through `JSONSerialization`, so the result is also a valid JSON string. + /// + /// ```swift + /// "a\"b".javaScriptStringLiteral // -> "\"a\\\"b\"" + /// webView.evaluateJavaScript("setValue(\(id?.javaScriptStringLiteral ?? "null"))") + /// ``` + /// + /// - Returns: The quoted JS string literal, or `nil` if the string cannot be encoded. + var javaScriptStringLiteral: String? { + // [self] is always valid JSON; reuse JSON's escaping and drop the array brackets to + // get a quoted JS string literal. + guard let data = try? JSONSerialization.data(withJSONObject: [self]), + let arrayLiteral = String(data: data, encoding: .utf8), + arrayLiteral.count >= 2 else { + return nil + } + return String(arrayLiteral.dropFirst().dropLast()) + } +} diff --git a/Packages/CmuxFoundation/Tests/CmuxFoundationTests/String+JavaScriptStringLiteralTests.swift b/Packages/CmuxFoundation/Tests/CmuxFoundationTests/String+JavaScriptStringLiteralTests.swift new file mode 100644 index 000000000000..994fb492ae45 --- /dev/null +++ b/Packages/CmuxFoundation/Tests/CmuxFoundationTests/String+JavaScriptStringLiteralTests.swift @@ -0,0 +1,25 @@ +import Testing + +import CmuxFoundation + +@Suite struct StringJavaScriptStringLiteralTests { + @Test func plainStringIsQuoted() { + #expect("hello".javaScriptStringLiteral == "\"hello\"") + } + + @Test func emptyStringIsEmptyQuotes() { + #expect("".javaScriptStringLiteral == "\"\"") + } + + @Test func escapesDoubleQuotesAndBackslashes() { + #expect(#"a"b\c"#.javaScriptStringLiteral == #""a\"b\\c""#) + } + + @Test func escapesNewlines() { + #expect("line1\nline2".javaScriptStringLiteral == #""line1\nline2""#) + } + + @Test func preservesUnicode() { + #expect("café".javaScriptStringLiteral == "\"café\"") + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e64a408726c7..6e4aa2025f27 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9,17 +9,7 @@ import WebKit import Combine import ObjectiveC.runtime import Darwin - -func cmuxJavaScriptStringLiteral(_ value: String?) -> String? { - guard let value else { return nil } - // Serialize as a JSON array, then strip the outer brackets to get a quoted JS string literal. - guard let data = try? JSONSerialization.data(withJSONObject: [value]), - let arrayLiteral = String(data: data, encoding: .utf8), - arrayLiteral.count >= 2 else { - return nil - } - return String(arrayLiteral.dropFirst().dropLast()) -} +import CmuxFoundation private struct MultiWindowRouteCLIResult { let status: String @@ -10524,7 +10514,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent awaitingInputId: String? = nil, completion: @escaping ([String: String]) -> Void ) { - let expectedInputIdLiteral = cmuxJavaScriptStringLiteral(awaitingInputId) ?? "null" + let expectedInputIdLiteral = awaitingInputId?.javaScriptStringLiteral ?? "null" let script = """ (() => { const expectedInputId = \(expectedInputIdLiteral); diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 29e65084c744..9264eb69735e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -140,6 +140,7 @@ E7E000000000000000000001 /* CmuxEventStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E000000000000000000002 /* CmuxEventStream.swift */; }; C0DE46000000000000000001 /* CMUXExtensionClient in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE46000000000000000002 /* CMUXExtensionClient */; }; C0DE45000000000000000001 /* CmuxExtensionKit in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE45000000000000000002 /* CmuxExtensionKit */; }; + CF00F00000000000000000A3 /* CmuxFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CF00F00000000000000000A2 /* CmuxFoundation */; }; C0DE34020000000000000001 /* CmuxHelpCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE34020000000000000003 /* CmuxHelpCommands.swift */; }; C0DE34020000000000000002 /* CmuxHelpResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE34020000000000000004 /* CmuxHelpResource.swift */; }; C0DE46010000000000000001 /* CMUXInstalledExtensionSidebarHostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE46010000000000000002 /* CMUXInstalledExtensionSidebarHostView.swift */; }; @@ -1216,6 +1217,7 @@ 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */, C0DE46000000000000000001 /* CMUXExtensionClient in Frameworks */, C0DE45000000000000000001 /* CmuxExtensionKit in Frameworks */, + CF00F00000000000000000A3 /* CmuxFoundation in Frameworks */, 3069F1D10000000000000005 /* CMUXPasteboardFidelity in Frameworks */, A5C0DE0000000000000000A3 /* CMUXProjectModel in Frameworks */, CD0CFE5300000000CD0CFE53 /* CmuxSettings in Frameworks */, @@ -1935,6 +1937,7 @@ A500D013A1B2C3D4E5F60718 /* CMUXDebugLog */, CD0CFE5200000000CD0CFE52 /* CmuxSettings */, CD0CFE5500000000CD0CFE55 /* CmuxSettingsUI */, + CF00F00000000000000000A2 /* CmuxFoundation */, A8BD195031FC4B82B4354297 /* StackAuth */, ); productName = cmux; @@ -2072,6 +2075,7 @@ A5354304A5354304A5354304 /* XCLocalSwiftPackageReference "CMUXSocketPathDomain" */, CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */, CD0CFE5400000000CD0CFE54 /* XCLocalSwiftPackageReference "CmuxSettingsUI" */, + CF00F00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxFoundation" */, 28B798BB9086C8E6B60C3355 /* XCLocalSwiftPackageReference "stack-auth-swift-sdk-prerelease" */, ); productRefGroup = A5001042 /* Products */; @@ -3198,6 +3202,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/CmuxSettingsUI; }; + CF00F00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxFoundation" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/CmuxFoundation; + }; A5001260 /* XCLocalSwiftPackageReference "bonsplit" */ = { isa = XCLocalSwiftPackageReference; relativePath = vendor/bonsplit; @@ -3300,6 +3308,11 @@ package = CD0CFE5400000000CD0CFE54 /* XCLocalSwiftPackageReference "CmuxSettingsUI" */; productName = CmuxSettingsUI; }; + CF00F00000000000000000A2 /* CmuxFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = CF00F00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxFoundation" */; + productName = CmuxFoundation; + }; A5001231 /* Sparkle */ = { isa = XCSwiftPackageProductDependency; package = A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */; diff --git a/cmux.xcworkspace/contents.xcworkspacedata b/cmux.xcworkspace/contents.xcworkspacedata index 2ad47a26aa42..559dcbd02b42 100644 --- a/cmux.xcworkspace/contents.xcworkspacedata +++ b/cmux.xcworkspace/contents.xcworkspacedata @@ -25,6 +25,9 @@ + +