From fc9405259421eb4cfbc8547a6e433516eefb3bda Mon Sep 17 00:00:00 2001 From: Shivanath Devinarayanan <1937549+shivanathd@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:59:30 +0530 Subject: [PATCH 1/3] Allow user plugins to declare a monochrome SVG icon Plugins can set icon.svg (inline, at most 8 KB). Only plain vector shapes are accepted; scripts, styles, links, url() references, raster images, foreignObject and DOCTYPE/entities are rejected, and a rejected SVG falls back to the monogram. The SVG renders as a template image in the switcher and on the menu card. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + ...tatusItemController+MenuLocalization.swift | 1 + .../StatusItemController+UserPlugins.swift | 26 ++++-- .../Plugins/ProviderPluginIconSVG.swift | 82 +++++++++++++++++++ .../Plugins/ProviderPluginManifest.swift | 10 ++- .../Resources/Plugins/codexbar-plugin.d.ts | 3 +- .../UserProviderPluginTests.swift | 59 +++++++++++++ docs/plugins.md | 8 +- 8 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 Sources/CodexBarCore/Plugins/ProviderPluginIconSVG.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 636ab772be1..66f7eea8ee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.77.1 — Unreleased +### 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. + ## 0.77.0 — 2026-10-16 ### Highlights 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..dafcec3e2a9 100644 --- a/Sources/CodexBar/StatusItemController+UserPlugins.swift +++ b/Sources/CodexBar/StatusItemController+UserPlugins.swift @@ -164,6 +164,10 @@ extension StatusItemController { } func userPluginSwitcherIcon(for plugin: UserProviderPlugin) -> NSImage { + if let image = Self.userPluginSVGImage(plugin) { + image.size = NSSize(width: 16, height: 16) + 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 +192,13 @@ 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 image = NSImage(data: Data(svg.utf8)) else { return nil } + image.isTemplate = true + return image + } } struct UserPluginQuotaPresentation: Equatable { @@ -226,11 +237,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..27e32f26280 --- /dev/null +++ b/Sources/CodexBarCore/Plugins/ProviderPluginIconSVG.swift @@ -0,0 +1,82 @@ +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 + + 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", + ] + + 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 4c31b9978d2..8ed1b6dfa43 100644 --- a/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts +++ b/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts @@ -301,7 +301,8 @@ interface CodexBarPluginContext { interface CodexBarProviderDefinition { id: string; name: string; - icon?: { monogram?: string; tint?: string }; + /** `svg`: optional monochrome SVG (≤ 8 KB, plain shapes only) 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..3b42047a527 100644 --- a/Tests/CodexBarTests/UserProviderPluginTests.swift +++ b/Tests/CodexBarTests/UserProviderPluginTests.swift @@ -923,6 +923,65 @@ 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) + #expect(plugin.manifest.icon.monogram == "SV") + let image = try #require(StatusItemController.userPluginSVGImage(plugin)) + #expect(image.isTemplate) + } + + @Test + func `unsafe or oversized svg icons fall back to the monogram`() throws { + let open = #""# + let rejected = [ + 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") + } + + 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 9231c549695..d533390779f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -126,8 +126,12 @@ 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. An SVG that fails these checks is + ignored and the monogram is shown. 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 From 2732769ae05bd2d5b343b83d2d9d336c097787a6 Mon Sep 17 00:00:00 2001 From: Shivanath Devinarayanan <1937549+shivanathd@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:54:51 +0530 Subject: [PATCH 2/3] Constrain plugin SVG attribute values to a non-referencing grammar Rejecting a literal url( was not enough: fill="u\72l(...)" passes it and CSS decodes the escape into a URL function. Attribute values now allow only letters, digits, spaces and .,#%+-() (no CSS escapes, comments, declarations, quotes, colons or slashes), fill/stroke must be a colour keyword, #hex or numeric rgb()/rgba(), and xmlns must be the SVG namespace. Adds the reported bypass plus red-team cases (case and full-width variants, character references, image-set/paint/var, style, use/a/xlink/xml:base, foreign namespaces, stylesheet PI, NUL) and accepted-paint cases. --- .../Plugins/ProviderPluginIconSVG.swift | 28 +++++++++++++++-- .../UserProviderPluginTests.swift | 31 +++++++++++++++++++ docs/plugins.md | 4 ++- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginIconSVG.swift b/Sources/CodexBarCore/Plugins/ProviderPluginIconSVG.swift index 27e32f26280..943ed2f4861 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginIconSVG.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginIconSVG.swift @@ -19,6 +19,28 @@ public enum ProviderPluginIconSVG { "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 + } + public static func isValid(_ svg: String) -> Bool { guard !svg.isEmpty, svg.utf8.count <= self.maximumBytes, svg.range(of: ""# + 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) @@ -949,6 +955,31 @@ extension UserProviderPluginTests { 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 + #""#, diff --git a/docs/plugins.md b/docs/plugins.md index d533390779f..8c4c47e5e9e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -130,7 +130,9 @@ defineProvider({ 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. An SVG that fails these checks is + 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. 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 From 4fe7af967b7a54b70aa9280f6831619c8d7c0558 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Oct 2026 19:14:02 -0700 Subject: [PATCH 3/3] fix(plugins): bound and rasterize user SVG icons Rasterize validated inline icons into fixed template bitmaps and reject unbounded canvas dimensions before native decoding. Keep validation in the icon constructor so every caller retains the monogram fallback. Co-authored-by: Shivanath Devinarayanan <1937549+shivanathd@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../StatusItemController+UserPlugins.swift | 37 ++++++++++++++++++- .../Plugins/ProviderPluginIconSVG.swift | 20 ++++++++++ .../Plugins/ProviderPluginManifest.swift | 3 +- .../Resources/Plugins/codexbar-plugin.d.ts | 2 +- .../UserProviderPluginTests.swift | 16 +++++++- docs/plugins.md | 5 ++- 7 files changed, 77 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f7eea8ee2..2bec5351bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +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. +- 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! ## 0.77.0 — 2026-10-16 diff --git a/Sources/CodexBar/StatusItemController+UserPlugins.swift b/Sources/CodexBar/StatusItemController+UserPlugins.swift index dafcec3e2a9..a145acfcf0b 100644 --- a/Sources/CodexBar/StatusItemController+UserPlugins.swift +++ b/Sources/CodexBar/StatusItemController+UserPlugins.swift @@ -165,7 +165,6 @@ extension StatusItemController { func userPluginSwitcherIcon(for plugin: UserProviderPlugin) -> NSImage { if let image = Self.userPluginSVGImage(plugin) { - image.size = NSSize(width: 16, height: 16) return image } let size = NSSize(width: 16, height: 16) @@ -195,7 +194,41 @@ extension StatusItemController { /// 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 image = NSImage(data: Data(svg.utf8)) else { return nil } + 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 } diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginIconSVG.swift b/Sources/CodexBarCore/Plugins/ProviderPluginIconSVG.swift index 943ed2f4861..7b79d37d5f4 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginIconSVG.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginIconSVG.swift @@ -8,6 +8,7 @@ import FoundationXML /// 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", @@ -41,6 +42,24 @@ public enum ProviderPluginIconSVG { && 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: ""# + 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) @@ -943,12 +946,22 @@ extension UserProviderPluginTests { #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 + "
", @@ -996,6 +1009,7 @@ extension UserProviderPluginTests { 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 { diff --git a/docs/plugins.md b/docs/plugins.md index 8c4c47e5e9e..6477fdd8eba 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -133,7 +133,10 @@ defineProvider({ 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. File icons are not supported. + 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