Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Scripts/test_release_tooling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
16 changes: 12 additions & 4 deletions Sources/RepoPrompt/App/AppDeepLinkRoute.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"

Expand All @@ -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? {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
16 changes: 12 additions & 4 deletions Sources/RepoPrompt/App/AppDeepLinkRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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?) {
Expand All @@ -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<Int>()

for candidate in Self.agentSessionPreferredExistingWindows(for: route, in: liveWindows) {
Expand Down
4 changes: 2 additions & 2 deletions Sources/RepoPrompt/App/WindowState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +32,10 @@ struct AdvancedSettingsView: View {
)
}

private var canonicalURLPrefix: String {
"\(AppDeepLinkURLScheme.canonical)://"
}

private var respectGitignoreBinding: Binding<Bool> {
Binding(
get: { globalSettings.respectGitignore() },
Expand Down Expand Up @@ -114,6 +117,11 @@ struct AdvancedSettingsView: View {

keyboardShortcutsSection

Divider()
.padding(.horizontal, -16)

urlOpenerSection

Divider()
.padding(.horizontal, -16)

Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 51 additions & 3 deletions Tests/RepoPromptTests/App/AppPlatformUtilityRecoveryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
<?xml version="1.0" encoding="utf-8"?>
Expand Down
Loading