diff --git a/Example/Example/SymbolVariableValueModeSetting.swift b/Example/Example/SymbolVariableValueModeSetting.swift index c6bd76b..4e346e1 100644 --- a/Example/Example/SymbolVariableValueModeSetting.swift +++ b/Example/Example/SymbolVariableValueModeSetting.swift @@ -19,7 +19,7 @@ enum SymbolVariableValueModeSetting: String, CaseIterable, Identifiable { } } -@available(macOS 26.0, *) +@available(iOS 26.0, macOS 26.0, *) extension SymbolVariableValueMode { init(_ setting: SymbolVariableValueModeSetting) { switch setting { diff --git a/Sources/SFSymbols/Internal/CategoryFilter/CategoryFilterPicker.swift b/Sources/SFSymbols/Internal/CategoryFilter/CategoryFilterPicker.swift index 6029433..9e2ec75 100644 --- a/Sources/SFSymbols/Internal/CategoryFilter/CategoryFilterPicker.swift +++ b/Sources/SFSymbols/Internal/CategoryFilter/CategoryFilterPicker.swift @@ -80,6 +80,7 @@ private extension CategoryFilterPicker { case .dark: .white.opacity(0.15) case .light: + // swiftlint:disable:next fallthrough fallthrough @unknown default: .black.opacity(0.1) diff --git a/Sources/SFSymbols/Internal/DataProviders/BundleCoreGlyphsDataProvider.swift b/Sources/SFSymbols/Internal/DataProviders/BundleCoreGlyphsDataProvider.swift new file mode 100644 index 0000000..055f382 --- /dev/null +++ b/Sources/SFSymbols/Internal/DataProviders/BundleCoreGlyphsDataProvider.swift @@ -0,0 +1,33 @@ +import Foundation + +final class BundleCoreGlyphsDataProvider: CoreGlyphsDataProvider { + enum ReadError: LocalizedError, CustomDebugStringConvertible { + case plistNotFound(String) + case failedReadingData(Error) + + var errorDescription: String? { + debugDescription + } + + var debugDescription: String { + switch self { + case .plistNotFound(let filename): + "The property list '\(filename)' was not found in the bundle." + case .failedReadingData(let error): + "Failed reading plist data: \(error.localizedDescription)" + } + } + } + + func data(forPlistNamed filename: String) async throws -> Data { + let bundle = try await CoreGlyphsBundleLoader.load() + guard let filePath = bundle.path(forResource: filename, ofType: "plist") else { + throw ReadError.plistNotFound(filename) + } + do { + return try Data(contentsOf: URL(filePath: filePath)) + } catch { + throw ReadError.failedReadingData(error) + } + } +} diff --git a/Sources/SFSymbols/Internal/DataProviders/CachingCoreGlyphsDataProvider.swift b/Sources/SFSymbols/Internal/DataProviders/CachingCoreGlyphsDataProvider.swift new file mode 100644 index 0000000..3b20ea1 --- /dev/null +++ b/Sources/SFSymbols/Internal/DataProviders/CachingCoreGlyphsDataProvider.swift @@ -0,0 +1,101 @@ +import Foundation +import OSLog + +final class CachingCoreGlyphsDataProvider: CoreGlyphsDataProvider { + enum ReadError: LocalizedError, CustomDebugStringConvertible { + case cacheUnavailable(underlyingError: Error) + + var errorDescription: String? { + debugDescription + } + + var debugDescription: String { + switch self { + case .cacheUnavailable(let underlyingError): + """ + Failed loading from bundle and cache is unavailable. \ + Original error: \(underlyingError.localizedDescription) + """ + } + } + } + + private let dataProvider: CoreGlyphsDataProvider + private let cacheStorage: CacheStorage + + init(wrapping dataProvider: CoreGlyphsDataProvider, cacheDirectory: URL) { + self.dataProvider = dataProvider + self.cacheStorage = CacheStorage(directory: cacheDirectory) + } + + func data(forPlistNamed filename: String) async throws -> Data { + do { + let data = try await dataProvider.data(forPlistNamed: filename) + Task { await cacheStorage.write(data, filename: filename) } + return data + } catch { + return try await cacheStorage.read(filename: filename, underlyingError: error) + } + } +} + +private actor CacheStorage { + private let logger = Logger(subsystem: "SFSymbols", category: "CacheStorage") + private let directory: URL + private var directoryCreated = false + + init(directory: URL) { + self.directory = directory + } + + func write(_ data: Data, filename: String) { + do { + try createDirectoryIfNeeded() + let url = fileURL(for: filename) + try data.write(to: url, options: .atomic) + #if os(iOS) + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.none], + ofItemAtPath: url.path() + ) + #endif + } catch { + // Caching failures are non-fatal. We still have the data from the bundle. + } + } + + func read(filename: String, underlyingError: Error) throws -> Data { + do { + return try Data(contentsOf: fileURL(for: filename)) + } catch { + logger.error( + """ + Failed reading '\(filename)'. \ + Bundle error: \(underlyingError.localizedDescription). \ + Cache error: \(error.localizedDescription) + """ + ) + throw CachingCoreGlyphsDataProvider.ReadError.cacheUnavailable(underlyingError: underlyingError) + } + } + + private func createDirectoryIfNeeded() throws { + guard !directoryCreated else { + return + } + #if os(iOS) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.protectionKey: FileProtectionType.none] + ) + #else + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + #endif + directoryCreated = true + } + + private func fileURL(for filename: String) -> URL { + directory.appendingPathComponent("\(filename).plist") + } +} diff --git a/Sources/SFSymbols/Internal/Plists/CoreGlyphsBundleLoader.swift b/Sources/SFSymbols/Internal/DataProviders/CoreGlyphsBundleLoader.swift similarity index 100% rename from Sources/SFSymbols/Internal/Plists/CoreGlyphsBundleLoader.swift rename to Sources/SFSymbols/Internal/DataProviders/CoreGlyphsBundleLoader.swift diff --git a/Sources/SFSymbols/Internal/DataProviders/CoreGlyphsDataProvider.swift b/Sources/SFSymbols/Internal/DataProviders/CoreGlyphsDataProvider.swift new file mode 100644 index 0000000..eb280cf --- /dev/null +++ b/Sources/SFSymbols/Internal/DataProviders/CoreGlyphsDataProvider.swift @@ -0,0 +1,24 @@ +import Foundation + +protocol CoreGlyphsDataProvider: Sendable { + func data(forPlistNamed filename: String) async throws -> Data +} + +extension CoreGlyphsDataProvider where Self == CachingCoreGlyphsDataProvider { + static func `default`() -> CoreGlyphsDataProvider { + CachingCoreGlyphsDataProvider( + wrapping: BundleCoreGlyphsDataProvider(), + cacheDirectory: defaultCacheDirectory() + ) + } + + private static func defaultCacheDirectory() -> URL { + let applicationSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + return applicationSupport + .appendingPathComponent("SFSymbols", isDirectory: true) + .appendingPathComponent("CoreGlyphs", isDirectory: true) + } +} diff --git a/Sources/SFSymbols/Internal/DataProviders/CoreGlyphsPlistReader.swift b/Sources/SFSymbols/Internal/DataProviders/CoreGlyphsPlistReader.swift new file mode 100644 index 0000000..4ca74b8 --- /dev/null +++ b/Sources/SFSymbols/Internal/DataProviders/CoreGlyphsPlistReader.swift @@ -0,0 +1,39 @@ +import Foundation + +struct CoreGlyphsPlistReader: Sendable { + enum ReadError: LocalizedError, CustomDebugStringConvertible { + case decodingError(DecodingError) + case unknownError(Error) + + var errorDescription: String? { + debugDescription + } + + var debugDescription: String { + switch self { + case .decodingError(let error): + error.localizedDescription + case .unknownError(let error): + error.localizedDescription + } + } + } + + let dataProvider: CoreGlyphsDataProvider + + init(dataProvider: CoreGlyphsDataProvider = .default()) { + self.dataProvider = dataProvider + } + + func read(plistNamed filename: String, as type: T.Type) async throws -> T { + let data = try await dataProvider.data(forPlistNamed: filename) + do { + let decoder = PropertyListDecoder() + return try decoder.decode(type, from: data) + } catch let error as DecodingError { + throw ReadError.decodingError(error) + } catch { + throw ReadError.unknownError(error) + } + } +} diff --git a/Sources/SFSymbols/Internal/Plists/CategoriesPlist.swift b/Sources/SFSymbols/Internal/Plists/CategoriesPlist.swift index 210098a..5b5ce04 100644 --- a/Sources/SFSymbols/Internal/Plists/CategoriesPlist.swift +++ b/Sources/SFSymbols/Internal/Plists/CategoriesPlist.swift @@ -16,10 +16,6 @@ struct CategoriesPlist: Decodable { let container = try decoder.singleValueContainer() categories = try container.decode([Category].self) } - - static func load(from bundle: Bundle) throws(CoreGlyphsPlistFileReader.ReadError) -> Self { - try CoreGlyphsPlistFileReader.readFile(named: "categories", in: bundle, decoding: Self.self) - } } extension CategoriesPlist: CustomDebugStringConvertible { diff --git a/Sources/SFSymbols/Internal/Plists/CoreGlyphsPlistFileReader.swift b/Sources/SFSymbols/Internal/Plists/CoreGlyphsPlistFileReader.swift deleted file mode 100644 index 1cfef58..0000000 --- a/Sources/SFSymbols/Internal/Plists/CoreGlyphsPlistFileReader.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation - -enum CoreGlyphsPlistFileReader { - enum ReadError: LocalizedError, CustomDebugStringConvertible { - case propertyListNotFound - case failedReadingFile - case decodingError(DecodingError) - case unknownError(Error) - - var errorDescription: String? { - debugDescription - } - - var debugDescription: String { - switch self { - case .propertyListNotFound: - "The property list was not found in the bundle." - case .failedReadingFile: - "Could not read data from the property list." - case .decodingError(let error): - error.localizedDescription - case .unknownError(let error): - error.localizedDescription - } - } - } - - static func readFile( - named filename: String, - in bundle: Bundle, - decoding valueType: T.Type - ) throws(ReadError) -> T { - guard let filePath = bundle.path(forResource: filename, ofType: "plist") else { - throw .propertyListNotFound - } - guard let data = try? Data(contentsOf: URL(filePath: filePath)) else { - throw .failedReadingFile - } - do { - let decoder = PropertyListDecoder() - return try decoder.decode(valueType.self, from: data) - } catch let error as DecodingError { - throw .decodingError(error) - } catch { - throw .unknownError(error) - } - } -} diff --git a/Sources/SFSymbols/Internal/Plists/NameAvailabilityPlist.swift b/Sources/SFSymbols/Internal/Plists/NameAvailabilityPlist.swift index c065afc..9cae07f 100644 --- a/Sources/SFSymbols/Internal/Plists/NameAvailabilityPlist.swift +++ b/Sources/SFSymbols/Internal/Plists/NameAvailabilityPlist.swift @@ -203,9 +203,6 @@ struct NameAvailabilityPlist: Decodable { return symbols.arrayValue.filter { availableSymbolVersions.contains($0.symbolsVersion) } } - static func load(from bundle: Bundle) throws(CoreGlyphsPlistFileReader.ReadError) -> Self { - try CoreGlyphsPlistFileReader.readFile(named: "name_availability", in: bundle, decoding: Self.self) - } } extension NameAvailabilityPlist: CustomDebugStringConvertible { diff --git a/Sources/SFSymbols/Internal/Plists/SymbolCategoriesPlist.swift b/Sources/SFSymbols/Internal/Plists/SymbolCategoriesPlist.swift index c7a3c0d..1bd1e9e 100644 --- a/Sources/SFSymbols/Internal/Plists/SymbolCategoriesPlist.swift +++ b/Sources/SFSymbols/Internal/Plists/SymbolCategoriesPlist.swift @@ -7,10 +7,6 @@ struct SymbolCategoriesPlist: Decodable { let container = try decoder.singleValueContainer() symbols = try container.decode([String: [String]].self) } - - static func load(from bundle: Bundle) throws(CoreGlyphsPlistFileReader.ReadError) -> Self { - try CoreGlyphsPlistFileReader.readFile(named: "symbol_categories", in: bundle, decoding: Self.self) - } } extension SymbolCategoriesPlist: CustomDebugStringConvertible { diff --git a/Sources/SFSymbols/Internal/Plists/SymbolOrderPlist.swift b/Sources/SFSymbols/Internal/Plists/SymbolOrderPlist.swift index 5d9a915..595fe0b 100644 --- a/Sources/SFSymbols/Internal/Plists/SymbolOrderPlist.swift +++ b/Sources/SFSymbols/Internal/Plists/SymbolOrderPlist.swift @@ -7,10 +7,6 @@ struct SymbolOrderPlist: Decodable { let container = try decoder.singleValueContainer() names = try container.decode([String].self) } - - static func load(from bundle: Bundle) throws(CoreGlyphsPlistFileReader.ReadError) -> Self { - try CoreGlyphsPlistFileReader.readFile(named: "symbol_order", in: bundle, decoding: Self.self) - } } extension SymbolOrderPlist: CustomDebugStringConvertible { diff --git a/Sources/SFSymbols/Internal/Plists/SymbolSearchPlist.swift b/Sources/SFSymbols/Internal/Plists/SymbolSearchPlist.swift index c4dfb82..c55d2c3 100644 --- a/Sources/SFSymbols/Internal/Plists/SymbolSearchPlist.swift +++ b/Sources/SFSymbols/Internal/Plists/SymbolSearchPlist.swift @@ -7,10 +7,6 @@ struct SymbolSearchPlist: Decodable { let container = try decoder.singleValueContainer() symbols = try container.decode([String: [String]].self) } - - static func load(from bundle: Bundle) throws(CoreGlyphsPlistFileReader.ReadError) -> Self { - try CoreGlyphsPlistFileReader.readFile(named: "symbol_search", in: bundle, decoding: Self.self) - } } extension SymbolSearchPlist: CustomDebugStringConvertible { diff --git a/Sources/SFSymbols/Internal/Settings/Environment+Settings.swift b/Sources/SFSymbols/Internal/Settings/Environment+Settings.swift index 5bcce48..34f1593 100644 --- a/Sources/SFSymbols/Internal/Settings/Environment+Settings.swift +++ b/Sources/SFSymbols/Internal/Settings/Environment+Settings.swift @@ -4,6 +4,7 @@ private struct SymbolPickerRenderingModeEnvironmentKey: EnvironmentKey { static let defaultValue: SymbolRenderingMode = .monochrome } +// swiftlint:disable:next type_name private struct SymbolColorRenderingModeSettingEnvironmentKey: EnvironmentKey { static let defaultValue: SymbolColorRenderingModeSetting = .flat } @@ -20,14 +21,17 @@ private struct SymbolPickerVariableValueEnvironmentKey: EnvironmentKey { static let defaultValue: Double = 1 } +// swiftlint:disable:next type_name private struct SymbolPickerVariableValueModeEnvironmentKey: EnvironmentKey { static let defaultValue: SymbolVariableValueModeSetting = .color } +// swiftlint:disable:next type_name private struct SymbolPickerPreviewUsesRenderingModeEnvironmentKey: EnvironmentKey { static let defaultValue = false } +// swiftlint:disable:next type_name private struct SymbolPickerPreviewUsesVariableValueEnvironmentKey: EnvironmentKey { static let defaultValue = false } diff --git a/Sources/SFSymbols/SFSymbolPicker+Settings.swift b/Sources/SFSymbols/SFSymbolPicker+Settings.swift index 5028908..4f47869 100644 --- a/Sources/SFSymbols/SFSymbolPicker+Settings.swift +++ b/Sources/SFSymbols/SFSymbolPicker+Settings.swift @@ -27,7 +27,7 @@ public extension View { func sfSymbolPickerForegroundStyle(_ primary: Color, _ secondary: Color) -> some View { environment( \.symbolColorsSetting, - SymbolColorsSetting(primaryColor: primary, secondaryColor: secondary) + SymbolColorsSetting(primaryColor: primary, secondaryColor: secondary) ) } @@ -35,7 +35,7 @@ public extension View { func sfSymbolPickerForegroundStyle(_ primary: Color, _ secondary: Color, _ tertiary: Color) -> some View { environment( \.symbolColorsSetting, - SymbolColorsSetting(primaryColor: primary, secondaryColor: secondary, tertiaryColor: tertiary) + SymbolColorsSetting(primaryColor: primary, secondaryColor: secondary, tertiaryColor: tertiary) ) } diff --git a/Sources/SFSymbols/SFSymbols.swift b/Sources/SFSymbols/SFSymbols.swift index 2bbe998..9ea9bba 100644 --- a/Sources/SFSymbols/SFSymbols.swift +++ b/Sources/SFSymbols/SFSymbols.swift @@ -9,14 +9,14 @@ public struct SFSymbols: Sendable { public init() async throws { do { - let bundle = try await CoreGlyphsBundleLoader.load() - async let _categoriesPlist = try CategoriesPlist.load(from: bundle) - async let _symbolOrderPlist = try SymbolOrderPlist.load(from: bundle) - let (categoriesPlist, symbolOrderPlist) = try await (_categoriesPlist, _symbolOrderPlist) - let (symbols, symbolNameMap) = try await Self.symbols(in: bundle, categoriesPlist: categoriesPlist) + let reader = CoreGlyphsPlistReader() + async let categoriesPlistTask = reader.read(plistNamed: "categories", as: CategoriesPlist.self) + async let symbolOrderPlistTask = reader.read(plistNamed: "symbol_order", as: SymbolOrderPlist.self) + let (categoriesPlist, symbolOrderPlist) = try await (categoriesPlistTask, symbolOrderPlistTask) + let (symbols, symbolNameMap) = try await Self.symbols(using: reader, categoriesPlist: categoriesPlist) let sortedSymbols = Self.sortSymbols(symbols, accordingTo: symbolOrderPlist.names) self.symbols = sortedSymbols - self.categories = try Self.categories( + self.categories = Self.categories( categoriesPlist: categoriesPlist, symbols: sortedSymbols, symbolNameMap: symbolNameMap @@ -30,16 +30,25 @@ public struct SFSymbols: Sendable { private extension SFSymbols { private static func symbols( - in bundle: Bundle, + using reader: CoreGlyphsPlistReader, categoriesPlist: CategoriesPlist ) async throws -> ([SFSymbol], [String: SFSymbol]) { - async let _nameAvailabilityPlist = try NameAvailabilityPlist.load(from: bundle) - async let _symbolSearchPlist = try SymbolSearchPlist.load(from: bundle) - async let _symbolCategoriesPlist = try SymbolCategoriesPlist.load(from: bundle) + async let nameAvailabilityPlistTask = reader.read( + plistNamed: "name_availability", + as: NameAvailabilityPlist.self + ) + async let symbolSearchPlistTask = reader.read( + plistNamed: "symbol_search", + as: SymbolSearchPlist.self + ) + async let symbolCategoriesPlistTask = reader.read( + plistNamed: "symbol_categories", + as: SymbolCategoriesPlist.self + ) let (nameAvailabilityPlist, symbolSearchPlist, symbolCategoriesPlist) = try await ( - _nameAvailabilityPlist, - _symbolSearchPlist, - _symbolCategoriesPlist + nameAvailabilityPlistTask, + symbolSearchPlistTask, + symbolCategoriesPlistTask ) var symbols: [SFSymbol] = [] symbols.reserveCapacity(nameAvailabilityPlist.availableSymbols.count) @@ -63,7 +72,7 @@ private extension SFSymbols { categoriesPlist: CategoriesPlist, symbols: [SFSymbol], symbolNameMap: [String: SFSymbol] - ) throws -> [SFSymbolCategory] { + ) -> [SFSymbolCategory] { var categoryKeyMap: [String: [SFSymbol]] = [:] categoryKeyMap.reserveCapacity(categoriesPlist.categories.count) for category in categoriesPlist.categories {