krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.6.0: Hutch/App/DeepLink.swift · raw
1import Foundation
2import os
3
4private let deepLinkParserLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
5
6enum HutchWorkQueueScope: String, CaseIterable, Sendable {
7 case all
8 case unread
9 case assigned
10}
11
12enum HutchRoute: Equatable, Sendable {
13 case home
14 case workQueue(scope: HutchWorkQueueScope = .all)
15 case recentActivity
16 case repository(service: SRHTService, owner: String, repo: String)
17 case tracker(owner: String, tracker: String)
18 case ticket(owner: String, tracker: String, ticketId: Int)
19 case build(jobId: Int)
20 case mailingList(owner: String, list: String)
21 case userProfile(owner: String)
22 case builds
23 case failedBuilds
24 case repositories
25 case trackers
26 case systemStatus
27 case lookup
28 case search(query: String)
29 case projectDashboard(id: String, title: String?)
30
31 init?(url: URL) {
32 guard url.scheme == "hutch" else { return nil }
33
34 let components = url.deepLinkPathComponents
35 let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
36 let queryValue: (String) -> String? = { name in
37 queryItems.first { $0.name == name }?.value?.trimmingCharacters(in: .whitespacesAndNewlines)
38 }
39
40 switch components.first {
41 case "home", nil:
42 self = .home
43
44 case "recent", "recent-activity", "activity":
45 self = .recentActivity
46
47 case "work", "inbox":
48 let scope = queryValue("scope").flatMap(HutchWorkQueueScope.init(rawValue:)) ?? .all
49 self = .workQueue(scope: scope)
50
51 case let .some(serviceName) where ["git", "hg", "todo", "builds", "lists"].contains(serviceName)
52 && components.count == 2
53 && components[1].hasPrefix("~"):
54 deepLinkParserLogger.info("Treating owner-root service URL as user profile: service=\(serviceName, privacy: .public), owner=\(components[1], privacy: .public)")
55 self = .userProfile(owner: components[1])
56
57 case let .some(serviceName) where ["git", "hg"].contains(serviceName) && components.count >= 3:
58 guard let service = SRHTService(rawValue: serviceName)
59 else { return nil }
60 let owner = components[1]
61 let repo = components[2]
62 self = .repository(service: service, owner: owner, repo: repo)
63
64 case "todo" where components.count >= 4:
65 let owner = components[1]
66 let tracker = components[2]
67 guard let ticketId = Int(components[3]) else { return nil }
68 self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId)
69
70 case "todo" where components.count >= 3:
71 self = .tracker(owner: components[1], tracker: components[2])
72
73 case "builds" where components.count >= 2:
74 let rawJobId: String
75 if components.count >= 4, components[2] == "job" {
76 rawJobId = components[3]
77 } else {
78 rawJobId = components[1]
79 }
80 guard let jobId = Int(rawJobId) else { return nil }
81 self = .build(jobId: jobId)
82
83 case "builds":
84 self = queryValue("filter") == "failed" ? .failedBuilds : .builds
85
86 case "lists" where components.count >= 3:
87 self = .mailingList(owner: components[1], list: components[2])
88
89 case "projects" where components.count >= 2:
90 self = .projectDashboard(id: components[1], title: queryValue("title"))
91
92 case "repositories":
93 self = .repositories
94
95 case "trackers":
96 self = .trackers
97
98 case "status":
99 self = .systemStatus
100
101 case "lookup" where components.count >= 2:
102 self = .userProfile(owner: components[1])
103
104 case "lookup":
105 if let query = queryValue("q"), !query.isEmpty {
106 self = .search(query: query)
107 } else {
108 self = .lookup
109 }
110
111 default:
112 return nil
113 }
114 }
115
116 var url: URL {
117 switch self {
118 case .home:
119 return Self.makeURL(host: "home")
120 case .workQueue(let scope):
121 return Self.makeURL(
122 host: "work",
123 queryItems: scope == .all ? [] : [URLQueryItem(name: "scope", value: scope.rawValue)]
124 )
125 case .recentActivity:
126 return Self.makeURL(host: "recent-activity")
127 case .repository(let service, let owner, let repo):
128 return Self.makeURL(host: service.rawValue, path: [owner, repo])
129 case .tracker(let owner, let tracker):
130 return Self.makeURL(host: "todo", path: [owner, tracker])
131 case .ticket(let owner, let tracker, let ticketId):
132 return Self.makeURL(host: "todo", path: [owner, tracker, String(ticketId)])
133 case .build(let jobId):
134 return Self.makeURL(host: "builds", path: [String(jobId)])
135 case .mailingList(let owner, let list):
136 return Self.makeURL(host: "lists", path: [owner, list])
137 case .userProfile(let owner):
138 return Self.makeURL(host: "lookup", path: [owner])
139 case .builds:
140 return Self.makeURL(host: "builds")
141 case .failedBuilds:
142 return Self.makeURL(host: "builds", queryItems: [URLQueryItem(name: "filter", value: "failed")])
143 case .repositories:
144 return Self.makeURL(host: "repositories")
145 case .trackers:
146 return Self.makeURL(host: "trackers")
147 case .systemStatus:
148 return Self.makeURL(host: "status")
149 case .lookup:
150 return Self.makeURL(host: "lookup")
151 case .search(let query):
152 return Self.makeURL(host: "lookup", queryItems: [URLQueryItem(name: "q", value: query)])
153 case .projectDashboard(let id, let title):
154 return Self.makeURL(
155 host: "projects",
156 path: [id],
157 queryItems: title.map { [URLQueryItem(name: "title", value: $0)] } ?? []
158 )
159 }
160 }
161
162 private static func makeURL(
163 host: String,
164 path: [String] = [],
165 queryItems: [URLQueryItem] = []
166 ) -> URL {
167 var components = URLComponents()
168 components.scheme = "hutch"
169 components.host = host
170 if !path.isEmpty {
171 components.path = "/" + path.joined(separator: "/")
172 }
173 if !queryItems.isEmpty {
174 components.queryItems = queryItems
175 }
176 return components.url!
177 }
178}
179
180/// Represents a parsed `hutch://` deep link.
181enum DeepLink: Equatable {
182 case home
183 case work
184 case workQueue(scope: HutchWorkQueueScope)
185 case recentActivity
186 /// hutch://git/<owner>/<repo> or hutch://hg/<owner>/<repo>
187 case repository(service: SRHTService, owner: String, repo: String)
188 /// hutch://todo/<owner>/<tracker>
189 case tracker(owner: String, tracker: String)
190 /// hutch://todo/<owner>/<tracker>/<ticketId>
191 case ticket(owner: String, tracker: String, ticketId: Int)
192 /// hutch://builds/<jobId> or hutch://builds/<owner>/job/<jobId>
193 case build(jobId: Int)
194 /// hutch://lists/<owner>/<list>
195 case mailingList(owner: String, list: String)
196 /// hutch://lookup/<owner>
197 case userProfile(owner: String)
198 /// hutch://builds (tab-level)
199 case buildsTab
200 /// hutch://repositories (tab-level)
201 case repositoriesTab
202 /// hutch://trackers (tab-level)
203 case trackersTab
204 /// hutch://status
205 case systemStatus
206 /// hutch://lookup
207 case lookup
208 /// hutch://lookup?q=<query>
209 case search(query: String)
210 /// hutch://builds?filter=failed
211 case failedBuilds
212 /// hutch://projects/<rid>
213 case projectDashboard(id: String, title: String?)
214
215 /// Attempt to parse a URL into a DeepLink.
216 /// Expected format: hutch://<path>
217 init?(url: URL) {
218 let components = url.deepLinkPathComponents
219 deepLinkParserLogger.info("DeepLink parser components for \(url.absoluteString, privacy: .public): \(components.joined(separator: ","), privacy: .public)")
220 guard let route = HutchRoute(url: url) else { return nil }
221 self = Self(route: route)
222 }
223
224 init(route: HutchRoute) {
225 switch route {
226 case .home:
227 self = .home
228 case .workQueue(let scope):
229 self = scope == .all ? .work : .workQueue(scope: scope)
230 case .recentActivity:
231 self = .recentActivity
232 case .repository(let service, let owner, let repo):
233 self = .repository(service: service, owner: owner, repo: repo)
234 case .tracker(let owner, let tracker):
235 self = .tracker(owner: owner, tracker: tracker)
236 case .ticket(let owner, let tracker, let ticketId):
237 self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId)
238 case .build(let jobId):
239 self = .build(jobId: jobId)
240 case .mailingList(let owner, let list):
241 self = .mailingList(owner: owner, list: list)
242 case .userProfile(let owner):
243 self = .userProfile(owner: owner)
244 case .builds:
245 self = .buildsTab
246 case .failedBuilds:
247 self = .failedBuilds
248 case .repositories:
249 self = .repositoriesTab
250 case .trackers:
251 self = .trackersTab
252 case .systemStatus:
253 self = .systemStatus
254 case .lookup:
255 self = .lookup
256 case .search(let query):
257 self = .search(query: query)
258 case .projectDashboard(let id, let title):
259 self = .projectDashboard(id: id, title: title)
260 }
261 }
262}
263
264private extension URL {
265 var deepLinkPathComponents: [String] {
266 guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {
267 return []
268 }
269
270 let pathComponents = components.path
271 .split(separator: "/", omittingEmptySubsequences: true)
272 .map(String.init)
273
274 if let host = components.host, !host.isEmpty {
275 return [host] + pathComponents
276 }
277 return pathComponents
278 }
279}