From c2e0d7cc3c79cdf6b0c4e184fb495edd721b971b Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 27 Apr 2026 11:04:54 +0900 Subject: [PATCH] feat(repo-appearance): accept any image format the system can render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the importer enforced a PNG/SVG whitelist and threw `RepositoryIconAssetStoreError.unsupportedExtension` for everything else. The whitelist was redundant — the file picker already filters to image UTTypes, and a corrupt or unrenderable file falls back to the dashed-questionmark placeholder via `RepositoryIconImage`. Drop the gate so JPG / WebP / HEIC / GIF / TIFF / BMP / etc. all work without any extra plumbing. - Remove `RepositoryIconAssetStoreError` and the extension whitelist in the live store. Files with no extension fall back to a generic `.img` suffix on the destination filename so the round-trip still works. - Reducer's import effect collapses to a single `catch error in` branch using `error.localizedDescription`; the dedicated `errorMessage(for:)` helper is gone. - Picker's `fileImporter` widens from `[.png, .svg]` to `[.image, .svg]`. SVG stays explicit because UTType conformance for SVG-as-`.image` has been spotty across older OS revisions — redundant on macOS 26+ but cheap insurance. - `isTintable` logic unchanged — only `.svg` filenames get the template-tint branch; bitmaps render with their own colors. Help text generalized: "PNG icons" → "Bitmap icons", picker subtitle same. - Tests: drop `importImageRejectsUnsupportedExtension` (no longer applicable) and `importFailureSurfacesErrorMessage` (covered by the generic error path test). Add coverage for jpg/jpeg/webp/heic/ gif/tiff/bmp acceptance and for files with no extension. --- .../RepositoryIconAssetStore.swift | 24 +++++++------- .../Reducer/RepositorySettingsFeature.swift | 8 ----- .../RepositoryAppearancePickerView.swift | 12 +++++-- .../RepositoryIconAssetStoreTests.swift | 31 ++++++++++++++++--- .../RepositorySettingsAppearanceTests.swift | 18 ----------- 5 files changed, 46 insertions(+), 47 deletions(-) diff --git a/supacode/Clients/Repositories/RepositoryIconAssetStore.swift b/supacode/Clients/Repositories/RepositoryIconAssetStore.swift index 2aafedcb..20370dd5 100644 --- a/supacode/Clients/Repositories/RepositoryIconAssetStore.swift +++ b/supacode/Clients/Repositories/RepositoryIconAssetStore.swift @@ -39,23 +39,21 @@ nonisolated struct RepositoryIconAssetStore: Sendable { ) -> Bool } -nonisolated enum RepositoryIconAssetStoreError: Error, Equatable { - case unsupportedExtension(String) -} - nonisolated extension RepositoryIconAssetStore { - /// Allowed input extensions. PNG and SVG only — JPEG and other - /// formats either don't suit repo icon use (no transparency) or - /// don't render well at small sidebar sizes. - static let supportedExtensions: Set = ["png", "svg"] - static var liveValue: RepositoryIconAssetStore { RepositoryIconAssetStore( importImage: { sourceURL, rootURL in - let normalizedExt = sourceURL.pathExtension.lowercased() - guard Self.supportedExtensions.contains(normalizedExt) else { - throw RepositoryIconAssetStoreError.unsupportedExtension(normalizedExt) - } + // No extension whitelist — the file picker filters down to + // image UTTypes already, and anything that NSImage can't + // render later falls back to the dashed-questionmark + // placeholder in `RepositoryIconImage`. The `.svg` suffix + // remains the lone meaningful signal because it gates the + // template-tinting branch downstream; everything else is + // treated as an opaque bitmap. + let normalizedExt = + sourceURL.pathExtension.lowercased().isEmpty + ? "img" + : sourceURL.pathExtension.lowercased() let directory = SupacodePaths.repositoryIconsDirectory(for: rootURL) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let filename = "\(UUID().uuidString.lowercased()).\(normalizedExt)" diff --git a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift index 8f21ccde..7e4df9df 100644 --- a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift +++ b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift @@ -222,8 +222,6 @@ struct RepositorySettingsFeature { do { let filename = try store.importImage(sourceURL, rootURL) await send(.userImageImported(filename: filename)) - } catch let error as RepositoryIconAssetStoreError { - await send(.userImageImportFailed(Self.errorMessage(for: error))) } catch { await send(.userImageImportFailed(error.localizedDescription)) } @@ -326,10 +324,4 @@ struct RepositorySettingsFeature { } } - private static func errorMessage(for error: RepositoryIconAssetStoreError) -> String { - switch error { - case .unsupportedExtension(let ext): - return "Repository icons must be PNG or SVG. \(ext.uppercased()) files aren't supported." - } - } } diff --git a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift index 56a02270..3f385721 100644 --- a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift +++ b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift @@ -39,15 +39,21 @@ struct RepositoryAppearancePickerView: View { title: "Repository Icon", subtitle: "Pick a preset or enter any SF Symbol name. SVG and SF Symbol icons are tinted " - + "with the repo color; PNG keeps its own colors.", + + "with the repo color; bitmap formats keep their own colors.", presets: RepositoryIconPresets.presets, onApply: { applySymbolFromPicker($0) }, onCancel: { isSymbolPickerPresented = false } ) } + // Accept any image UTType — PNG / JPEG / WebP / HEIC / TIFF / GIF + // / etc. all flow through the same `NSImage(contentsOf:)` render + // path. SVG is listed explicitly because it's a structured-text + // format that doesn't always conform to `.image` in older + // UTType conformance tables. Anything that fails to decode falls + // back to the dashed placeholder at render time. .fileImporter( isPresented: $isImageImporterPresented, - allowedContentTypes: [.png, .svg], + allowedContentTypes: [.image, .svg], allowsMultipleSelection: false ) { result in handleImageImportResult(result) @@ -171,7 +177,7 @@ struct RepositoryAppearancePickerView: View { private var iconHelpText: String { switch store.appearance.icon { case .userImage(let filename) where !filename.lowercased().hasSuffix(".svg"): - return "PNG icons keep their original colors and ignore the repo color." + return "Bitmap icons keep their original colors and ignore the repo color." case .userImage: return "User-provided SVGs are tinted with the repo color." case .sfSymbol: diff --git a/supacodeTests/RepositoryIconAssetStoreTests.swift b/supacodeTests/RepositoryIconAssetStoreTests.swift index 8dccc4d4..ed7ada1f 100644 --- a/supacodeTests/RepositoryIconAssetStoreTests.swift +++ b/supacodeTests/RepositoryIconAssetStoreTests.swift @@ -80,17 +80,38 @@ struct RepositoryIconAssetStoreTests { #expect(filename.hasSuffix(".svg")) } - @Test func importImageRejectsUnsupportedExtension() throws { + @Test func importImageAcceptsArbitraryImageExtensions() throws { + // The store no longer enforces a PNG/SVG whitelist — the file + // picker filters down to image UTTypes already, and anything + // that NSImage can't decode falls back to a placeholder at + // render time. JPG / WebP / HEIC / GIF / TIFF / etc. all flow + // through the same byte-copy path and round-trip through + // `repositoryIconFileURL` like PNG does. let store = RepositoryIconAssetStore.liveValue let repoRoot = makeRepoRootScratch() - let source = ScratchDirectory(prefix: "prowl-icon-source") - let sourceFile = try writeSourceFile(in: source, extension: "jpeg") - #expect(throws: RepositoryIconAssetStoreError.self) { - _ = try store.importImage(sourceFile, repoRoot.url) + for ext in ["jpg", "jpeg", "webp", "heic", "gif", "tiff", "bmp"] { + let source = ScratchDirectory(prefix: "prowl-icon-source") + let sourceFile = try writeSourceFile(in: source, extension: ext) + let filename = try store.importImage(sourceFile, repoRoot.url) + #expect(filename.hasSuffix(".\(ext)")) } } + @Test func importImageHandlesFileWithNoExtension() throws { + // Defensive: a dragged-in file without an extension shouldn't + // crash the importer. The destination filename gets a generic + // fallback so the round-trip still works. + let store = RepositoryIconAssetStore.liveValue + let repoRoot = makeRepoRootScratch() + let source = ScratchDirectory(prefix: "prowl-icon-source") + let sourceFile = source.url.appending(path: "icon", directoryHint: .notDirectory) + try Data([0xDE, 0xAD]).write(to: sourceFile) + + let filename = try store.importImage(sourceFile, repoRoot.url) + #expect(!filename.isEmpty) + } + @Test func importImageCreatesIconsDirectoryWhenMissing() throws { let store = RepositoryIconAssetStore.liveValue let repoRoot = makeRepoRootScratch() diff --git a/supacodeTests/RepositorySettingsAppearanceTests.swift b/supacodeTests/RepositorySettingsAppearanceTests.swift index 6cb75f17..9d1998c4 100644 --- a/supacodeTests/RepositorySettingsAppearanceTests.swift +++ b/supacodeTests/RepositorySettingsAppearanceTests.swift @@ -179,24 +179,6 @@ struct RepositorySettingsAppearanceTests { await store.finish() } - @Test func importFailureSurfacesErrorMessage() async throws { - let store = makeStore( - iconAssetStore: RepositoryIconAssetStore( - importImage: { _, _ in throw RepositoryIconAssetStoreError.unsupportedExtension("jpeg") }, - remove: { _, _ in }, - exists: { _, _ in false } - ) - ) - - await store.send(.importUserImage(URL(fileURLWithPath: "/tmp/source.jpeg"))) - await store.receive(\.userImageImportFailed) { - $0.appearanceImportError = """ - Repository icons must be PNG or SVG. JPEG files aren't supported. - """.trimmingCharacters(in: .whitespacesAndNewlines) - } - await store.finish() - } - @Test func importGenericErrorSurfacesLocalizedDescription() async throws { struct Boom: LocalizedError { var errorDescription: String? { "boom" } } let store = makeStore( -- 2.51.2