|
| 1 | +// AboutPlatformMark.swift |
| 2 | +// VocaMac |
| 3 | +// |
| 4 | +// Official platform marks used by the VocaHQ family links. |
| 5 | + |
| 6 | +import AppKit |
| 7 | +import Foundation |
| 8 | +import SwiftUI |
| 9 | + |
| 10 | +/// Official platform artwork from VocaHQ/.github. |
| 11 | +enum AboutPlatformMark: String, CaseIterable, Identifiable { |
| 12 | + case apple |
| 13 | + case android |
| 14 | + case windows |
| 15 | + case linux |
| 16 | + |
| 17 | + var id: String { rawValue } |
| 18 | + |
| 19 | + /// SHA-256 of the bundled canonical VocaHQ platform SVG. |
| 20 | + var expectedSHA256: String { |
| 21 | + switch self { |
| 22 | + case .apple: return "aab854e12a2a1a639c90662705452313b06b22c670bcfeeb49975a065d792afc" |
| 23 | + case .android: return "1862ad78b96e213c0ebd7366bdc4164c7d96b77ccd144fa90297bfe659165cea" |
| 24 | + case .windows: return "a2e1f084391d7acdf89e7d6c0c9a87247aaa1d60bee30b35a30a5283a14df357" |
| 25 | + case .linux: return "7107327b049ef9192577392d15810fead852a664f48d7f7034fe46e9a09fa8ef" |
| 26 | + } |
| 27 | + } |
| 28 | + |
| 29 | + func svgData() -> Data? { |
| 30 | + guard let url = svgURL() else { return nil } |
| 31 | + return try? Data(contentsOf: url) |
| 32 | + } |
| 33 | + |
| 34 | + /// Official path data from the bundled 24-point SVG. |
| 35 | + func officialPathData() -> String? { |
| 36 | + guard let text = String(data: svgData() ?? Data(), encoding: .utf8), |
| 37 | + let regex = try? NSRegularExpression(pattern: #"\bd="([^"]+)""#), |
| 38 | + let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), |
| 39 | + let range = Range(match.range(at: 1), in: text) else { |
| 40 | + return nil |
| 41 | + } |
| 42 | + return String(text[range]) |
| 43 | + } |
| 44 | + |
| 45 | + func templateImage() -> NSImage? { |
| 46 | + Self.cacheLock.lock() |
| 47 | + defer { Self.cacheLock.unlock() } |
| 48 | + if let cached = Self.imageCache[rawValue] { |
| 49 | + return cached |
| 50 | + } |
| 51 | + guard let pathData = officialPathData(), |
| 52 | + let path = SVGPath.makeCGPath(from: pathData) else { return nil } |
| 53 | + let size = NSSize(width: 24, height: 24) |
| 54 | + let image = NSImage(size: size, flipped: false) { rect in |
| 55 | + guard let context = NSGraphicsContext.current?.cgContext else { return false } |
| 56 | + context.saveGState() |
| 57 | + context.translateBy(x: 0, y: rect.height) |
| 58 | + context.scaleBy(x: rect.width / 24, y: -rect.height / 24) |
| 59 | + context.setFillColor(NSColor.black.cgColor) |
| 60 | + context.addPath(path) |
| 61 | + context.fillPath(using: .winding) |
| 62 | + context.restoreGState() |
| 63 | + return true |
| 64 | + } |
| 65 | + image.isTemplate = true |
| 66 | + Self.imageCache[rawValue] = image |
| 67 | + return image |
| 68 | + } |
| 69 | + |
| 70 | + private func svgURL() -> URL? { |
| 71 | + let bundle = Bundle.module |
| 72 | + return bundle.url(forResource: rawValue, withExtension: "svg", subdirectory: "Resources/platform") |
| 73 | + ?? bundle.url(forResource: rawValue, withExtension: "svg", subdirectory: "platform") |
| 74 | + ?? bundle.url(forResource: rawValue, withExtension: "svg") |
| 75 | + } |
| 76 | + |
| 77 | + private static let cacheLock = NSLock() |
| 78 | + private static var imageCache: [String: NSImage] = [:] |
| 79 | +} |
| 80 | + |
| 81 | +/// A VocaHQ product represented by its platform rather than a raw URL. |
| 82 | +struct AboutFamilyProduct: Identifiable, Equatable { |
| 83 | + let title: String |
| 84 | + let platform: String |
| 85 | + let url: URL |
| 86 | + let marks: [AboutPlatformMark] |
| 87 | + let systemImage: String? |
| 88 | + |
| 89 | + var id: String { title } |
| 90 | + |
| 91 | + static let all: [AboutFamilyProduct] = [ |
| 92 | + AboutFamilyProduct( |
| 93 | + title: "VocaLinux", |
| 94 | + platform: "Linux", |
| 95 | + url: URL(string: "https://vocalinux.com")!, |
| 96 | + marks: [.linux], |
| 97 | + systemImage: nil |
| 98 | + ), |
| 99 | + AboutFamilyProduct( |
| 100 | + title: "VocaMac", |
| 101 | + platform: "macOS", |
| 102 | + url: URL(string: "https://vocamac.com")!, |
| 103 | + marks: [.apple], |
| 104 | + systemImage: nil |
| 105 | + ), |
| 106 | + AboutFamilyProduct( |
| 107 | + title: "VocaWin", |
| 108 | + platform: "Windows", |
| 109 | + url: URL(string: "https://vocawin.com")!, |
| 110 | + marks: [.windows], |
| 111 | + systemImage: nil |
| 112 | + ), |
| 113 | + AboutFamilyProduct( |
| 114 | + title: "VocaPhone", |
| 115 | + platform: "iOS & Android", |
| 116 | + url: URL(string: "https://vocaphone.vocahq.com")!, |
| 117 | + marks: [.apple, .android], |
| 118 | + systemImage: nil |
| 119 | + ), |
| 120 | + AboutFamilyProduct( |
| 121 | + title: "VocaGateway", |
| 122 | + platform: "Self-hosted", |
| 123 | + url: URL(string: "https://vocagateway.vocahq.com")!, |
| 124 | + marks: [], |
| 125 | + systemImage: "server.rack" |
| 126 | + ), |
| 127 | + ] |
| 128 | +} |
| 129 | + |
| 130 | +/// Canonical organization and project links shown on the About screen. |
| 131 | +enum AboutLinks { |
| 132 | + static let headquarters = URL(string: "https://vocahq.com")! |
| 133 | + static let contributors = URL( |
| 134 | + string: "https://github.com/VocaHQ/vocamac#:~:text=about%20GitHub%20Sponsors-,Contributors,-9" |
| 135 | + )! |
| 136 | +} |
| 137 | + |
| 138 | +/// A platform mark that follows the current macOS foreground style. |
| 139 | +struct AboutPlatformMarkImage: View { |
| 140 | + let mark: AboutPlatformMark |
| 141 | + var size: CGFloat = 24 |
| 142 | + |
| 143 | + var body: some View { |
| 144 | + if let image = mark.templateImage() { |
| 145 | + Image(nsImage: image) |
| 146 | + .renderingMode(.template) |
| 147 | + .resizable() |
| 148 | + .scaledToFit() |
| 149 | + .frame(width: size, height: size) |
| 150 | + .accessibilityHidden(true) |
| 151 | + } |
| 152 | + } |
| 153 | +} |
0 commit comments