krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: 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, type: LookupType?)
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 let type = queryValue("type").flatMap(LookupType.init(rawValue:))
107 self = .search(query: query, type: type)
108 } else {
109 self = .lookup
110 }
111
112 default:
113 return nil
114 }
115 }
116
117 var url: URL {
118 switch self {
119 case .home:
120 return Self.makeURL(host: "home")
121 case .workQueue(let scope):
122 return Self.makeURL(
123 host: "work",
124 queryItems: scope == .all ? [] : [URLQueryItem(name: "scope", value: scope.rawValue)]
125 )
126 case .recentActivity:
127 return Self.makeURL(host: "recent-activity")
128 case .repository(let service, let owner, let repo):
129 return Self.makeURL(host: service.rawValue, path: [owner, repo])
130 case .tracker(let owner, let tracker):
131 return Self.makeURL(host: "todo", path: [owner, tracker])
132 case .ticket(let owner, let tracker, let ticketId):
133 return Self.makeURL(host: "todo", path: [owner, tracker, String(ticketId)])
134 case .build(let jobId):
135 return Self.makeURL(host: "builds", path: [String(jobId)])
136 case .mailingList(let owner, let list):
137 return Self.makeURL(host: "lists", path: [owner, list])
138 case .userProfile(let owner):
139 return Self.makeURL(host: "lookup", path: [owner])
140 case .builds:
141 return Self.makeURL(host: "builds")
142 case .failedBuilds:
143 return Self.makeURL(host: "builds", queryItems: [URLQueryItem(name: "filter", value: "failed")])
144 case .repositories:
145 return Self.makeURL(host: "repositories")
146 case .trackers:
147 return Self.makeURL(host: "trackers")
148 case .systemStatus:
149 return Self.makeURL(host: "status")
150 case .lookup:
151 return Self.makeURL(host: "lookup")
152 case .search(let query, let type):
153 var items = [URLQueryItem(name: "q", value: query)]
154 if let type {
155 items.append(URLQueryItem(name: "type", value: type.rawValue))
156 }
157 return Self.makeURL(host: "lookup", queryItems: items)
158 case .projectDashboard(let id, let title):
159 return Self.makeURL(
160 host: "projects",
161 path: [id],
162 queryItems: title.map { [URLQueryItem(name: "title", value: $0)] } ?? []
163 )
164 }
165 }
166
167 private static func makeURL(
168 host: String,
169 path: [String] = [],
170 queryItems: [URLQueryItem] = []
171 ) -> URL {
172 var components = URLComponents()
173 components.scheme = "hutch"
174 components.host = host
175 if !path.isEmpty {
176 components.path = "/" + path.joined(separator: "/")
177 }
178 if !queryItems.isEmpty {
179 components.queryItems = queryItems
180 }
181 return components.url!
182 }
183}
184
185/// Represents a parsed `hutch://` deep link.
186enum DeepLink: Equatable {
187 case home
188 case work
189 case workQueue(scope: HutchWorkQueueScope)
190 case recentActivity
191 /// hutch://git/<owner>/<repo> or hutch://hg/<owner>/<repo>
192 case repository(service: SRHTService, owner: String, repo: String)
193 /// hutch://todo/<owner>/<tracker>
194 case tracker(owner: String, tracker: String)
195 /// hutch://todo/<owner>/<tracker>/<ticketId>
196 case ticket(owner: String, tracker: String, ticketId: Int)
197 /// hutch://builds/<jobId> or hutch://builds/<owner>/job/<jobId>
198 case build(jobId: Int)
199 /// hutch://lists/<owner>/<list>
200 case mailingList(owner: String, list: String)
201 /// hutch://lookup/<owner>
202 case userProfile(owner: String)
203 /// hutch://builds (tab-level)
204 case buildsTab
205 /// hutch://repositories (tab-level)
206 case repositoriesTab
207 /// hutch://trackers (tab-level)
208 case trackersTab
209 /// hutch://status
210 case systemStatus
211 /// hutch://lookup
212 case lookup
213 /// hutch://lookup?q=<query>&type=<type>
214 case search(query: String, type: LookupType?)
215 /// hutch://builds?filter=failed
216 case failedBuilds
217 /// hutch://projects/<rid>
218 case projectDashboard(id: String, title: String?)
219
220 /// Attempt to parse a URL into a DeepLink.
221 /// Expected format: hutch://<path>
222 init?(url: URL) {
223 let components = url.deepLinkPathComponents
224 deepLinkParserLogger.info("DeepLink parser components for \(url.absoluteString, privacy: .public): \(components.joined(separator: ","), privacy: .public)")
225 guard let route = HutchRoute(url: url) else { return nil }
226 self = Self(route: route)
227 }
228
229 init(route: HutchRoute) {
230 switch route {
231 case .home:
232 self = .home
233 case .workQueue(let scope):
234 self = scope == .all ? .work : .workQueue(scope: scope)
235 case .recentActivity:
236 self = .recentActivity
237 case .repository(let service, let owner, let repo):
238 self = .repository(service: service, owner: owner, repo: repo)
239 case .tracker(let owner, let tracker):
240 self = .tracker(owner: owner, tracker: tracker)
241 case .ticket(let owner, let tracker, let ticketId):
242 self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId)
243 case .build(let jobId):
244 self = .build(jobId: jobId)
245 case .mailingList(let owner, let list):
246 self = .mailingList(owner: owner, list: list)
247 case .userProfile(let owner):
248 self = .userProfile(owner: owner)
249 case .builds:
250 self = .buildsTab
251 case .failedBuilds:
252 self = .failedBuilds
253 case .repositories:
254 self = .repositoriesTab
255 case .trackers:
256 self = .trackersTab
257 case .systemStatus:
258 self = .systemStatus
259 case .lookup:
260 self = .lookup
261 case .search(let query, let type):
262 self = .search(query: query, type: type)
263 case .projectDashboard(let id, let title):
264 self = .projectDashboard(id: id, title: title)
265 }
266 }
267}
268
269private extension URL {
270 var deepLinkPathComponents: [String] {
271 guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {
272 return []
273 }
274
275 let pathComponents = components.path
276 .split(separator: "/", omittingEmptySubsequences: true)
277 .map(String.init)
278
279 if let host = components.host, !host.isEmpty {
280 return [host] + pathComponents
281 }
282 return pathComponents
283 }
284}