krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.9.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, 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}