krz/hutch

an ios client for sourcehut

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

remove-splash-highlighter: 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}