| 1 | import Foundation |
| 2 | #if canImport(UIKit) |
| 3 | import UIKit |
| 4 | #else |
| 5 | import AppKit |
| 6 | #endif |
| 7 | |
| 8 | // π€ AppIdentity β the native half of the icon system (docs/icons.md): which |
| 9 | // mark an app wears in natively-rendered rows, headers, and pickers. Three |
| 10 | // kinds, mirroring the web exactly: |
| 11 | // |
| 12 | // 1. First-party apps β an SF Symbol. |
| 13 | // 2. Integration apps β the real brand mark, vendored as a monochrome |
| 14 | // template PNG in Marks/ (brands have no SF Symbols; these are the same |
| 15 | // SVGs web/static/logos.js carries, rasterized black-on-transparent so |
| 16 | // the platform tints them like a symbol). |
| 17 | // 3. Everything else β emoji stays CONTENT, never chrome: a member-made |
| 18 | // app or bot keeps its chosen glyph (the avatar call sites all pass it). |
| 19 | // |
| 20 | // WHICH symbol or mark an app wears is not native knowledge anymore: each |
| 21 | // app declares it in its own JS spec beside its web `logo` (`symbol:` / |
| 22 | // `mark:`, carried by the manifest report β docs/native-apps.md). AppRegistry |
| 23 | // pushes the decoration snapshot here on every catalogue load, the bundled |
| 24 | // seed included, so nothing hand-kept can drift; this file only RENDERS |
| 25 | // (name β SF Symbol string, asset key β vendored template image). |
| 26 | public enum AppIdentity { |
| 27 | private static let lock = NSLock() |
| 28 | nonisolated(unsafe) private static var symbols: [String: String] = [:] |
| 29 | nonisolated(unsafe) private static var markKeys: [String: String] = [:] |
| 30 | |
| 31 | /// The registry's feed (seed at init, live reports after): app id β |
| 32 | /// declared SF Symbol / mark asset. Merged, new values winning β several |
| 33 | /// spaces' registries share this one decoration surface. |
| 34 | static func updateDecoration(symbols s: [String: String], marks m: [String: String]) { |
| 35 | lock.lock() |
| 36 | defer { lock.unlock() } |
| 37 | symbols.merge(s) { _, new in new } |
| 38 | markKeys.merge(m) { _, new in new } |
| 39 | } |
| 40 | |
| 41 | /// The SF Symbol an app-thread's avatar renders, or nil when the app |
| 42 | /// wears a brand mark (see mark(for:)) or declared none. No generic |
| 43 | /// fallback: an app with no declared chrome renders its manifest glyph |
| 44 | /// (the avatar call sites all pass it), so a member app wears its own |
| 45 | /// emoji, never a generic grid. Call sites that NEED a symbol (iOS menus |
| 46 | /// drop non-image Label icons) keep their own ?? "square.grid.2x2". |
| 47 | public static func symbol(for appId: String?) -> String? { |
| 48 | guard let id = appId else { return nil } |
| 49 | lock.lock() |
| 50 | let name = symbols[id] |
| 51 | lock.unlock() |
| 52 | // Member apps can declare a symbol too (their manifest, like a |
| 53 | // built-in's) β but a name this OS can't resolve must fall back to |
| 54 | // the glyph, never render a blank. Resolution is cached; the rows |
| 55 | // re-render constantly. |
| 56 | guard let name else { return nil } |
| 57 | return resolvable(name) ? name : nil |
| 58 | } |
| 59 | |
| 60 | private static func resolvable(_ name: String) -> Bool { |
| 61 | lock.lock() |
| 62 | if let known = resolvableCache[name] { |
| 63 | lock.unlock() |
| 64 | return known |
| 65 | } |
| 66 | lock.unlock() |
| 67 | #if canImport(UIKit) |
| 68 | let ok = UIImage(systemName: name) != nil |
| 69 | #else |
| 70 | let ok = NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil |
| 71 | #endif |
| 72 | lock.lock() |
| 73 | resolvableCache[name] = ok |
| 74 | lock.unlock() |
| 75 | return ok |
| 76 | } |
| 77 | |
| 78 | nonisolated(unsafe) private static var resolvableCache: [String: Bool] = [:] |
| 79 | |
| 80 | /// The vendored brand mark for an integration app, template-tinted by the |
| 81 | /// caller. The app names its asset (manifest `mark`); only assets that |
| 82 | /// actually ship in Marks/ render β a custom app naming one it wishes it |
| 83 | /// had simply falls back to its glyph. Cached β the PNGs are tiny and the |
| 84 | /// rows re-render constantly. |
| 85 | public static func mark(for appId: String?) -> PlatformImage? { |
| 86 | guard let id = appId else { return nil } |
| 87 | lock.lock() |
| 88 | let file = markKeys[id] |
| 89 | lock.unlock() |
| 90 | guard let file else { return nil } |
| 91 | if let cached = cache[id] { return cached } |
| 92 | guard let url = Bundle.module.url(forResource: file, withExtension: "png", subdirectory: "Marks"), |
| 93 | let data = try? Data(contentsOf: url), |
| 94 | let image = PlatformImage(data: data) else { return nil } |
| 95 | cache[id] = image |
| 96 | return image |
| 97 | } |
| 98 | |
| 99 | private nonisolated(unsafe) static var cache: [String: PlatformImage] = [:] |
| 100 | } |
| 101 | |