Universal links for gitbay.org URLs !139
6 files changed, +299 −3
Layout: unified · split
gitbay/ContentView.swift +43 −3
| @@ -4,6 +4,7 @@ struct ContentView: View { | ||
| 4 | 4 | |
| 5 | 5 | @Environment(SessionStore.self) private var session |
| 6 | 6 | @Environment(PushRouter.self) private var router |
| 7 | @Environment(\.openURL) private var openURL | |
| 7 | 8 | |
| 8 | 9 | enum Tabs { case dashboard, feed, repositories, explore, profile } |
| 9 | 10 | |
| @@ -64,7 +65,7 @@ struct ContentView: View { | ||
| 64 | 65 | } |
| 65 | 66 | } |
| 66 | 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 | 69 | await routePendingNotification(account) |
| 69 | 70 | } |
| 70 | 71 | } else { |
| @@ -87,11 +88,13 @@ struct ContentView: View { | ||
| 87 | 88 | nonisolated private struct DrainKey: Equatable, Sendable { |
| 88 | 89 | let account: String |
| 89 | 90 | let target: PushTarget? |
| 91 | let link: URL? | |
| 90 | 92 | } |
| 91 | 93 | |
| 92 | 94 | /// Resets the navigation state an account switch invalidated, then |
| 93 | 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 | 99 | /// A switch changes `account.id`, which rebuilds the tab bar. That |
| 97 | 100 | /// is why the target is left pending across it: this runs again |
| @@ -106,7 +109,10 @@ struct ContentView: View { | ||
| 106 | 109 | selectedTab = .dashboard |
| 107 | 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 | 116 | if account.id != target.accountID { |
| 111 | 117 | guard let other = session.accounts.first(where: { $0.id == target.accountID }) else { |
| 112 | 118 | router.pending = nil // signed out of it since the tap |
| @@ -134,6 +140,40 @@ struct ContentView: View { | ||
| 134 | 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 | 179 | /// Every value-routed screen, attachable to any stack root. |
gitbay/Push/PushRouter.swift +4
| @@ -1,3 +1,4 @@ | ||
| 1 | import Foundation | |
| 1 | 2 | import Observation |
| 2 | 3 | |
| 3 | 4 | /// Carries a tapped notification's destination into the view tree. |
| @@ -16,4 +17,7 @@ import Observation | ||
| 16 | 17 | @MainActor |
| 17 | 18 | final class PushRouter { |
| 18 | 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 @@ | ||
| 1 | import 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. | |
| 6 | nonisolated 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 | 4 | <dict> |
| 5 | 5 | <key>aps-environment</key> |
| 6 | 6 | <string>development</string> |
| 7 | <key>com.apple.developer.associated-domains</key> | |
| 8 | <array> | |
| 9 | <string>applinks:gitbay.org</string> | |
| 10 | </array> | |
| 7 | 11 | </dict> |
| 8 | 12 | </plist> |
gitbay/gitbayApp.swift +9
| @@ -30,6 +30,15 @@ struct gitbayApp: App { | ||
| 30 | 30 | // The screenshot checkpoint forces appearance per run; |
| 31 | 31 | // simulator clones ignore the base device's setting. |
| 32 | 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 | 42 | .task { |
| 34 | 43 | let registrar = self.registrar |
| 35 | 44 | appDelegate.registrar = registrar |
gitbayTests/WebURLRouteTests.swift added +132
| @@ -0,0 +1,132 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private 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 | ||
| 105 | private 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 | } | |