Space in Finder opens the board — a Quick Look preview extension that outlines a .kanban package
A board is a folder, and a folder previews as a folder. `KanbanQuickLook.appex` gives it a document's preview instead: the board's name, its tint and its symbol, then its lanes in display order with each one's card count and its first few card titles. The reading is `BoardOutline` (Kanban/Storage), deliberately not `BoardLoader.load`. The loader throws on a half-broken board — right for opening one, wrong for pressing Space, where the honest answer is the part that reads; it visits every card and lists `attachments/` and counts `comments/` inside each; and it carries trash, tombstone migration and the defect stream, none of which renders. This walk never throws and is capped at every level (`BoardOutlineLimits`): 12 lanes shown of at most 100 considered, 6 card titles per lane of at most 200 parsed, counts by readdir-plus-stat up to 2000 per lane and never a parse. It re-derives nothing that decides *what* the answer is — `FrontmatterDocument` parses, `IntegrityRules.isIdentityShaped` says what a lane or a card is, `BoardLoader.directoryCandidates` supplies the stray tolerance, `Ranks` supplies display order, `Palette` resolves colours — only *how far to look*. The reply is HTML, the one data-based reply that reflows: a Quick Look panel is resized by the user and a board outline is a wrapping row of columns, so a drawing block baked at a fixed `contentSize` would be the wrong size a moment later. It gets vector text, its own scrolling and light/dark for free. The board tint is a wash under the title and a lane's edge accent — never under text, because a preview has none of `ContrastMath`'s ink-picking machinery and should not grow one. The extension compiles `Kanban/Storage` whole, the `KanbanMobile` arrangement — the directory is one unit in practice, so a narrower list is not on offer. `STORAGE_ONLY` is new: EchoLedger's consumer sections speak the live store's vocabulary, and the phone's `#if os(macOS)` cannot exclude them from a target that *is* macOS. Platform, and layer. Nothing else defines it. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Quartz
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// **The preview's whole appearance** — a `BoardOutline` turned into one self-contained HTML
|
||||
/// document plus the images it references.
|
||||
///
|
||||
/// ### Why the styling values are resolved here and not in the walk
|
||||
///
|
||||
/// `BoardOutline` carries `background`, `icon` and `iconColor` **as written** — a palette name, a
|
||||
/// hex, an SF Symbol name — exactly as `BoardModel` does, because "an unrecognized value renders as
|
||||
/// the default and the bytes stay as written" is a *rendering* rule and this file is the renderer
|
||||
/// (Palette.swift ▸ "Lenient, never an error"; ItemSymbol.swift ▸ the same for symbols). Resolution
|
||||
/// goes through `Palette` itself rather than through a second copy of the twelve-and-twelve table,
|
||||
/// so a board tinted `smokey-ocean` is the same colour in the preview as it is in the app.
|
||||
///
|
||||
/// ### Why the board tint is a wash and not a band
|
||||
///
|
||||
/// A board's colour is chosen to sit *behind a whole window* of app chrome, where the app picks
|
||||
/// readable ink for it against the window backdrop (`ContrastMath` ▸ `BoardTextInk`). A preview has
|
||||
/// no such machinery and should not grow one for a header strip, so the tint is applied at low alpha
|
||||
/// under the title and at full strength only as a lane's edge accent — narrow marks that carry no
|
||||
/// text. Contrast is then never in question, in either appearance, for any of the 16.7 million
|
||||
/// values a hand-written hex can be.
|
||||
enum BoardPreviewPage {
|
||||
|
||||
/// The size Quick Look draws its loading state at, and its opening guess at the panel. Only a
|
||||
/// hint: HTML has no intrinsic size, so the panel is the user's to resize and the page reflows.
|
||||
static let contentSizeHint = CGSize(width: 820, height: 560)
|
||||
|
||||
/// The board's default symbol when it carries no `icon` — `ItemSymbol.board`'s value
|
||||
/// (03-board-ui.md § Styling ▸ Capabilities: "`icon`: SF Symbol per item with per-level
|
||||
/// defaults").
|
||||
///
|
||||
/// Restated rather than shared: `ItemSymbol` reaches `StyleLevel` for its per-level lookup, which
|
||||
/// reaches the live store, which has no business inside a preview extension. One string is the
|
||||
/// cheaper coupling, and it is the board's default — the only one of the three this file can ever
|
||||
/// need.
|
||||
private static let defaultSymbol = "rectangle.split.3x1"
|
||||
|
||||
/// The mark's two bakings. A PNG has one colour and a preview has two appearances, so the page
|
||||
/// carries both and lets CSS choose — see `attachments(for:)`.
|
||||
private static let lightMarkID = "board-mark-light"
|
||||
private static let darkMarkID = "board-mark-dark"
|
||||
|
||||
// MARK: - The page
|
||||
|
||||
static func html(for outline: BoardOutline) -> Data {
|
||||
var out = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
|
||||
out += "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
||||
out += "<title>\(escape(outline.title))</title>\n"
|
||||
out += "<style>\n\(stylesheet(for: outline))\n</style>\n</head>\n<body>\n"
|
||||
out += "<main>\n"
|
||||
out += header(for: outline)
|
||||
out += lanes(of: outline)
|
||||
out += "</main>\n</body>\n</html>\n"
|
||||
return Data(out.utf8)
|
||||
}
|
||||
|
||||
private static func header(for outline: BoardOutline) -> String {
|
||||
var out = "<header class=\"board\">\n"
|
||||
out += "<img class=\"mark light-only\" src=\"cid:\(lightMarkID)\" alt=\"\">"
|
||||
out += "<img class=\"mark dark-only\" src=\"cid:\(darkMarkID)\" alt=\"\">\n"
|
||||
out += "<div class=\"board-text\">\n"
|
||||
out += "<h1>\(escape(outline.title))</h1>\n"
|
||||
out += "<p class=\"meta\">\(escape(laneCountPhrase(for: outline)))</p>\n"
|
||||
out += "</div>\n</header>\n"
|
||||
return out
|
||||
}
|
||||
|
||||
/// "3 lanes", "1 lane", "No lanes yet" — and "100+ lanes" for the board whose count the walk
|
||||
/// stopped taking (`BoardOutline.laneCountIsCapped`), because a number that is not the number is
|
||||
/// worse than an honest floor.
|
||||
private static func laneCountPhrase(for outline: BoardOutline) -> String {
|
||||
guard outline.laneCount > 0 else { return "No lanes yet" }
|
||||
let count = outline.laneCountIsCapped ? "\(outline.laneCount)+" : "\(outline.laneCount)"
|
||||
return "\(count) \(outline.laneCount == 1 && !outline.laneCountIsCapped ? "lane" : "lanes")"
|
||||
}
|
||||
|
||||
private static func lanes(of outline: BoardOutline) -> String {
|
||||
// A board with nothing in it, and a folder that is not a board at all, reach here the same
|
||||
// way and say so once — the header's "No lanes yet" is the count, this is the invitation.
|
||||
guard outline.laneCount > 0 else {
|
||||
return "<p class=\"empty\">This board has no lanes yet.</p>\n"
|
||||
}
|
||||
|
||||
var out = "<div class=\"lanes\">\n"
|
||||
for lane in outline.lanes {
|
||||
let accent = cssColor(lane.background).map { " style=\"--accent: \($0)\"" } ?? ""
|
||||
out += "<section class=\"lane\"\(accent)>\n"
|
||||
out += "<h2><span class=\"lane-title\">\(escape(lane.title ?? "Untitled"))</span>"
|
||||
out += "<span class=\"count\">\(escape(cardCountPhrase(for: lane)))</span></h2>\n"
|
||||
if lane.cardTitles.isEmpty {
|
||||
out += "<p class=\"empty-lane\">Empty</p>\n"
|
||||
} else {
|
||||
out += "<ul>\n"
|
||||
for title in lane.cardTitles {
|
||||
out += "<li>\(escape(title ?? "Untitled"))</li>\n"
|
||||
}
|
||||
out += "</ul>\n"
|
||||
}
|
||||
if lane.hiddenCardCount > 0 {
|
||||
out += "<p class=\"more\">+\(lane.hiddenCardCount) more</p>\n"
|
||||
}
|
||||
out += "</section>\n"
|
||||
}
|
||||
out += "</div>\n"
|
||||
|
||||
if outline.hiddenLaneCount > 0 {
|
||||
let suffix = outline.laneCountIsCapped ? "+" : ""
|
||||
out += "<p class=\"more-lanes\">+\(outline.hiddenLaneCount)\(suffix) more lanes</p>\n"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// A lane's badge: its exact card count, or the floor the count stopped at
|
||||
/// (`LaneOutline.cardCountIsCapped`).
|
||||
private static func cardCountPhrase(for lane: LaneOutline) -> String {
|
||||
lane.cardCountIsCapped ? "\(lane.cardCount)+" : "\(lane.cardCount)"
|
||||
}
|
||||
|
||||
// MARK: - Style
|
||||
|
||||
private static func stylesheet(for outline: BoardOutline) -> String {
|
||||
let wash = cssColor(outline.background, alpha: 0.16) ?? "transparent"
|
||||
let rule = cssColor(outline.background, alpha: 0.55) ?? "var(--hairline)"
|
||||
return """
|
||||
:root {
|
||||
--ink: #1d1d1f;
|
||||
--ink-quiet: #6e6e73;
|
||||
--page: #ffffff;
|
||||
--plate: #f5f5f7;
|
||||
--hairline: rgba(0, 0, 0, 0.12);
|
||||
--wash: \(wash);
|
||||
--rule: \(rule);
|
||||
--accent: var(--hairline);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--ink: #f5f5f7;
|
||||
--ink-quiet: #98989d;
|
||||
--page: #1e1e1e;
|
||||
--plate: #2a2a2c;
|
||||
--hairline: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--page);
|
||||
color: var(--ink);
|
||||
font: 13px/1.45 -apple-system, "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
main { padding: 20px 22px 26px; }
|
||||
header.board {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--wash);
|
||||
border-bottom: 2px solid var(--rule);
|
||||
/* The wash and the rule are both the board's own colour, so a board tinted the same shade
|
||||
as the page it is previewed on paints nothing at all — a `#1E1E1E` board in the dark
|
||||
appearance. This hairline is what keeps the block a block regardless: the tint is the
|
||||
board's, the edge is the page's. */
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
}
|
||||
.mark { width: 34px; height: 34px; flex: none; object-fit: contain; }
|
||||
.light-only { display: block; }
|
||||
.dark-only { display: none; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.light-only { display: none; }
|
||||
.dark-only { display: block; }
|
||||
}
|
||||
.board-text { min-width: 0; }
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.meta { margin: 2px 0 0; color: var(--ink-quiet); font-size: 12px; }
|
||||
.lanes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.lane {
|
||||
flex: 1 1 190px;
|
||||
min-width: 170px;
|
||||
max-width: 300px;
|
||||
padding: 10px 12px 10px 13px;
|
||||
border-radius: 8px;
|
||||
background: var(--plate);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.lane h2 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.lane-title { overflow-wrap: anywhere; }
|
||||
.count {
|
||||
flex: none;
|
||||
color: var(--ink-quiet);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 400;
|
||||
}
|
||||
.lane ul { margin: 0; padding: 0; list-style: none; }
|
||||
.lane li {
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid var(--hairline);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.lane li:first-child { border-top: none; padding-top: 0; }
|
||||
.more, .empty-lane { margin: 6px 0 0; color: var(--ink-quiet); font-size: 12px; }
|
||||
.more-lanes, .empty { margin: 14px 0 0; color: var(--ink-quiet); font-size: 12px; }
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - Colour
|
||||
|
||||
/// A stored styling value as a CSS colour — `Palette`'s resolution (a palette name or a
|
||||
/// `#RRGGBB[AA]` hex), re-emitted as `rgba()` so the value's own alpha and the caller's can be
|
||||
/// combined. `nil` for every shape `Palette` declines to read, which every call site treats as
|
||||
/// "there is no colour" rather than as an error.
|
||||
private static func cssColor(_ value: String?, alpha: Double = 1) -> String? {
|
||||
guard let value,
|
||||
let color = Palette.nsColor(for: value)?.usingColorSpace(.sRGB)
|
||||
else { return nil }
|
||||
let channel = { (component: CGFloat) in Int((component * 255).rounded()) }
|
||||
let combined = Double(color.alphaComponent) * alpha
|
||||
return "rgba(\(channel(color.redComponent)), \(channel(color.greenComponent)), "
|
||||
+ "\(channel(color.blueComponent)), \(String(format: "%.3f", combined)))"
|
||||
}
|
||||
|
||||
// MARK: - The mark
|
||||
|
||||
/// The board's symbol, baked twice: once in the light appearance's ink and once in the dark
|
||||
/// one's, so a page rendered in either reads correctly. A board that names its own `iconColor`
|
||||
/// gets that colour in both — a chosen tint is not an appearance-dependent value.
|
||||
///
|
||||
/// Both entries are always present, even when the symbol will not resolve: an attachment
|
||||
/// dictionary missing a `cid:` the page references is a broken-image glyph, and this way a bad
|
||||
/// `icon:` degrades to the *default* symbol exactly as it does in the app.
|
||||
static func attachments(for outline: BoardOutline) -> [String: QLPreviewReplyAttachment] {
|
||||
let symbol = outline.icon.flatMap(resolvedSymbol) ?? defaultSymbol
|
||||
let chosen = outline.iconColor.flatMap(Palette.nsColor(for:))
|
||||
var attachments: [String: QLPreviewReplyAttachment] = [:]
|
||||
let inks: [(id: String, fallback: NSColor)] = [
|
||||
(lightMarkID, NSColor(srgbRed: 0.11, green: 0.11, blue: 0.12, alpha: 1)),
|
||||
(darkMarkID, NSColor(srgbRed: 0.96, green: 0.96, blue: 0.97, alpha: 1)),
|
||||
]
|
||||
for ink in inks {
|
||||
guard let png = markPNG(symbol: symbol, tint: chosen ?? ink.fallback) else { continue }
|
||||
attachments[ink.id] = QLPreviewReplyAttachment(data: png, contentType: .png)
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
/// `name` if the running system can draw it as an SF Symbol — `ItemSymbol.exists(_:)`'s rule and
|
||||
/// its reasoning ("`NSImage(systemSymbolName:)` is the only honest test").
|
||||
private static func resolvedSymbol(_ name: String) -> String? {
|
||||
guard !name.isEmpty, NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil
|
||||
else { return nil }
|
||||
return name
|
||||
}
|
||||
|
||||
/// One symbol as tinted PNG bytes.
|
||||
///
|
||||
/// Drawn at twice the size the page displays it at (34 CSS pixels), which is the whole reason for
|
||||
/// the point size below: a PNG has no vector fallback, and a Retina panel would show a 34-pixel
|
||||
/// image soft. `nil` for a symbol the system declines to draw or an image that will not encode —
|
||||
/// the page then simply shows no mark, which is `Palette`'s "there is no colour, so show none"
|
||||
/// one medium over.
|
||||
private static func markPNG(symbol: String, tint: NSColor) -> Data? {
|
||||
let configuration = NSImage.SymbolConfiguration(pointSize: 68, weight: .regular)
|
||||
guard let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)?
|
||||
.withSymbolConfiguration(configuration)
|
||||
else { return nil }
|
||||
|
||||
// `sourceAtop` over the drawn glyph rather than a palette configuration: it flattens a
|
||||
// multicolour symbol to the one tint, which is what the app's own `foregroundStyle` does to
|
||||
// it anyway, and it cannot come out black the way a template image can.
|
||||
let tinted = NSImage(size: image.size, flipped: false) { rect in
|
||||
image.draw(in: rect)
|
||||
tint.setFill()
|
||||
rect.fill(using: .sourceAtop)
|
||||
return true
|
||||
}
|
||||
|
||||
guard let tiff = tinted.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiff)
|
||||
else { return nil }
|
||||
return bitmap.representation(using: .png, properties: [:])
|
||||
}
|
||||
|
||||
// MARK: - Escaping
|
||||
|
||||
/// Every character that could end an attribute, open a tag or start an entity — the page is
|
||||
/// assembled from board text that is entirely the user's, so nothing reaches the document without
|
||||
/// passing through here.
|
||||
private static func escape(_ text: String) -> String {
|
||||
var escaped = ""
|
||||
escaped.reserveCapacity(text.count)
|
||||
for character in text {
|
||||
switch character {
|
||||
case "&": escaped += "&"
|
||||
case "<": escaped += "<"
|
||||
case ">": escaped += ">"
|
||||
case "\"": escaped += """
|
||||
case "'": escaped += "'"
|
||||
default: escaped.append(character)
|
||||
}
|
||||
}
|
||||
return escaped
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
import Quartz
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// **Space in Finder, on a `.kanban` package** — the whole of this extension's job.
|
||||
///
|
||||
/// A board is a folder, and a folder previews as a folder: an icon and nothing else. This gives it a
|
||||
/// document's preview instead — its name, its tint, its lanes in order, each lane's card count and
|
||||
/// its first few card titles — built from `BoardOutline.read`, the capped read-only walk that lives
|
||||
/// beside the storage format it reads (Kanban/Storage/BoardOutline.swift).
|
||||
///
|
||||
/// ### Data-based, not view-based
|
||||
///
|
||||
/// `QLPreviewProvider` is the data-based half of the Quick Look preview API: the extension answers
|
||||
/// with *content* (`QLPreviewReply`) and Quick Look owns the window. The view-based half
|
||||
/// (`QLPreviewingController` on an `NSViewController`) would put a SwiftUI hierarchy on screen, and
|
||||
/// this preview has no use for one — it is static by design (v1: no interactivity, no attachment or
|
||||
/// image loading), so a view controller would be a lifecycle to keep honest in exchange for nothing.
|
||||
///
|
||||
/// The reply is **HTML**, which is the one supported data type that reflows: a Quick Look panel is
|
||||
/// resized by the user and a board summary is a wrapping row of columns, so a fixed `contentSize`
|
||||
/// drawing block or a rendered image would be the wrong shape the moment the panel is not the size
|
||||
/// it was baked at. HTML also gets vector text, selectable text, its own scrolling, and light/dark
|
||||
/// through `prefers-color-scheme` — all of which a `CGContext` reply would have to reinvent.
|
||||
///
|
||||
/// ### Where the work happens
|
||||
///
|
||||
/// Everything is inside the reply's data-creation block, which is where Apple's own documentation
|
||||
/// puts it ("Heavy lifting should be done inside of the dataCreationBlock instead of when creating
|
||||
/// the QLPreviewReply"): `providePreview` returns immediately with a size hint, Quick Look draws its
|
||||
/// loading state at the right size, and the walk runs while it does.
|
||||
///
|
||||
/// ### Reading the board
|
||||
///
|
||||
/// The extension is sandboxed with read access to the URL it was handed and nothing else, which is
|
||||
/// exactly what the walk needs — it opens `index.md` files under `request.fileURL` and never looks
|
||||
/// outside the package. `BoardOutline.read` does not throw: a folder that is not a board, a board
|
||||
/// with a broken `index.md`, a permissions race mid-walk all yield an outline of whatever *did* read,
|
||||
/// so this method has no error path of its own and Quick Look never sees a failed preview where it
|
||||
/// could have shown a name.
|
||||
final class BoardPreviewProvider: QLPreviewProvider, QLPreviewingController {
|
||||
|
||||
func providePreview(for request: QLFilePreviewRequest) async throws -> QLPreviewReply {
|
||||
let boardRoot = request.fileURL
|
||||
return QLPreviewReply(
|
||||
dataOfContentType: .html,
|
||||
contentSize: BoardPreviewPage.contentSizeHint
|
||||
) { reply in
|
||||
let outline = BoardOutline.read(boardRoot: boardRoot)
|
||||
reply.stringEncoding = .utf8
|
||||
// The panel's own title bar. Left empty, Quick Look uses the file name — which is the
|
||||
// folder name, and therefore *wrong* for every board whose `title:` differs from it.
|
||||
reply.title = outline.title
|
||||
reply.attachments = BoardPreviewPage.attachments(for: outline)
|
||||
return BoardPreviewPage.html(for: outline)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Lanework Board Preview</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Lanework Board Preview</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026 rzen</string>
|
||||
<!-- The classic app-extension shape, matching Xcode's own "Quick Look Preview Extension"
|
||||
template: Quick Look's preview point is an NSExtension point, not an ExtensionKit one.
|
||||
|
||||
`QLIsDataBasedPreview` is what selects the data-based half of the API — the principal
|
||||
class is a `QLPreviewProvider` answering with content (`QLPreviewReply`) rather than an
|
||||
NSViewController putting a view on screen. See BoardPreviewProvider.swift for why.
|
||||
|
||||
`QLSupportedContentTypes` names the board type the *host app* exports (Kanban/Info.plist ▸
|
||||
UTExportedTypeDeclarations, `LSHandlerRank: Owner`). The extension deliberately re-declares
|
||||
nothing: it ships inside that app's bundle, so LaunchServices already knows the type, and a
|
||||
second declaration of one identifier is how two bundles end up disagreeing about it.
|
||||
|
||||
`QLSupportsSearchableItems` is false — this previews files, and there is no Spotlight
|
||||
searchable-item vocabulary behind a board. -->
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.quicklook.preview</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).BoardPreviewProvider</string>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>QLSupportedContentTypes</key>
|
||||
<array>
|
||||
<string>dev.rzen.indie.kanban-board</string>
|
||||
</array>
|
||||
<key>QLSupportsSearchableItems</key>
|
||||
<false/>
|
||||
<key>QLIsDataBasedPreview</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<!-- **Read-only, and that is the whole of it** — the two keys Xcode's own Quick Look extension
|
||||
template sets (`ENABLE_APP_SANDBOX`, `ENABLE_USER_SELECTED_FILES = readonly`), written out
|
||||
here because this project declares entitlements in files rather than in build settings.
|
||||
|
||||
Quick Look hands the extension a sandbox extension for the one URL it was asked to preview;
|
||||
this key is what lets that extension be consumed. Nothing else is needed and nothing else is
|
||||
asked for: the walk reads `index.md` files under that URL and writes nothing, anywhere.
|
||||
|
||||
Deliberately absent, and each for a stated reason: no `read-write` (a preview must never be
|
||||
able to change the board it is describing), no `bookmarks.app-scope` (the extension resolves
|
||||
no bookmark — it is handed a live URL per request and keeps nothing between them), no
|
||||
`network.client` (the page is self-contained HTML with its images inlined as reply
|
||||
attachments; there is nothing to fetch), and no `print`. -->
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user