Commit c70b54ad62

c70b54ad624879132e3e1fd4beca0ff869f7085d

parent: 8618aa6dc1

Unsigned

cmc <hello@cleberg.net> · 2026-09-30 02:19 UTC

Universal links for gitbay.org URLs (!139)

Adds `applinks:gitbay.org` and routes incoming gitbay.org links to existing screens.

- `WebURLRoute` parses a URL into the existing route enums: owner (and `/-/` pages: labels, milestones, snippets), repo, issues, MRs, builds, tree/blob/blame, commit, log, refs, compare, releases, milestones, labels, wiki. Segments are decoded after splitting, so `%2F` stays in a ref.
- `onOpenURL` parks a routable link on `PushRouter`; `ContentView`'s drain pushes it on the Dashboard stack under the current account if it is on the link's instance, otherwise switches to one that is.
- Signed out: the link is kept and routed after sign-in.
- Unroutable links, and links with no signed-in account on their instance, open in Safari (iOS does not route an app's own universal link back to it).

Only gitbay.org can be claimed: associated domains are fixed in the entitlement, so links to self-hosted instances keep opening in Safari.

Needs krz/gitbay!544 deployed with `[web] apple_app_ids = ["ZCNAX3VL9D.org.gitbay.gitbay"]` on gitbay.org, and the Associated Domains capability on the App ID (automatic signing with `-allowProvisioningUpdates` adds it).

Closes #20

Layout: unified · split

gitbay/ContentView.swift +43 −3
@@ -4,6 +4,7 @@ struct ContentView: View {
4 4
5 @Environment(SessionStore.self) private var session 5 @Environment(SessionStore.self) private var session
6 @Environment(PushRouter.self) private var router 6 @Environment(PushRouter.self) private var router
7 @Environment(\.openURL) private var openURL
7 8
8 enum Tabs { case dashboard, feed, repositories, explore, profile } 9 enum Tabs { case dashboard, feed, repositories, explore, profile }
9 10
@@ -64,7 +65,7 @@ struct ContentView: View {
64 } 65 }
65 } 66 }
66 .id(account.id) // a fresh tab bar and stacks per account 67 .id(account.id) // a fresh tab bar and stacks per account
67 .task(id: DrainKey(account: account.id, target: router.pending)) { 68 .task(id: DrainKey(account: account.id, target: router.pending, link: router.pendingLink)) {
68 await routePendingNotification(account) 69 await routePendingNotification(account)
69 } 70 }
70 } else { 71 } else {
@@ -87,11 +88,13 @@ struct ContentView: View {
87 nonisolated private struct DrainKey: Equatable, Sendable { 88 nonisolated private struct DrainKey: Equatable, Sendable {
88 let account: String 89 let account: String
89 let target: PushTarget? 90 let target: PushTarget?
91 let link: URL?
90 } 92 }
91 93
92 /// Resets the navigation state an account switch invalidated, then 94 /// Resets the navigation state an account switch invalidated, then
93 /// routes a tapped notification, switching accounts first when it 95 /// routes a tapped notification, switching accounts first when it
94 /// belongs to another one. 96 /// belongs to another one. With no notification pending, a pending
97 /// universal link is routed instead.
95 /// 98 ///
96 /// A switch changes `account.id`, which rebuilds the tab bar. That 99 /// A switch changes `account.id`, which rebuilds the tab bar. That
97 /// is why the target is left pending across it: this runs again 100 /// is why the target is left pending across it: this runs again
@@ -106,7 +109,10 @@ struct ContentView: View {
106 selectedTab = .dashboard 109 selectedTab = .dashboard
107 pathAccount = account.id 110 pathAccount = account.id
108 } 111 }
109 guard let target = router.pending else { return } 112 guard let target = router.pending else {
113 routePendingLink(account)
114 return
115 }
110 if account.id != target.accountID { 116 if account.id != target.accountID {
111 guard let other = session.accounts.first(where: { $0.id == target.accountID }) else { 117 guard let other = session.accounts.first(where: { $0.id == target.accountID }) else {
112 router.pending = nil // signed out of it since the tap 118 router.pending = nil // signed out of it since the tap
@@ -134,6 +140,40 @@ struct ContentView: View {
134 router.pending = nil 140 router.pending = nil
135 } 141 }
136 142
143 /// Opens a pending universal link on the Dashboard stack, under the
144 /// current account when it is on the link's instance, otherwise under
145 /// one that is. Switching leaves the link pending for the rebuilt
146 /// tree, as a notification does. With no account there, or a token
147 /// gone, the link goes to Safari.
148 private func routePendingLink(_ account: Account) {
149 guard let url = router.pendingLink else { return }
150 guard let route = WebURLRoute(url: url),
151 let owner = WebURLRoute.account(for: url, current: account, accounts: session.accounts)
152 else {
153 router.pendingLink = nil
154 openURL(url)
155 return
156 }
157 if owner.id != account.id {
158 if !session.activate(owner) {
159 router.pendingLink = nil
160 openURL(url)
161 }
162 return
163 }
164 selectedTab = .dashboard
165 switch route {
166 case .repo(let r): dashboardPath.append(r)
167 case .issue(let r): dashboardPath.append(r)
168 case .mr(let r): dashboardPath.append(r)
169 case .build(let r): dashboardPath.append(r)
170 case .release(let r): dashboardPath.append(r)
171 case .org(let r): dashboardPath.append(r)
172 case .snippet(let r): dashboardPath.append(r)
173 }
174 router.pendingLink = nil
175 }
176
137} 177}
138 178
139/// Every value-routed screen, attachable to any stack root. 179/// Every value-routed screen, attachable to any stack root.
gitbay/Push/PushRouter.swift +4
@@ -1,3 +1,4 @@
1import Foundation
1import Observation 2import Observation
2 3
3/// Carries a tapped notification's destination into the view tree. 4/// Carries a tapped notification's destination into the view tree.
@@ -16,4 +17,7 @@ import Observation
16@MainActor 17@MainActor
17final class PushRouter { 18final class PushRouter {
18 var pending: PushTarget? 19 var pending: PushTarget?
20 /// A universal link to route, held for the same reason, and across
21 /// sign-in when it arrives signed out.
22 var pendingLink: URL?
19} 23}
gitbay/Views/WebURLRoute.swift added +107
@@ -0,0 +1,107 @@
1import Foundation
2
3/// The screen a web page's URL opens, for links to the instance that
4/// arrive as universal links. The shapes follow the forge's route table;
5/// a page the app has no screen for is nil, and goes to Safari.
6nonisolated enum WebURLRoute: Hashable, Sendable {
7 case repo(RepoRoute)
8 case issue(IssueRoute)
9 case mr(MRRoute)
10 case build(BuildRoute)
11 case release(ReleaseRoute)
12 case org(OrgRoute)
13 case snippet(SnippetRoute)
14
15 init?(url: URL) {
16 guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http",
17 let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
18 // Split before decoding, so an escaped slash stays in its segment.
19 let s = components.percentEncodedPath.split(separator: "/").compactMap {
20 String($0).removingPercentEncoding
21 }
22 guard let owner = s.first else { return nil }
23 if s.count == 1 {
24 switch owner {
25 case "bookmarks": self = .repo(.bookmarks)
26 case "notifications": self = .repo(.notifications)
27 default: self = .repo(.profile(owner))
28 }
29 return
30 }
31 if s[1] == "-" {
32 guard let route = Self.ownerPage(owner, Array(s.dropFirst(2))) else { return nil }
33 self = route
34 return
35 }
36 let repo = "\(owner)/\(s[1])"
37 guard s.count > 2 else {
38 self = .repo(.repo(repo))
39 return
40 }
41 guard let route = Self.repoPage(repo, s[2], Array(s.dropFirst(3))) else { return nil }
42 self = route
43 }
44
45 /// `/{owner}/-/...`
46 private static func ownerPage(_ owner: String, _ rest: [String]) -> WebURLRoute? {
47 switch (rest.first, rest.count) {
48 case ("repositories", 1), ("bookmarks", 1), ("people", 1): .repo(.profile(owner))
49 case ("labels", 1): .org(.labels(owner))
50 case ("milestones", 1): .org(.milestones(owner))
51 case ("snippets", 1): .snippet(.list(owner: owner))
52 case ("snippets", 2) where rest[1] != "new": .snippet(.snippet(id: rest[1]))
53 default: nil
54 }
55 }
56
57 /// `/{owner}/{repo}/{section}/...`
58 private static func repoPage(_ repo: String, _ section: String, _ rest: [String]) -> WebURLRoute? {
59 let number = rest.count == 1 ? Int64(rest[0]).flatMap { $0 > 0 ? $0 : nil } : nil
60 let ref = rest.first
61 let path = rest.dropFirst().joined(separator: "/")
62 switch section {
63 case "issues":
64 if rest.isEmpty { return .issue(.list(repo: repo)) }
65 return number.map { .issue(.issue(repo: repo, number: $0)) }
66 case "mrs":
67 if rest.isEmpty { return .mr(.list(repo: repo)) }
68 return number.map { .mr(.mr(repo: repo, number: $0)) }
69 case "builds":
70 if rest.isEmpty { return .build(.list(repo: repo)) }
71 return number.map { .build(.detail(repo: repo, number: $0)) }
72 case "tree":
73 return ref.map { .repo(.tree(repo: repo, directory: path, ref: $0)) }
74 case "blob" where !path.isEmpty:
75 return .repo(.file(repo: repo, path: path, ref: ref))
76 case "blame" where !path.isEmpty:
77 return .repo(.blame(repo: repo, path: path, ref: ref))
78 case "commit" where rest.count == 1:
79 return .repo(.commit(repo: repo, sha: rest[0]))
80 case "log" where rest.count <= 1:
81 return .repo(.log(repo: repo, ref: ref, path: nil))
82 case "wiki" where ref != "_raw":
83 if rest.isEmpty { return .repo(.wiki(repo: repo)) }
84 return .repo(.wikiPage(repo: repo, page: rest.joined(separator: "/")))
85 case _ where !rest.isEmpty:
86 return nil
87 case "refs": return .repo(.refs(repo: repo))
88 case "compare": return .repo(.compare(repo: repo, defaultBranch: nil))
89 case "releases": return .release(.list(repo: repo))
90 case "milestones": return .repo(.milestones(repo: repo))
91 case "labels": return .repo(.labels(repo: repo))
92 default: return nil
93 }
94 }
95
96 /// The signed-in account a link opens under: the current one when it
97 /// is on the link's instance, otherwise the first that is. Both sides
98 /// go through `GitbayInstance`'s normalisation, as `PushTarget` does.
99 static func account(for url: URL, current: Account?, accounts: [Account]) -> Account? {
100 guard let instance = try? GitbayInstance(url: url.absoluteString) else { return nil }
101 let matches = { (account: Account) in
102 (try? GitbayInstance(url: account.instance.baseURL.absoluteString))?.baseURL == instance.baseURL
103 }
104 if let current, matches(current) { return current }
105 return accounts.first(where: matches)
106 }
107}
gitbay/gitbay.entitlements +4
@@ -4,5 +4,9 @@
4<dict> 4<dict>
5 <key>aps-environment</key> 5 <key>aps-environment</key>
6 <string>development</string> 6 <string>development</string>
7 <key>com.apple.developer.associated-domains</key>
8 <array>
9 <string>applinks:gitbay.org</string>
10 </array>
7</dict> 11</dict>
8</plist> 12</plist>
gitbay/gitbayApp.swift +9
@@ -30,6 +30,15 @@ struct gitbayApp: App {
30 // The screenshot checkpoint forces appearance per run; 30 // The screenshot checkpoint forces appearance per run;
31 // simulator clones ignore the base device's setting. 31 // simulator clones ignore the base device's setting.
32 .preferredColorScheme(Self.forcedColorScheme) 32 .preferredColorScheme(Self.forcedColorScheme)
33 .onOpenURL { url in
34 // A page the app has no screen for goes back to
35 // Safari; the rest waits for ContentView's drain.
36 if WebURLRoute(url: url) == nil {
37 UIApplication.shared.open(url)
38 } else {
39 router.pendingLink = url
40 }
41 }
33 .task { 42 .task {
34 let registrar = self.registrar 43 let registrar = self.registrar
35 appDelegate.registrar = registrar 44 appDelegate.registrar = registrar
gitbayTests/WebURLRouteTests.swift added +132
@@ -0,0 +1,132 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func route(_ path: String) -> WebURLRoute? {
6 WebURLRoute(url: URL(string: "https://gitbay.org\(path)")!)
7}
8
9@Test func webURLOwnerAndRepo() {
10 #expect(route("/krz") == .repo(.profile("krz")))
11 #expect(route("/krz/") == .repo(.profile("krz")))
12 #expect(route("/krz/gitbay") == .repo(.repo("krz/gitbay")))
13 #expect(route("/krz/gitbay/") == .repo(.repo("krz/gitbay")))
14}
15
16@Test func webURLOwnerPages() {
17 #expect(route("/krz/-/repositories") == .repo(.profile("krz")))
18 #expect(route("/krz/-/bookmarks") == .repo(.profile("krz")))
19 #expect(route("/krz/-/people") == .repo(.profile("krz")))
20 #expect(route("/krz/-/labels") == .org(.labels("krz")))
21 #expect(route("/krz/-/milestones") == .org(.milestones("krz")))
22 #expect(route("/cmc/-/snippets") == .snippet(.list(owner: "cmc")))
23 #expect(route("/cmc/-/snippets/a1b2") == .snippet(.snippet(id: "a1b2")))
24}
25
26@Test func webURLTopLevelPages() {
27 #expect(route("/bookmarks") == .repo(.bookmarks))
28 #expect(route("/notifications") == .repo(.notifications))
29}
30
31@Test func webURLIssuesMRsBuilds() {
32 #expect(route("/krz/gitbay/issues") == .issue(.list(repo: "krz/gitbay")))
33 #expect(route("/krz/gitbay/issues/12") == .issue(.issue(repo: "krz/gitbay", number: 12)))
34 #expect(route("/krz/gitbay/mrs") == .mr(.list(repo: "krz/gitbay")))
35 #expect(route("/krz/gitbay/mrs/7") == .mr(.mr(repo: "krz/gitbay", number: 7)))
36 #expect(route("/krz/gitbay/builds") == .build(.list(repo: "krz/gitbay")))
37 #expect(route("/krz/gitbay/builds/1510") == .build(.detail(repo: "krz/gitbay", number: 1510)))
38}
39
40// Query and fragment do not change the screen: a comment anchor or a
41// list filter still lands on the page it belongs to.
42@Test func webURLIgnoresQueryAndFragment() {
43 #expect(route("/krz/gitbay/issues/12#comment-3") == .issue(.issue(repo: "krz/gitbay", number: 12)))
44 #expect(route("/krz/gitbay/issues?state=closed") == .issue(.list(repo: "krz/gitbay")))
45}
46
47@Test func webURLFiles() {
48 #expect(route("/krz/gitbay/tree/main") == .repo(.tree(repo: "krz/gitbay", directory: "", ref: "main")))
49 #expect(route("/krz/gitbay/tree/main/") == .repo(.tree(repo: "krz/gitbay", directory: "", ref: "main")))
50 #expect(route("/krz/gitbay/tree/main/internal/httpd")
51 == .repo(.tree(repo: "krz/gitbay", directory: "internal/httpd", ref: "main")))
52 #expect(route("/krz/gitbay/blob/v1.0.0/README.org")
53 == .repo(.file(repo: "krz/gitbay", path: "README.org", ref: "v1.0.0")))
54 #expect(route("/krz/gitbay/blame/main/cmd/gitbay/main.go")
55 == .repo(.blame(repo: "krz/gitbay", path: "cmd/gitbay/main.go", ref: "main")))
56}
57
58// Each segment is decoded on its own, so an escaped slash stays inside
59// the ref instead of splitting it.
60@Test func webURLDecodesSegments() {
61 #expect(route("/krz/gitbay/blob/main/docs/My%20Notes.md")
62 == .repo(.file(repo: "krz/gitbay", path: "docs/My Notes.md", ref: "main")))
63 #expect(route("/krz/gitbay/tree/feature%2Fx/docs")
64 == .repo(.tree(repo: "krz/gitbay", directory: "docs", ref: "feature/x")))
65}
66
67@Test func webURLHistoryAndRefs() {
68 #expect(route("/krz/gitbay/commit/d0c63c4") == .repo(.commit(repo: "krz/gitbay", sha: "d0c63c4")))
69 #expect(route("/krz/gitbay/log") == .repo(.log(repo: "krz/gitbay", ref: nil, path: nil)))
70 #expect(route("/krz/gitbay/log/main") == .repo(.log(repo: "krz/gitbay", ref: "main", path: nil)))
71 #expect(route("/krz/gitbay/refs") == .repo(.refs(repo: "krz/gitbay")))
72 #expect(route("/krz/gitbay/compare") == .repo(.compare(repo: "krz/gitbay", defaultBranch: nil)))
73}
74
75@Test func webURLRepoSections() {
76 #expect(route("/krz/gitbay/releases") == .release(.list(repo: "krz/gitbay")))
77 #expect(route("/krz/gitbay/milestones") == .repo(.milestones(repo: "krz/gitbay")))
78 #expect(route("/krz/gitbay/labels") == .repo(.labels(repo: "krz/gitbay")))
79 #expect(route("/krz/gitbay/wiki") == .repo(.wiki(repo: "krz/gitbay")))
80 #expect(route("/krz/gitbay/wiki/Home") == .repo(.wikiPage(repo: "krz/gitbay", page: "Home")))
81 #expect(route("/krz/gitbay/wiki/Architecture/00-Overview")
82 == .repo(.wikiPage(repo: "krz/gitbay", page: "Architecture/00-Overview")))
83}
84
85// Pages the app has no screen for, and malformed ids, are nil: the
86// caller hands those to Safari.
87@Test func webURLUnroutable() {
88 for path in [
89 "", "/",
90 "/krz/gitbay/issues/new", "/krz/gitbay/issues/abc", "/krz/gitbay/issues/12/extra",
91 "/krz/gitbay/mrs/new", "/krz/gitbay/mrs/7/range-diff", "/krz/gitbay/builds/0",
92 "/krz/gitbay/blob/main", "/krz/gitbay/tree", "/krz/gitbay/commit",
93 "/krz/gitbay/raw/main/README.org", "/krz/gitbay/archive/main.tar.gz",
94 "/krz/gitbay/releases/download/v1/x.tgz", "/krz/gitbay/settings",
95 "/krz/gitbay/wiki/_raw/a.svg", "/krz/gitbay/compare/main...x",
96 "/krz/gitbay/log/main/extra", "/krz/-", "/krz/-/queries",
97 "/krz/-/snippets/a1b2/raw/x.txt",
98 ] {
99 #expect(route(path) == nil, "\(path)")
100 }
101 #expect(WebURLRoute(url: URL(string: "mailto:cmc@gitbay.org")!) == nil)
102 #expect(WebURLRoute(url: URL(string: "ftp://gitbay.org/krz")!) == nil)
103}
104
105private func account(_ host: String, _ user: String) throws -> Account {
106 Account(instance: try GitbayInstance(url: "https://\(host)"), username: user)
107}
108
109@Test func webURLAccountPrefersTheCurrentOne() throws {
110 let accounts = [try account("gitbay.org", "cmc"), try account("gitbay.org", "ios-smoke")]
111 let url = URL(string: "https://gitbay.org/krz")!
112 #expect(WebURLRoute.account(for: url, current: accounts[1], accounts: accounts) == accounts[1])
113}
114
115@Test func webURLAccountFallsBackToOneOnTheInstance() throws {
116 let accounts = [try account("dev.local", "cmc"), try account("gitbay.org", "cmc")]
117 let url = URL(string: "https://gitbay.org/krz")!
118 #expect(WebURLRoute.account(for: url, current: accounts[0], accounts: accounts) == accounts[1])
119}
120
121@Test func webURLAccountIsNilWithoutOneOnTheInstance() throws {
122 let accounts = [try account("dev.local", "cmc")]
123 let url = URL(string: "https://gitbay.org/krz")!
124 #expect(WebURLRoute.account(for: url, current: accounts[0], accounts: accounts) == nil)
125 #expect(WebURLRoute.account(for: url, current: nil, accounts: []) == nil)
126}
127
128@Test func webURLAccountMatchesAnExplicitDefaultPort() throws {
129 let accounts = [try account("gitbay.org", "cmc")]
130 let url = URL(string: "https://gitbay.org:443/krz")!
131 #expect(WebURLRoute.account(for: url, current: nil, accounts: accounts) == accounts[0])
132}