diff --git a/CHANGELOG.md b/CHANGELOG.md index c729841c1dd..c7a2669abd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Plugins: user plugins can declare a monochrome inline SVG icon (`icon.svg`, at most 8 KB, plain shapes only), drawn as a template image in the switcher and menu card; an SVG that fails validation falls back to the monogram (#4608). Thanks @shivanathd! - Plugins: detail rows can declare normal, warning, or critical severity independently of progress, with system colors and warning symbols (#4602). Thanks @DamonYoungWork! ### Fixed diff --git a/Sources/CodexBar/StatusItemController+MenuLocalization.swift b/Sources/CodexBar/StatusItemController+MenuLocalization.swift index daf15e48909..c3408ae049e 100644 --- a/Sources/CodexBar/StatusItemController+MenuLocalization.swift +++ b/Sources/CodexBar/StatusItemController+MenuLocalization.swift @@ -8,6 +8,7 @@ extension StatusItemController { plugin.manifest.name, plugin.manifest.icon.monogram, plugin.manifest.icon.tint, + plugin.manifest.icon.svg ?? "", ].map { "\($0.utf8.count):\($0)" }.joined() }.joined() return [ diff --git a/Sources/CodexBar/StatusItemController+UserPlugins.swift b/Sources/CodexBar/StatusItemController+UserPlugins.swift index 6857d9ce01f..a145acfcf0b 100644 --- a/Sources/CodexBar/StatusItemController+UserPlugins.swift +++ b/Sources/CodexBar/StatusItemController+UserPlugins.swift @@ -164,6 +164,9 @@ extension StatusItemController { } func userPluginSwitcherIcon(for plugin: UserProviderPlugin) -> NSImage { + if let image = Self.userPluginSVGImage(plugin) { + return image + } let size = NSSize(width: 16, height: 16) let image = NSImage(size: size, flipped: false) { rect in let rawTint = plugin.manifest.icon.tint.dropFirst() @@ -188,6 +191,47 @@ extension StatusItemController { image.isTemplate = false return image } + + /// The plugin's validated SVG as a template image, or nil when it has none or it does not decode. + static func userPluginSVGImage(_ plugin: UserProviderPlugin) -> NSImage? { + guard let svg = plugin.manifest.icon.svg, + let source = NSImage(data: Data(svg.utf8)), + source.size.width.isFinite, source.size.height.isFinite, + source.size.width > 0, source.size.height > 0, + source.size.width <= CGFloat(ProviderPluginIconSVG.maximumCanvasDimension), + source.size.height <= CGFloat(ProviderPluginIconSVG.maximumCanvasDimension), + let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: 32, + pixelsHigh: 32, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0), + let context = NSGraphicsContext(bitmapImageRep: bitmap) + else { return nil } + let scale = 32 / max(source.size.width, source.size.height) + guard scale.isFinite else { return nil } + source.size = NSSize(width: source.size.width * scale, height: source.size.height * scale) + let rect = NSRect( + x: (32 - source.size.width) / 2, + y: (32 - source.size.height) / 2, + width: source.size.width, + height: source.size.height) + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + NSGraphicsContext.current = context + context.cgContext.clear(NSRect(x: 0, y: 0, width: 32, height: 32)) + source.draw(in: rect) + bitmap.size = NSSize(width: 16, height: 16) + let image = NSImage(size: bitmap.size) + image.addRepresentation(bitmap) + image.isTemplate = true + return image + } } struct UserPluginQuotaPresentation: Equatable { @@ -226,11 +270,16 @@ private struct UserPluginMenuCardView: View { var body: some View { VStack(alignment: .leading, spacing: 9) { HStack(spacing: 8) { - Text(self.plugin.manifest.icon.monogram) - .font(.caption.bold()) - .foregroundStyle(.white) - .frame(width: 26, height: 26) - .background(self.tint, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + Group { + if let image = StatusItemController.userPluginSVGImage(self.plugin) { + Image(nsImage: image).resizable().scaledToFit().padding(4) + } else { + Text(self.plugin.manifest.icon.monogram).font(.caption.bold()) + } + } + .foregroundStyle(.white) + .frame(width: 26, height: 26) + .background(self.tint, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) VStack(alignment: .leading, spacing: 1) { Text(self.plugin.manifest.name).font(.headline) Text(self.plugin.fileURL.lastPathComponent) diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginIconSVG.swift b/Sources/CodexBarCore/Plugins/ProviderPluginIconSVG.swift new file mode 100644 index 00000000000..7b79d37d5f4 --- /dev/null +++ b/Sources/CodexBarCore/Plugins/ProviderPluginIconSVG.swift @@ -0,0 +1,126 @@ +import Foundation +#if canImport(FoundationXML) +import FoundationXML +#endif + +/// Validates a plugin's monochrome SVG icon. Only plain vector shapes pass: no scripts, styles, links, +/// raster images, foreign objects, entities, or event handlers. Anything else is rejected so the host +/// falls back to the monogram. +public enum ProviderPluginIconSVG { + public static let maximumBytes = 8 * 1024 + public static let maximumCanvasDimension = 4096.0 + + static let allowedElements: Set = [ + "svg", "g", "path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "title", "desc", + ] + + static let allowedAttributes: Set = [ + "xmlns", "version", "id", "viewBox", "width", "height", "fill", "fill-rule", "clip-rule", "opacity", + "fill-opacity", "transform", "d", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "y1", "x2", "y2", + "points", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", + ] + + static let svgNamespace = "http://www.w3.org/2000/svg" + + /// Geometry, colours and transforms need only these characters. Excluding `\`, `/`, `:`, `;`, quotes and + /// `*` leaves no CSS escape, comment, declaration or URL syntax, so a value cannot reference anything. + static let allowedValueCharacters = CharacterSet( + charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .,#%+-()\t\n\r") + + /// Paint is the only place a function could load an image (`image-set()`, `paint()`, …), so it gets an exact + /// grammar: a keyword or colour name, a hex colour, or numeric `rgb()`/`rgba()`. + static let paintPattern = #"^(?:[A-Za-z]+|#[0-9A-Fa-f]{3,8}|rgba?\([0-9.%,\s]+\))$"# + + static func isAllowedValue(_ name: String, _ value: String) -> Bool { + if name == "xmlns" { return value == self.svgNamespace } + if name == "fill" || name == "stroke" { + let paint = value.trimmingCharacters(in: .whitespaces) + return paint.range(of: self.paintPattern, options: .regularExpression) != nil + && paint.range(of: "url", options: .caseInsensitive) == nil + } + return value.unicodeScalars.allSatisfy { self.allowedValueCharacters.contains($0) } + && value.range(of: "url", options: .caseInsensitive) == nil + } + + private static func hasBoundedCanvas(_ attributes: [String: String]) -> Bool { + // Paired dimensions prevent one length plus an extreme aspect ratio from implying a huge canvas. + guard (attributes["width"] == nil) == (attributes["height"] == nil) else { return false } + for key in ["width", "height"] { + guard let value = attributes[key] else { continue } + let number = value.lowercased().hasSuffix("px") ? String(value.dropLast(2)) : value + guard let length = Double(number.trimmingCharacters(in: .whitespacesAndNewlines)), + length.isFinite, length > 0, length <= self.maximumCanvasDimension + else { return false } + } + guard let viewBox = attributes["viewBox"] else { return attributes["width"] != nil } + let fields = viewBox.split { $0.isWhitespace || $0 == "," } + let numbers = fields.compactMap { Double($0) } + return fields.count == 4 && numbers.count == 4 && numbers.allSatisfy(\.isFinite) + && numbers[2] > 0 && numbers[2] <= self.maximumCanvasDimension + && numbers[3] > 0 && numbers[3] <= self.maximumCanvasDimension + } + + public static func isValid(_ svg: String) -> Bool { + guard !svg.isEmpty, svg.utf8.count <= self.maximumBytes, + svg.range(of: " String { diff --git a/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts b/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts index 929d490ba09..7135c654ed8 100644 --- a/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts +++ b/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts @@ -303,7 +303,8 @@ interface CodexBarPluginContext { interface CodexBarProviderDefinition { id: string; name: string; - icon?: { monogram?: string; tint?: string }; + /** `svg`: optional monochrome SVG (≤ 8 KB, canvas ≤ 4096; see docs/plugins.md) shown as a template image; invalid SVG falls back to the monogram. */ + icon?: { monogram?: string; tint?: string; svg?: string }; /** Defaults to true: a switcher tab when Merge Icons is on. False keeps an appended card. */ topLevel?: boolean; endpoints: CodexBarEndpoint[]; diff --git a/Tests/CodexBarTests/UserProviderPluginTests.swift b/Tests/CodexBarTests/UserProviderPluginTests.swift index 5b20359cbca..b43106e61aa 100644 --- a/Tests/CodexBarTests/UserProviderPluginTests.swift +++ b/Tests/CodexBarTests/UserProviderPluginTests.swift @@ -923,6 +923,110 @@ extension UserProviderPluginTests { .write(to: directory.appendingPathComponent("\(stage)-menus.json")) } + @MainActor + @Test + func `plugin svg icon is kept and rendered as a template image`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let svg = #""" + + + """# + _ = try fixture.write(name: "svg.js", source: Self.iconPlugin(id: "svg-meter", svg: svg)) + let plugin = try #require(UserProviderPluginRegistry.refresh( + loader: fixture.loader(transport: RecordingTransport(responseJSON: "{}"))).first?.plugin) + + #expect(plugin.manifest.icon.svg == svg) + let open = #""# + for paint in ["none", "currentColor", "black", "#fff", "#336699cc", "rgb(1, 2, 3)", "rgba(0,0,0,50%)"] { + let shape = #""# + #expect(ProviderPluginIconSVG.isValid(open + shape + ""), "rejected: \(paint)") + } + #expect(plugin.manifest.icon.monogram == "SV") + let image = try #require(StatusItemController.userPluginSVGImage(plugin)) + #expect(image.isTemplate) + #expect(image.size == NSSize(width: 16, height: 16)) + let bitmap = try #require(image.representations.first as? NSBitmapImageRep) + #expect(bitmap.pixelsWide == 32) + #expect(bitmap.pixelsHigh == 32) + #expect(image.representations.count == 1) + let center = try #require(bitmap.colorAt(x: 16, y: 16)) + #expect(center.alphaComponent > 0.9) + } + + @Test + func `unsafe or oversized svg icons fall back to the monogram`() throws { + let open = #""# + let rejected = [ + #""#, + #""#, + #""#, + open + "", + open + "", + open + "
", + open + #""#, + open + #""#, + open + #""#, + // CSS escapes, comments and character references cannot smuggle a URL past the check. + open + #""#, + open + #""#, + open + #""#, + open + #""#, + open + #""#, + #""#, + // Red team: other ways to reference or inject. + open + #""#, + open + #""#, + open + #""#, + open + #""#, + open + #""#, + open + ##""##, + open + #""#, + open + #""#, + #""#, + #""#, + open + #""#, + #""#, + open + #""#, + open + #""#, + open + #""#, + open + #""#, + open + "", + #"]>"#, + #""#, + open + #""#, + "", + ] + for svg in rejected { + #expect(!ProviderPluginIconSVG.isValid(svg), "accepted: \(svg.prefix(60))") + } + + let fixture = try Fixture() + defer { fixture.remove() } + _ = try fixture.write(name: "bad.js", source: Self.iconPlugin(id: "bad-meter", svg: rejected[0])) + let plugin = try #require(UserProviderPluginRegistry.refresh( + loader: fixture.loader(transport: RecordingTransport(responseJSON: "{}"))).first?.plugin) + #expect(plugin.manifest.icon.svg == nil) + #expect(plugin.manifest.icon.monogram == "SV") + #expect(ProviderPluginIcon(monogram: "SV", tint: "#336699", svg: rejected[0]).svg == nil) + } + + private static func iconPlugin(id: String, svg: String) -> String { + // Test SVGs contain no backticks or `${`, so a JS template literal carries them verbatim. + let literal = "`" + svg + "`" + return """ + defineProvider({ + id: "\(id)", + name: "SVG Meter", + icon: { monogram: "SV", tint: "#0176D3", svg: \(literal) }, + endpoints: ["https://\(id).example"], + settings: [], + fetchUsage() { return { primary: { usedPercent: 12 } }; }, + }); + """ + } + private static func menuPlugin(id: String, name: String, topLevel: Bool? = nil) -> String { """ defineProvider({ diff --git a/docs/plugins.md b/docs/plugins.md index 5f0498198b2..c93adccda95 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -131,8 +131,17 @@ defineProvider({ - `id`: 1–64 lowercase ASCII letters, digits, or hyphens. It must not match a built-in provider or another installed plugin. - `name`: trimmed display name, 1–80 UTF-8 bytes. -- `icon` (optional): `{monogram, tint}`. `monogram` is 1–3 characters; `tint` is `#RRGGBB`. The fallback is the first - letter of `name` with a neutral tint. File/SVG icons are not supported. +- `icon` (optional): `{monogram, tint, svg}`. `monogram` is 1–3 characters; `tint` is `#RRGGBB`. The fallback is the first + letter of `name` with a neutral tint. `svg` (optional) is an inline monochrome SVG string of at most 8 KB, drawn as a + template image like built-in provider icons. Only `svg`, `g`, `path`, `circle`, `ellipse`, `rect`, `line`, `polyline`, + `polygon`, `title`, and `desc` elements with plain geometry/fill/stroke attributes are accepted: no scripts, styles, + links, `url(...)` references, raster images, `foreignObject`, or DOCTYPE/entities. Attribute values may use only + letters, digits, spaces and `.,#%+-()` (no CSS escapes, comments or declarations); `fill`/`stroke` must be a colour + keyword, `#hex`, or numeric `rgb()`/`rgba()`; `xmlns` must be the SVG namespace. An SVG that fails these checks is + ignored and the monogram is shown. The host rasterizes accepted icons into a 32×32 bitmap before display; + canvas sizes are checked before native decoding and limited to 4096×4096. Include a finite `viewBox` or paired + positive `width`/`height` (numbers or `px`); partial dimensions and other canvas units fall back to the monogram. + File icons are not supported. - `topLevel` (optional, default `true`): gives an enabled plugin its own provider-switcher tab when Merge Icons is on. Set to `false` to keep an appended card. Plugin tabs show a quota gauge from the most constrained reported seven-day window, otherwise the first available primary, secondary, tertiary, or extra window. They follow the used/remaining display preference; unknown usage