Universal links for gitbay.org URLs !139

merged merged by cmc on 2026-09-30 02:19 UTC · krz/gitbay-ios:universal-links into main

6 files changed, +299 −3

Layout: unified · split

gitbay/ContentView.swift +43 −3
@@ -4,6 +4,7 @@ struct ContentView: View {
44
55 @Environment(SessionStore.self) private var session
66 @Environment(PushRouter.self) private var router
7 @Environment(\.openURL) private var openURL
78
89 enum Tabs { case dashboard, feed, repositories, explore, profile }
910
@@ -64,7 +65,7 @@ struct ContentView: View {
6465 }
6566 }
6667 .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)) {
6869 await routePendingNotification(account)
6970 }
7071 } else {
@@ -87,11 +88,13 @@ struct ContentView: View {
8788 nonisolated private struct DrainKey: Equatable, Sendable {
8889 let account: String
8990 let target: PushTarget?
91 let link: URL?
9092 }
9193
9294 /// Resets the navigation state an account switch invalidated, then
9395 /// 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.
9598 ///
9699 /// A switch changes `account.id`, which rebuilds the tab bar. That
97100 /// is why the target is left pending across it: this runs again
@@ -106,7 +109,10 @@ struct ContentView: View {
106109 selectedTab = .dashboard
107110 pathAccount = account.id
108111 }
109 guard let target = router.pending else { return }
112 guard let target = router.pending else {
113 routePendingLink(account)
114 return
115 }
110116 if account.id != target.accountID {
111117 guard let other = session.accounts.first(where: { $0.id == target.accountID }) else {
112118 router.pending = nil // signed out of it since the tap
@@ -134,6 +140,40 @@ struct ContentView: View {
134140 router.pending = nil
135141 }
136142
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
137177}
138178
139179/// Every value-routed screen, attachable to any stack root.
gitbay/Push/PushRouter.swift +4
@@ -1,3 +1,4 @@
1import Foundation
12import Observation
23
34/// Carries a tapped notification's destination into the view tree.
@@ -16,4 +17,7 @@ import Observation
1617@MainActor
1718final class PushRouter {
1819 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?
1923}
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 @@
44<dict>
55 <key>aps-environment</key>
66 <string>development</string>
7 <key>com.apple.developer.associated-domains</key>
8 <array>
9 <string>applinks:gitbay.org</string>
10 </array>
711</dict>
812</plist>
gitbay/gitbayApp.swift +9
@@ -30,6 +30,15 @@ struct gitbayApp: App {
3030 // The screenshot checkpoint forces appearance per run;
3131 // simulator clones ignore the base device's setting.
3232 .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 }
3342 .task {
3443 let registrar = self.registrar
3544 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}