@capgo/capacitor-launch-navigator 8.0.20 → 8.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,447 @@
1
+ import Foundation
2
+
3
+ private let defaultIconCacheMaxAgeMs = 24.0 * 60.0 * 60.0 * 1000.0
4
+ private let iconRequestTimeout: TimeInterval = 8
5
+ private let maxIconBytes = 2 * 1024 * 1024
6
+ private let maxHTMLBytes = 512 * 1024
7
+ private let iconCacheDirectoryName = "LaunchNavigatorIcons"
8
+
9
+ private struct IconProvider {
10
+ let app: String
11
+ let name: String?
12
+ let url: String?
13
+ let iconUrl: String?
14
+ }
15
+
16
+ private struct CachedIcon {
17
+ let fileURL: URL
18
+ let metadata: [String: Any]
19
+ }
20
+
21
+ private struct DownloadedIcon {
22
+ let data: Data
23
+ let sourceUrl: String
24
+ let mimeType: String?
25
+ }
26
+
27
+ private struct IconDownloadError: LocalizedError {
28
+ let message: String
29
+
30
+ var errorDescription: String? {
31
+ message
32
+ }
33
+ }
34
+
35
+ extension LaunchNavigator {
36
+ func getAppIcons(options: [String: Any], forceRefresh forceRefreshOverride: Bool) -> [String: Any] {
37
+ let maxAgeMs = maxAge(from: options)
38
+ let forceRefresh = forceRefreshOverride || (options["forceRefresh"] as? Bool ?? false)
39
+ var icons: [[String: Any]] = []
40
+ var failures: [[String: Any]] = []
41
+
42
+ for provider in resolveIconProviders(options: options) {
43
+ do {
44
+ icons.append(try resolveProviderIcon(provider: provider, maxAgeMs: maxAgeMs, forceRefresh: forceRefresh))
45
+ } catch {
46
+ failures.append(iconFailure(provider: provider, error: error))
47
+ }
48
+ }
49
+
50
+ return [
51
+ "icons": icons,
52
+ "failures": failures
53
+ ]
54
+ }
55
+
56
+ func clearIconCache(options: [String: Any]) -> [String: Any] {
57
+ var cleared = 0
58
+
59
+ if let apps = options["apps"] as? [String], !apps.isEmpty {
60
+ for app in apps {
61
+ cleared += deleteCachedFiles(app: app) ? 1 : 0
62
+ }
63
+ } else if let files = try? FileManager.default.contentsOfDirectory(
64
+ at: iconCacheDirectory(),
65
+ includingPropertiesForKeys: nil
66
+ ) {
67
+ for file in files where deleteFileIfExists(file) {
68
+ cleared += 1
69
+ }
70
+ }
71
+
72
+ return ["cleared": cleared]
73
+ }
74
+
75
+ private func resolveProviderIcon(
76
+ provider: IconProvider,
77
+ maxAgeMs: Double,
78
+ forceRefresh: Bool
79
+ ) throws -> [String: Any] {
80
+ let cachedIcon = readCachedIcon(app: provider.app)
81
+ let now = Date().timeIntervalSince1970 * 1000
82
+
83
+ if let cachedIcon = cachedIcon,
84
+ !forceRefresh,
85
+ let fetchedAt = cachedIcon.metadata["fetchedAt"] as? Double,
86
+ now - fetchedAt < maxAgeMs {
87
+ return iconObject(cachedIcon: cachedIcon, fromCache: true, stale: false)
88
+ }
89
+
90
+ do {
91
+ let downloadedIcon = try downloadIcon(provider: provider)
92
+ let iconFileURL = try writeIcon(app: provider.app, downloadedIcon: downloadedIcon)
93
+ var metadata: [String: Any] = [
94
+ "app": provider.app,
95
+ "sourceUrl": downloadedIcon.sourceUrl,
96
+ "fetchedAt": now,
97
+ "fileName": iconFileURL.lastPathComponent
98
+ ]
99
+ if let name = provider.name, !name.isEmpty {
100
+ metadata["name"] = name
101
+ }
102
+ if let mimeType = downloadedIcon.mimeType, !mimeType.isEmpty {
103
+ metadata["mimeType"] = mimeType
104
+ }
105
+ try writeMetadata(metadata, app: provider.app)
106
+ _ = deleteCachedFiles(app: provider.app, keeping: [iconFileURL.lastPathComponent, metadataURL(app: provider.app).lastPathComponent])
107
+ return iconObject(cachedIcon: CachedIcon(fileURL: iconFileURL, metadata: metadata), fromCache: false, stale: false)
108
+ } catch {
109
+ if let cachedIcon = cachedIcon {
110
+ return iconObject(cachedIcon: cachedIcon, fromCache: true, stale: true)
111
+ }
112
+ throw error
113
+ }
114
+ }
115
+
116
+ private func iconObject(cachedIcon: CachedIcon, fromCache: Bool, stale: Bool) -> [String: Any] {
117
+ var icon: [String: Any] = [
118
+ "app": cachedIcon.metadata["app"] as? String ?? "",
119
+ "localUrl": webPath(for: cachedIcon.fileURL),
120
+ "sourceUrl": cachedIcon.metadata["sourceUrl"] as? String ?? "",
121
+ "fetchedAt": cachedIcon.metadata["fetchedAt"] as? Double ?? 0,
122
+ "fromCache": fromCache,
123
+ "stale": stale
124
+ ]
125
+
126
+ if let name = cachedIcon.metadata["name"] as? String, !name.isEmpty {
127
+ icon["name"] = name
128
+ }
129
+ if let mimeType = cachedIcon.metadata["mimeType"] as? String, !mimeType.isEmpty {
130
+ icon["mimeType"] = mimeType
131
+ }
132
+
133
+ return icon
134
+ }
135
+
136
+ private func iconFailure(provider: IconProvider, error: Error) -> [String: Any] {
137
+ var failure: [String: Any] = [
138
+ "app": provider.app,
139
+ "message": error.localizedDescription
140
+ ]
141
+
142
+ if let name = provider.name, !name.isEmpty {
143
+ failure["name"] = name
144
+ }
145
+ if let sourceUrl = provider.iconUrl ?? provider.url, !sourceUrl.isEmpty {
146
+ failure["sourceUrl"] = sourceUrl
147
+ }
148
+
149
+ return failure
150
+ }
151
+
152
+ private func downloadIcon(provider: IconProvider) throws -> DownloadedIcon {
153
+ let sourceUrl: URL
154
+ if let iconUrl = provider.iconUrl, !iconUrl.isEmpty {
155
+ sourceUrl = try resolvedURL(iconUrl, relativeTo: provider.url)
156
+ } else {
157
+ guard let providerUrl = provider.url, !providerUrl.isEmpty else {
158
+ throw IconDownloadError(message: "Provider url or iconUrl is required")
159
+ }
160
+ sourceUrl = try discoverIconURL(providerUrl)
161
+ }
162
+
163
+ let (data, response) = try fetch(sourceUrl, maxBytes: maxIconBytes)
164
+ let mimeType = normalizeMimeType(response.mimeType)
165
+ guard isSupportedImageResponse(mimeType: mimeType, sourceUrl: response.url ?? sourceUrl) else {
166
+ throw IconDownloadError(message: "Icon response is not an image")
167
+ }
168
+
169
+ return DownloadedIcon(data: data, sourceUrl: (response.url ?? sourceUrl).absoluteString, mimeType: mimeType)
170
+ }
171
+
172
+ private func discoverIconURL(_ providerUrl: String) throws -> URL {
173
+ let pageURL = try resolvedURL(providerUrl, relativeTo: nil)
174
+ let (data, response) = try fetch(pageURL, maxBytes: maxHTMLBytes)
175
+ let html = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii) ?? ""
176
+ let baseURL = response.url ?? pageURL
177
+
178
+ if let iconPath = firstIconHref(in: html) {
179
+ return try resolvedURL(iconPath, relativeTo: baseURL.absoluteString)
180
+ }
181
+
182
+ return try resolvedURL("/favicon.ico", relativeTo: baseURL.absoluteString)
183
+ }
184
+
185
+ private func fetch(_ url: URL, maxBytes: Int) throws -> (Data, URLResponse) {
186
+ var request = URLRequest(url: url)
187
+ request.timeoutInterval = iconRequestTimeout
188
+ request.setValue("CapgoLaunchNavigator/8", forHTTPHeaderField: "User-Agent")
189
+
190
+ let semaphore = DispatchSemaphore(value: 0)
191
+ var result: Result<(Data, URLResponse), Error>?
192
+ let task = URLSession.shared.dataTask(with: request) { data, response, error in
193
+ defer { semaphore.signal() }
194
+ result = self.responseResult(data: data, response: response, error: error, maxBytes: maxBytes)
195
+ }
196
+ task.resume()
197
+
198
+ if semaphore.wait(timeout: .now() + iconRequestTimeout + 2) == .timedOut {
199
+ task.cancel()
200
+ throw IconDownloadError(message: "Request timed out")
201
+ }
202
+
203
+ guard let result = result else {
204
+ throw IconDownloadError(message: "Request failed")
205
+ }
206
+ return try result.get()
207
+ }
208
+
209
+ private func responseResult(
210
+ data: Data?,
211
+ response: URLResponse?,
212
+ error: Error?,
213
+ maxBytes: Int
214
+ ) -> Result<(Data, URLResponse), Error> {
215
+ if let error = error {
216
+ return .failure(error)
217
+ }
218
+ guard let data = data, let response = response else {
219
+ return .failure(IconDownloadError(message: "Empty response"))
220
+ }
221
+ guard data.count <= maxBytes else {
222
+ return .failure(IconDownloadError(message: "Response is too large"))
223
+ }
224
+ if let httpResponse = response as? HTTPURLResponse,
225
+ httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
226
+ return .failure(IconDownloadError(message: "Request failed with status \(httpResponse.statusCode)"))
227
+ }
228
+ return .success((data, response))
229
+ }
230
+
231
+ private func firstIconHref(in html: String) -> String? {
232
+ let linkPattern = #"<link\s+[^>]*rel=["'][^"']*(?:apple-touch-icon|icon)[^"']*["'][^>]*>"#
233
+ let hrefPattern = #"\shref=["']([^"']+)["']"#
234
+
235
+ guard let linkRegex = try? NSRegularExpression(pattern: linkPattern, options: [.caseInsensitive]),
236
+ let hrefRegex = try? NSRegularExpression(pattern: hrefPattern, options: [.caseInsensitive]) else {
237
+ return nil
238
+ }
239
+
240
+ let range = NSRange(html.startIndex..<html.endIndex, in: html)
241
+ guard let linkMatch = linkRegex.firstMatch(in: html, range: range),
242
+ let linkRange = Range(linkMatch.range, in: html) else {
243
+ return nil
244
+ }
245
+
246
+ let linkTag = String(html[linkRange])
247
+ let hrefRange = NSRange(linkTag.startIndex..<linkTag.endIndex, in: linkTag)
248
+ guard let hrefMatch = hrefRegex.firstMatch(in: linkTag, range: hrefRange),
249
+ hrefMatch.numberOfRanges > 1,
250
+ let valueRange = Range(hrefMatch.range(at: 1), in: linkTag) else {
251
+ return nil
252
+ }
253
+
254
+ return String(linkTag[valueRange])
255
+ }
256
+
257
+ private func resolvedURL(_ value: String, relativeTo baseUrl: String?) throws -> URL {
258
+ if let baseUrl = baseUrl,
259
+ let base = URL(string: baseUrl),
260
+ let url = URL(string: value, relativeTo: base)?.absoluteURL {
261
+ return url
262
+ }
263
+
264
+ guard let url = URL(string: value) else {
265
+ throw IconDownloadError(message: "Invalid icon URL: \(value)")
266
+ }
267
+ return url
268
+ }
269
+
270
+ private func writeIcon(app: String, downloadedIcon: DownloadedIcon) throws -> URL {
271
+ let fileName = cacheKey(app) + iconExtension(mimeType: downloadedIcon.mimeType, sourceUrl: downloadedIcon.sourceUrl)
272
+ let directory = iconCacheDirectory()
273
+ let fileURL = directory.appendingPathComponent(fileName)
274
+ let tempURL = directory.appendingPathComponent(fileName + ".tmp")
275
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
276
+ try? FileManager.default.removeItem(at: tempURL)
277
+ try downloadedIcon.data.write(to: tempURL, options: .atomic)
278
+ if FileManager.default.fileExists(atPath: fileURL.path) {
279
+ _ = try FileManager.default.replaceItemAt(fileURL, withItemAt: tempURL)
280
+ } else {
281
+ try FileManager.default.moveItem(at: tempURL, to: fileURL)
282
+ }
283
+ return fileURL
284
+ }
285
+
286
+ private func readCachedIcon(app: String) -> CachedIcon? {
287
+ let metadataURL = metadataURL(app: app)
288
+ guard let data = try? Data(contentsOf: metadataURL),
289
+ let metadata = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
290
+ let fileName = metadata["fileName"] as? String else {
291
+ return nil
292
+ }
293
+
294
+ let fileURL = iconCacheDirectory().appendingPathComponent(fileName)
295
+ guard FileManager.default.fileExists(atPath: fileURL.path) else {
296
+ return nil
297
+ }
298
+
299
+ return CachedIcon(fileURL: fileURL, metadata: metadata)
300
+ }
301
+
302
+ private func writeMetadata(_ metadata: [String: Any], app: String) throws {
303
+ let data = try JSONSerialization.data(withJSONObject: metadata)
304
+ try FileManager.default.createDirectory(at: iconCacheDirectory(), withIntermediateDirectories: true)
305
+ try data.write(to: metadataURL(app: app), options: .atomic)
306
+ }
307
+
308
+ private func iconCacheDirectory() -> URL {
309
+ FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent(
310
+ iconCacheDirectoryName,
311
+ isDirectory: true
312
+ )
313
+ }
314
+
315
+ private func metadataURL(app: String) -> URL {
316
+ iconCacheDirectory().appendingPathComponent(cacheKey(app) + ".json")
317
+ }
318
+
319
+ @discardableResult
320
+ private func deleteCachedFiles(app: String, keeping keptFileNames: Set<String> = []) -> Bool {
321
+ let prefix = cacheKey(app) + "."
322
+ guard let files = try? FileManager.default.contentsOfDirectory(
323
+ at: iconCacheDirectory(),
324
+ includingPropertiesForKeys: nil
325
+ ) else {
326
+ return false
327
+ }
328
+
329
+ var deleted = false
330
+ for file in files
331
+ where file.lastPathComponent.hasPrefix(prefix) &&
332
+ !keptFileNames.contains(file.lastPathComponent) &&
333
+ deleteFileIfExists(file) {
334
+ deleted = true
335
+ }
336
+ return deleted
337
+ }
338
+
339
+ private func deleteFileIfExists(_ fileURL: URL) -> Bool {
340
+ do {
341
+ try FileManager.default.removeItem(at: fileURL)
342
+ return true
343
+ } catch {
344
+ return false
345
+ }
346
+ }
347
+
348
+ private func webPath(for fileURL: URL) -> String {
349
+ webPathResolver?(fileURL) ?? fileURL.absoluteString
350
+ }
351
+
352
+ private func maxAge(from options: [String: Any]) -> Double {
353
+ if let value = options["maxAgeMs"] as? Double, value >= 0 {
354
+ return value
355
+ }
356
+ if let value = options["maxAgeMs"] as? NSNumber, value.doubleValue >= 0 {
357
+ return value.doubleValue
358
+ }
359
+ return defaultIconCacheMaxAgeMs
360
+ }
361
+
362
+ private func resolveIconProviders(options: [String: Any]) -> [IconProvider] {
363
+ var providers: [String: IconProvider] = [:]
364
+
365
+ for (app, appInfo) in navigationApps {
366
+ providers[app] = IconProvider(app: app, name: appInfo.name, url: appInfo.url, iconUrl: nil)
367
+ }
368
+
369
+ let customProviders = options["providers"] as? [[String: Any]] ?? []
370
+ for providerObject in customProviders {
371
+ guard let app = providerObject["app"] as? String, !app.isEmpty else {
372
+ continue
373
+ }
374
+
375
+ let existing = providers[app]
376
+ providers[app] = IconProvider(
377
+ app: app,
378
+ name: providerObject["name"] as? String ?? existing?.name,
379
+ url: providerObject["url"] as? String ?? existing?.url,
380
+ iconUrl: providerObject["iconUrl"] as? String ?? existing?.iconUrl
381
+ )
382
+ }
383
+
384
+ if let apps = options["apps"] as? [String], !apps.isEmpty {
385
+ return apps.map { app in
386
+ providers[app] ?? IconProvider(app: app, name: nil, url: nil, iconUrl: nil)
387
+ }
388
+ }
389
+
390
+ return providers.keys.compactMap { providers[$0] }
391
+ }
392
+
393
+ private func normalizeMimeType(_ mimeType: String?) -> String? {
394
+ mimeType?.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
395
+ }
396
+
397
+ private func isSupportedImageResponse(mimeType: String?, sourceUrl: URL) -> Bool {
398
+ guard let mimeType = mimeType, !mimeType.isEmpty else {
399
+ return hasKnownImageExtension(sourceUrl)
400
+ }
401
+
402
+ return mimeType.hasPrefix("image/") || (mimeType == "application/octet-stream" && hasKnownImageExtension(sourceUrl))
403
+ }
404
+
405
+ private func hasKnownImageExtension(_ sourceUrl: URL) -> Bool {
406
+ let path = sourceUrl.path.lowercased()
407
+ return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"].contains { path.hasSuffix($0) }
408
+ }
409
+
410
+ private func iconExtension(mimeType: String?, sourceUrl: String) -> String {
411
+ switch mimeType {
412
+ case "image/jpeg":
413
+ return ".jpg"
414
+ case "image/png":
415
+ return ".png"
416
+ case "image/gif":
417
+ return ".gif"
418
+ case "image/webp":
419
+ return ".webp"
420
+ case "image/svg+xml":
421
+ return ".svg"
422
+ case "image/x-icon", "image/vnd.microsoft.icon":
423
+ return ".ico"
424
+ default:
425
+ let lowerUrl = sourceUrl.lowercased()
426
+ for ext in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"] where lowerUrl.hasSuffix(ext) {
427
+ return ext == ".jpeg" ? ".jpg" : ext
428
+ }
429
+ return ".img"
430
+ }
431
+ }
432
+
433
+ private func cacheKey(_ app: String) -> String {
434
+ let safeApp = app.map { character in
435
+ character.isLetter || character.isNumber || character == "." || character == "_" || character == "-" ? character : "_"
436
+ }
437
+ return String(safeApp) + "_" + stableHash(app)
438
+ }
439
+
440
+ private func stableHash(_ value: String) -> String {
441
+ var hash: UInt64 = 5381
442
+ for byte in value.utf8 {
443
+ hash = ((hash << 5) &+ hash) &+ UInt64(byte)
444
+ }
445
+ return String(format: "%016llx", hash)
446
+ }
447
+ }
@@ -9,7 +9,7 @@ import MapKit
9
9
  */
10
10
  @objc(LaunchNavigatorPlugin)
11
11
  public class LaunchNavigatorPlugin: CAPPlugin, CAPBridgedPlugin {
12
- private let pluginVersion: String = "8.0.20"
12
+ private let pluginVersion: String = "8.0.21"
13
13
  public let identifier = "LaunchNavigatorPlugin"
14
14
  public let jsName = "LaunchNavigator"
15
15
  public let pluginMethods: [CAPPluginMethod] = [
@@ -18,9 +18,23 @@ public class LaunchNavigatorPlugin: CAPPlugin, CAPBridgedPlugin {
18
18
  CAPPluginMethod(name: "getAvailableApps", returnType: CAPPluginReturnPromise),
19
19
  CAPPluginMethod(name: "getSupportedApps", returnType: CAPPluginReturnPromise),
20
20
  CAPPluginMethod(name: "getDefaultApp", returnType: CAPPluginReturnPromise),
21
+ CAPPluginMethod(name: "getAppIcons", returnType: CAPPluginReturnPromise),
22
+ CAPPluginMethod(name: "refreshAppIcons", returnType: CAPPluginReturnPromise),
23
+ CAPPluginMethod(name: "clearIconCache", returnType: CAPPluginReturnPromise),
21
24
  CAPPluginMethod(name: "getPluginVersion", returnType: CAPPluginReturnPromise)
22
25
  ]
23
26
  private let implementation = LaunchNavigator()
27
+ private let iconCacheQueue = DispatchQueue(label: "app.capgo.plugin.launch_navigator.icons")
28
+
29
+ override public func load() {
30
+ super.load()
31
+ implementation.webPathResolver = { [weak self] localURL in
32
+ guard let portable = self?.bridge?.portablePath(fromLocalURL: localURL) else {
33
+ return nil
34
+ }
35
+ return portable.absoluteString
36
+ }
37
+ }
24
38
 
25
39
  @objc func navigate(_ call: CAPPluginCall) {
26
40
  guard let destination = call.getArray("destination") as? [Double],
@@ -51,7 +65,8 @@ public class LaunchNavigatorPlugin: CAPPlugin, CAPBridgedPlugin {
51
65
  start: start,
52
66
  startName: startName,
53
67
  destinationName: destinationName,
54
- transportMode: transportMode
68
+ transportMode: transportMode,
69
+ viewController: self.bridge?.viewController
55
70
  ) { success, error in
56
71
  if success {
57
72
  call.resolve()
@@ -94,6 +109,36 @@ public class LaunchNavigatorPlugin: CAPPlugin, CAPBridgedPlugin {
94
109
  ])
95
110
  }
96
111
 
112
+ @objc func getAppIcons(_ call: CAPPluginCall) {
113
+ let options = call.options as? [String: Any] ?? [:]
114
+ iconCacheQueue.async {
115
+ let result = self.implementation.getAppIcons(options: options, forceRefresh: false)
116
+ DispatchQueue.main.async {
117
+ call.resolve(result)
118
+ }
119
+ }
120
+ }
121
+
122
+ @objc func refreshAppIcons(_ call: CAPPluginCall) {
123
+ let options = call.options as? [String: Any] ?? [:]
124
+ iconCacheQueue.async {
125
+ let result = self.implementation.getAppIcons(options: options, forceRefresh: true)
126
+ DispatchQueue.main.async {
127
+ call.resolve(result)
128
+ }
129
+ }
130
+ }
131
+
132
+ @objc func clearIconCache(_ call: CAPPluginCall) {
133
+ let options = call.options as? [String: Any] ?? [:]
134
+ iconCacheQueue.async {
135
+ let result = self.implementation.clearIconCache(options: options)
136
+ DispatchQueue.main.async {
137
+ call.resolve(result)
138
+ }
139
+ }
140
+ }
141
+
97
142
  @objc func getPluginVersion(_ call: CAPPluginCall) {
98
143
  call.resolve(["version": self.pluginVersion])
99
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-launch-navigator",
3
- "version": "8.0.20",
3
+ "version": "8.0.21",
4
4
  "description": "Capacitor plugin which launches native route navigation apps for Android, iOS",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",