diff --git a/Scripts/test_release_tooling.py b/Scripts/test_release_tooling.py index 2673cdfc1..135ef133f 100644 --- a/Scripts/test_release_tooling.py +++ b/Scripts/test_release_tooling.py @@ -63,6 +63,17 @@ def test_runtime_signing_policy_matches_release_metadata_and_entitlement_templat policy, ) + def test_info_plist_registers_canonical_ce_url_scheme_only(self) -> None: + info_plist = plistlib.loads((SCRIPT_DIR.parent / "AppBundle" / "Info.plist.template").read_bytes()) + url_types = info_plist.get("CFBundleURLTypes", []) + registered_schemes = [ + scheme + for url_type in url_types + for scheme in url_type.get("CFBundleURLSchemes", []) + ] + + self.assertEqual(registered_schemes, ["repoprompt-ce"]) + def test_local_self_signed_outer_codesign_uses_equals_requirement_argv(self) -> None: package_script = (SCRIPT_DIR / "package_app.sh").read_text(encoding="utf-8") sign_path_body = package_script.split("sign_path(){", 1)[1].split("\n}\nsign_sparkle_framework(){", 1)[0] diff --git a/Sources/RepoPrompt/App/AppDeepLinkRoute.swift b/Sources/RepoPrompt/App/AppDeepLinkRoute.swift index 4355fbd97..53280fcd2 100644 --- a/Sources/RepoPrompt/App/AppDeepLinkRoute.swift +++ b/Sources/RepoPrompt/App/AppDeepLinkRoute.swift @@ -1,5 +1,13 @@ import Foundation +enum AppDeepLinkURLScheme { + static let canonical = "repoprompt-ce" + + static func isSupported(_ scheme: String?) -> Bool { + scheme?.lowercased() == canonical + } +} + struct AgentSessionDeepLinkRoute: Equatable { let windowID: Int? let workspaceID: UUID @@ -31,7 +39,7 @@ struct AgentSessionDeepLinkRoute: Equatable { var url: URL { var components = URLComponents() - components.scheme = "repoprompt" + components.scheme = AppDeepLinkURLScheme.canonical components.host = "agent" components.path = "/session" @@ -47,7 +55,7 @@ struct AgentSessionDeepLinkRoute: Equatable { } components.queryItems = queryItems - return components.url ?? URL(string: "repoprompt://agent/session")! + return components.url ?? URL(string: "\(AppDeepLinkURLScheme.canonical)://agent/session")! } static func parse(notificationUserInfo userInfo: [AnyHashable: Any]) -> AgentSessionDeepLinkRoute? { @@ -83,7 +91,7 @@ struct AgentSessionDeepLinkRoute: Equatable { static func parse(url: URL) -> AgentSessionDeepLinkRoute? { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - components.scheme?.lowercased() == "repoprompt", + AppDeepLinkURLScheme.isSupported(components.scheme), components.host?.lowercased() == "agent", components.path == "/session" else { @@ -177,7 +185,7 @@ enum AppDeepLinkRoute: Equatable { static func parse(url: URL) -> AppDeepLinkURLParseResult { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - components.scheme?.lowercased() == "repoprompt" + AppDeepLinkURLScheme.isSupported(components.scheme) else { return .unsupported } diff --git a/Sources/RepoPrompt/App/AppDeepLinkRouter.swift b/Sources/RepoPrompt/App/AppDeepLinkRouter.swift index d233cc61c..d645d4143 100644 --- a/Sources/RepoPrompt/App/AppDeepLinkRouter.swift +++ b/Sources/RepoPrompt/App/AppDeepLinkRouter.swift @@ -24,7 +24,7 @@ final class AppDeepLinkRouter { case let .route(.legacyURL(legacyURL)): routeLegacyURL(legacyURL, preferredWindow: preferredLegacyWindow) case let .route(.agentSession(route)): - await routeAgentSession(route) + await routeAgentSession(route, sourceURL: url) case .invalidScopedRoute: NSApp.activate(ignoringOtherApps: true) case .unsupported: @@ -51,7 +51,7 @@ final class AppDeepLinkRouter { NSApp.activate(ignoringOtherApps: true) return } - await routeAgentSession(route) + await routeAgentSession(route, sourceURL: nil) } private func routeLegacyURL(_ url: URL, preferredWindow: WindowState?) { @@ -77,9 +77,17 @@ final class AppDeepLinkRouter { } } - private func routeAgentSession(_ route: AgentSessionDeepLinkRoute) async { - NSApp.activate(ignoringOtherApps: true) + private func routeAgentSession(_ route: AgentSessionDeepLinkRoute, sourceURL: URL?) async { let liveWindows = windowStatesManager.allWindows.filter { !$0.isClosing } + if let app = NSApp { + app.activate(ignoringOtherApps: true) + } + guard !liveWindows.isEmpty else { + if let sourceURL { + windowStatesManager.pendingURLs.append(sourceURL) + } + return + } var attemptedWindowIDs = Set() for candidate in Self.agentSessionPreferredExistingWindows(for: route, in: liveWindows) { diff --git a/Sources/RepoPrompt/App/WindowState.swift b/Sources/RepoPrompt/App/WindowState.swift index f83dfa3c2..5778dac67 100644 --- a/Sources/RepoPrompt/App/WindowState.swift +++ b/Sources/RepoPrompt/App/WindowState.swift @@ -1022,7 +1022,7 @@ class WindowState: ObservableObject { func handleIncomingURL(_ url: URL) { guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false), - comps.scheme == "repoprompt" + AppDeepLinkURLScheme.isSupported(comps.scheme) else { return } @@ -1066,7 +1066,7 @@ class WindowState: ObservableObject { return // ← we handled the prompt command } - // Require host == "open" to match repoprompt://open/~/MyProject + // Require host == "open" to match repoprompt-ce://open/~/MyProject guard let host = comps.host?.lowercased(), host == "open" else { return } diff --git a/Sources/RepoPrompt/Features/Settings/Views/AdvancedSettingsView.swift b/Sources/RepoPrompt/Features/Settings/Views/AdvancedSettingsView.swift index e19104c77..3b7cdc704 100644 --- a/Sources/RepoPrompt/Features/Settings/Views/AdvancedSettingsView.swift +++ b/Sources/RepoPrompt/Features/Settings/Views/AdvancedSettingsView.swift @@ -7,13 +7,12 @@ import SwiftUI -/// Slimmed Advanced Settings page. Controls are grouped into two sections -/// ("File System" and "AI Behavior") for progressive-disclosure-friendly -/// browsing. URL scheme details live in the docs site, which is the -/// canonical reference. +/// Slimmed Advanced Settings page. Controls are grouped into focused sections +/// for progressive-disclosure-friendly browsing. /// /// SEARCH-HELPER: Advanced Settings, File System, AI Behavior, Code Maps, -/// gitignore, symlinks, saved prompts, datetime instructions +/// URL Opener, URL scheme, deep links, gitignore, symlinks, saved prompts, +/// datetime instructions /// /// Related: /// - Keyboard Shortcuts: /RepoPrompt/Views/Settings/KeyboardShortcutsSettingsView.swift @@ -33,6 +32,10 @@ struct AdvancedSettingsView: View { ) } + private var canonicalURLPrefix: String { + "\(AppDeepLinkURLScheme.canonical)://" + } + private var respectGitignoreBinding: Binding { Binding( get: { globalSettings.respectGitignore() }, @@ -114,6 +117,11 @@ struct AdvancedSettingsView: View { keyboardShortcutsSection + Divider() + .padding(.horizontal, -16) + + urlOpenerSection + Divider() .padding(.horizontal, -16) @@ -134,7 +142,7 @@ struct AdvancedSettingsView: View { .font(.title2) .fontWeight(.semibold) - Text("File system, AI behavior, and saved-prompts utilities. Use sparingly — most daily settings live in the sections above.") + Text("File system, AI behavior, URL opener, and saved-prompts utilities. Use sparingly — most daily settings live in the sections above.") .font(.subheadline) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -271,6 +279,74 @@ struct AdvancedSettingsView: View { } } + // MARK: - URL Opener + + private var urlOpenerSection: some View { + SettingSection( + title: "URL Opener", + description: "Use RepoPrompt CE links to open folders, select files, seed prompt text, and focus windows from external tools." + ) { + VStack(alignment: .leading, spacing: 12) { + Text("Canonical scheme: \(AppDeepLinkURLScheme.canonical)://") + .font(.subheadline) + .foregroundColor(.secondary) + .textSelection(.enabled) + + VStack(alignment: .leading, spacing: 8) { + urlExampleRow( + title: "Open a folder", + value: "\(canonicalURLPrefix)open//Users/example/Project" + ) + urlExampleRow( + title: "Select files and prompt text", + value: "\(canonicalURLPrefix)open//Users/example/Project?files=Sources/App.swift,README.md&prompt=Review%20the%20selected%20files" + ) + urlExampleRow( + title: "Focus or create an ephemeral workspace", + value: "\(canonicalURLPrefix)open//Users/example/Project?workspace=Review&focus=true&ephemeral=true" + ) + urlExampleRow( + title: "Create a saved prompt", + value: "\(canonicalURLPrefix)prompt?title=Review&content=Review%20the%20current%20selection&focus=true" + ) + } + + Text("Supported opener parameters: workspace, files, prompt, focus, ephemeral, and persist. Use \(canonicalURLPrefix) for external links.") + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + } + } + + private func urlExampleRow(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + + HStack(alignment: .top, spacing: 8) { + Text(value) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(6) + + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } + .buttonStyle(CustomButtonStyle()) + .hoverTooltip("Copy this URL example.") + } + } + } + // MARK: - Saved Prompts private var savedPromptsSection: some View { diff --git a/Sources/RepoPrompt/Features/Settings/Views/SettingsView.swift b/Sources/RepoPrompt/Features/Settings/Views/SettingsView.swift index c14883d5a..85c9b6d64 100644 --- a/Sources/RepoPrompt/Features/Settings/Views/SettingsView.swift +++ b/Sources/RepoPrompt/Features/Settings/Views/SettingsView.swift @@ -689,7 +689,10 @@ enum SettingsTab: String, CaseIterable { "file system", "refresh", "url scheme", - "repoprompt://", + "url opener", + "deep link", + "deep links", + "repoprompt-ce://", "open links", "prompt", "instructions", diff --git a/Tests/RepoPromptTests/App/AppPlatformUtilityRecoveryTests.swift b/Tests/RepoPromptTests/App/AppPlatformUtilityRecoveryTests.swift index 879dd2d3e..0f8065615 100644 --- a/Tests/RepoPromptTests/App/AppPlatformUtilityRecoveryTests.swift +++ b/Tests/RepoPromptTests/App/AppPlatformUtilityRecoveryTests.swift @@ -10,17 +10,65 @@ final class AppPlatformUtilityRecoveryTests: XCTestCase { sessionID: XCTUnwrap(UUID(uuidString: "33333333-3333-3333-3333-333333333333")) ) + XCTAssertEqual(route.url.scheme, AppDeepLinkURLScheme.canonical) XCTAssertEqual(AppDeepLinkRoute.parse(url: route.url), .route(.agentSession(route))) - let missingWorkspace = try XCTUnwrap(URL(string: "repoprompt://agent/session?tab_id=\(route.tabID.uuidString)")) - let malformedSession = try XCTUnwrap(URL(string: "repoprompt://agent/session?workspace_id=\(route.workspaceID.uuidString)&tab_id=\(route.tabID.uuidString)&session_id=not-a-uuid")) - let unsupportedAgentPath = try XCTUnwrap(URL(string: "repoprompt://agent/other?workspace_id=\(route.workspaceID.uuidString)&tab_id=\(route.tabID.uuidString)")) + let sessionID = try XCTUnwrap(route.sessionID) + let legacyAgentRoute = try XCTUnwrap(URL(string: "repoprompt://agent/session?workspace_id=\(route.workspaceID.uuidString)&tab_id=\(route.tabID.uuidString)&session_id=\(sessionID.uuidString)&window_id=7")) + XCTAssertEqual(AppDeepLinkRoute.parse(url: legacyAgentRoute), .unsupported) + + let missingWorkspace = try XCTUnwrap(URL(string: "repoprompt-ce://agent/session?tab_id=\(route.tabID.uuidString)")) + let malformedSession = try XCTUnwrap(URL(string: "repoprompt-ce://agent/session?workspace_id=\(route.workspaceID.uuidString)&tab_id=\(route.tabID.uuidString)&session_id=not-a-uuid")) + let unsupportedAgentPath = try XCTUnwrap(URL(string: "repoprompt-ce://agent/other?workspace_id=\(route.workspaceID.uuidString)&tab_id=\(route.tabID.uuidString)")) XCTAssertEqual(AppDeepLinkRoute.parse(url: missingWorkspace), .invalidScopedRoute) XCTAssertEqual(AppDeepLinkRoute.parse(url: malformedSession), .invalidScopedRoute) XCTAssertEqual(AppDeepLinkRoute.parse(url: unsupportedAgentPath), .invalidScopedRoute) } + func testCEOnlySchemeRoutesLegacyOpeners() throws { + XCTAssertTrue(AppDeepLinkURLScheme.isSupported("repoprompt-ce")) + XCTAssertTrue(AppDeepLinkURLScheme.isSupported("REPOPROMPT-CE")) + XCTAssertFalse(AppDeepLinkURLScheme.isSupported("repoprompt")) + XCTAssertFalse(AppDeepLinkURLScheme.isSupported("https")) + XCTAssertFalse(AppDeepLinkURLScheme.isSupported(nil)) + + let ceOpen = try XCTUnwrap(URL(string: "repoprompt-ce://open//Users/example/Project?workspace=Review&files=Sources/App.swift,README.md&prompt=Review%20this&focus=true&ephemeral=true")) + let legacyOpen = try XCTUnwrap(URL(string: "repoprompt://open//Users/example/Project?persist=false")) + let cePrompt = try XCTUnwrap(URL(string: "repoprompt-ce://prompt?title=Review&content=Review%20the%20selection&focus=true")) + let legacyPrompt = try XCTUnwrap(URL(string: "repoprompt://prompt?title=Review&content=Review%20the%20selection&focus=true")) + let unsupportedScheme = try XCTUnwrap(URL(string: "https://open//Users/example/Project")) + + XCTAssertEqual(AppDeepLinkRoute.parse(url: ceOpen), .route(.legacyURL(ceOpen))) + XCTAssertEqual(AppDeepLinkRoute.parse(url: legacyOpen), .unsupported) + XCTAssertEqual(AppDeepLinkRoute.parse(url: cePrompt), .route(.legacyURL(cePrompt))) + XCTAssertEqual(AppDeepLinkRoute.parse(url: legacyPrompt), .unsupported) + XCTAssertEqual(AppDeepLinkRoute.parse(url: unsupportedScheme), .unsupported) + } + + @MainActor + func testAgentSessionURLQueuesWhenNoLiveWindowsAreRegistered() async throws { + let manager = WindowStatesManager.shared + let originalWindows = manager.allWindows + let originalPendingURLs = manager.pendingURLs + manager.allWindows = [] + manager.pendingURLs = [] + defer { + manager.allWindows = originalWindows + manager.pendingURLs = originalPendingURLs + } + + let route = try AgentSessionDeepLinkRoute( + workspaceID: XCTUnwrap(UUID(uuidString: "11111111-1111-1111-1111-111111111111")), + tabID: XCTUnwrap(UUID(uuidString: "22222222-2222-2222-2222-222222222222")), + sessionID: XCTUnwrap(UUID(uuidString: "33333333-3333-3333-3333-333333333333")) + ) + + await AppDeepLinkRouter(windowStatesManager: manager).route(url: route.url) + + XCTAssertEqual(manager.pendingURLs, [route.url]) + } + func testAppcastParserSelectsHighestInlineVersionAndKeepsMetadata() throws { let xml = """