@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.
- package/README.md +200 -15
- package/android/src/main/AndroidManifest.xml +10 -0
- package/android/src/main/java/app/capgo/plugin/launch_navigator/LaunchNavigator.java +858 -71
- package/android/src/main/java/app/capgo/plugin/launch_navigator/LaunchNavigatorPlugin.java +62 -2
- package/dist/docs.json +358 -0
- package/dist/esm/definitions.d.ts +180 -1
- package/dist/esm/definitions.js +12 -0
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web.d.ts +8 -2
- package/dist/esm/web.js +332 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +344 -0
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +344 -0
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/LaunchNavigatorPlugin/LaunchNavigator.swift +234 -19
- package/ios/Sources/LaunchNavigatorPlugin/LaunchNavigatorIcons.swift +447 -0
- package/ios/Sources/LaunchNavigatorPlugin/LaunchNavigatorPlugin.swift +47 -2
- package/package.json +1 -1
|
@@ -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.
|
|
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