import CoreGraphics import ImageIO import SwiftUI import os // MARK: - BoardBackdrop /// **The board background's image half** (03-board-ui.md § Styling ▸ Capabilities; the `background` /// mapping's `image` subkey, 01-storage-format.md § Frontmatter). /// /// ### The path is relative, and it stays inside the board /// /// `image: sunset.jpg` names a file in the board folder; `image: art/sunset.jpg` names one in a /// subfolder of it. An absolute path, or any path that climbs out with `..`, resolves to **nothing** /// — the same lenient degrade as an unrecognized colour, and for the same two reasons. A `.kanban` /// folder is a document: it is what gets copied, zipped, synced and handed to somebody else, and a /// background pointing at `/Users/someone/Pictures` would silently stop working the moment it left /// this Mac. And the sandbox would refuse the read anyway — the board's own security-scoped access /// is the only thing this app holds — so the rule the containment check states is the rule the /// system would enforce one layer down, stated where it can be explained instead of failing. /// /// The check is **lexical**, which is what makes it testable without a filesystem, and it is not the /// security boundary: a symlink inside the board pointing anywhere at all still resolves here and is /// still refused by the sandbox when the bytes are asked for. That is the correct division — 01's /// "symlinks are never traversed" governs what the *loader* renders as items, and this reads bytes /// nobody has an identity claim on. /// /// ### Nothing here decides whether the file is any good /// /// A path that resolves, a file that is missing, and a file that is not an image all end the same /// way: no image, no banner, no defect, bytes untouched. There is no editing UI for the field at all /// (Controls: "the raw file is the escape hatch"), so the one person who can be wrong about it is /// the one person looking at the folder. enum BoardBackdrop { /// The longest edge, in pixels, the backdrop is ever decoded at. /// /// Generous enough for a 6K display's short side and for the Retina backing of any window a /// board is realistically shown in, and small enough that a 60-megapixel photo dropped in the /// folder never becomes a 240 MB decode on a window resize. ImageIO does the reduction while it /// reads (`decode`), so the full-size bitmap is never materialized at all. static let maximumPixelSize = 3072 private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-backdrop") /// Where `path` lands inside `root`, or `nil` when it lands nowhere this board may read. /// /// Standardized before the comparison so `art/../sunset.jpg` is recognized as the file it names /// — the check is about where the path *ends up*, not how it is spelled. The trailing separator /// on the root is what keeps a sibling board named `Boards/Work.kanban.backup` from passing a /// prefix test against `Boards/Work.kanban`. /// /// `~` is not expanded and is not special: a file honestly named `~notes.png` sitting in the /// board folder resolves, because only the shell ever meant anything else by that character. static func imageURL(named path: String, inBoardRoot root: URL) -> URL? { // An absolute path has to be rejected before it is appended, not after: appending `/etc/x` // to a root yields `/etc/x`, which *passes* containment while naming a file the author // plainly did not mean. guard !path.isEmpty, !path.hasPrefix("/") else { return nil } let root = root.standardizedFileURL let candidate = root.appendingPathComponent(path).standardizedFileURL guard candidate.path.hasPrefix(root.path + "/") else { return nil } return candidate } /// This board's backdrop image, where it has a readable one to name. static func imageURL(for board: BoardModel, root: URL) -> URL? { guard let path = board.backgroundImage.value else { return nil } return imageURL(named: path, inBoardRoot: root) } /// Whether this board paints a background of its own — **the window-chrome predicate** /// (`BoardWindowHost`, `HostedWindowController.setExtendsContentUnderTitlebar`): a board with one /// runs its content under a transparent title bar, and a board without one keeps the standard /// chrome exactly as it has always looked. /// /// It asks the *resolved* image URL rather than merely whether the key reads, so a path that /// could never paint anything — absolute, or climbing out of the board — leaves the chrome alone /// instead of producing a transparent title bar over the standard background. It does **not** /// ask whether the file exists: that is a disk touch, this is read on every board render, and a /// declared-but-missing image renders as the frosted strip alone — which is the honest picture of /// a board that asked for a backdrop it has not got. static func isCustom(_ board: BoardModel, root: URL) -> Bool { Palette.color(for: board.background) != nil || imageURL(for: board, root: root) != nil } // MARK: Reading the bytes /// The file's identity as far as reloading is concerned — modification date and size. /// /// Both, because either alone is forgeable by an ordinary copy: a file replaced within the /// timestamp's resolution keeps its date, and a re-export at the same instant rarely keeps its /// byte count too. Missing values (a file that is not there) compare equal to each other, which /// is what stops a board naming a missing image from re-decoding on every reload. struct Stamp: Equatable, Sendable { var modified: Date? var size: Int? } static func stamp(of url: URL) -> Stamp { let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]) return Stamp(modified: values?.contentModificationDate, size: values?.fileSize) } /// Decodes the file at `url`, downsampled to `maximumPixelSize` on its longest edge — or `nil` /// for anything that is not a readable image. /// /// **ImageIO's thumbnail path, not a full decode plus a resize**: `CGImageSourceCreateThumbnail /// AtIndex` reads at a reduced scale, so the peak allocation is the *output* size rather than /// the file's. `FromImageAlways` is what makes it a downsample rather than a lottery — without /// it a JPEG carrying its own small embedded thumbnail would answer with that instead of the /// picture. `WithTransform` applies the EXIF orientation, so a photo shot in portrait is not /// laid on its side. /// /// Never call this on the main actor; see `BoardBackdropImage`'s task. static func decode(_ url: URL) -> CGImage? { guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize, ] guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { logger.debug("board backdrop image could not be decoded") return nil } return image } } // MARK: - BoardBackdropImage /// The decoded backdrop, drawn to fill (03-board-ui.md § Styling ▸ Capabilities). /// /// **Fill, cropped — never letterboxed and never stretched.** A background is a surface, so it /// covers the window whatever its aspect ratio; the alternative would put bars of the underlying /// colour along two edges and make the board look broken rather than styled. /// /// ### The load is asynchronous, and that is the whole design of this view /// /// A board is opened by double-clicking a folder, and the folder may contain a 60-megapixel /// photograph. Decoding that on the main actor during a body evaluation is a visible hitch on open /// and a worse one on every subsequent reload, so the work happens off it and the view simply has /// nothing to draw until it lands — under the board's colour, which is already painted beneath. /// /// The task is keyed on the URL and on the store's landed-reload count, which is the board's /// FSEvents pulse: replacing `sunset.jpg` in Finder changes no *model* value, so the snapshot comes /// back equal and `snapshotGeneration` deliberately does not move (`BoardStore.landedReloads`) — /// keying on the generation would mean an edited image never reloaded. Every re-key costs one /// `stat`; only a file that actually changed costs a decode. struct BoardBackdropImage: View { let url: URL /// The board's landed-reload count — see the type's note. Not read from a store here because /// this view has no other reason to hold one. let reloads: Int /// What is on screen, and what it was decoded from. One value rather than three `@State`s so a /// URL, its stamp and its bitmap can never disagree about which file is being shown. @State private var loaded: Loaded? private struct Loaded { let url: URL let stamp: BoardBackdrop.Stamp let image: CGImage } var body: some View { // `Color.clear` establishes the frame the image fills and is what `clipped` trims against; // the overlay is what overflows it. Decorative, because a board background is decoration in // the precise sense 10-accessibility.md means — it carries no information VoiceOver could // usefully say, and the ink rule keeps the text on it legible on its own. Color.clear .overlay { if let loaded { Image(decorative: loaded.image, scale: 1) .resizable() .aspectRatio(contentMode: .fill) } } .clipped() .task(id: Key(url: url, reloads: reloads)) { await reload() } } /// The `.task` identity: the file, and the board's pulse. private struct Key: Equatable { let url: URL let reloads: Int } /// Re-decodes when the bytes have changed, and only then. /// /// `Task.detached` rather than a bare `await` on a `nonisolated` function, so the hop off this /// view's actor is stated rather than inferred from whatever the language mode currently makes /// of an async call. Cancellation is checked on the way back instead of forwarded into it: both /// halves are short, and a stale bitmap assigned to a view that has gone away is the failure /// worth preventing. private func reload() async { let url = url let stamp = await Task.detached(priority: .utility) { BoardBackdrop.stamp(of: url) }.value if let loaded, loaded.url == url, loaded.stamp == stamp { return } guard !Task.isCancelled else { return } let decoded = await Task.detached(priority: .userInitiated) { BoardBackdrop.decode(url) }.value guard !Task.isCancelled else { return } // A failure clears what was there: the file the board names is the file it shows, and // holding the previous picture would make a broken path look like a working one. loaded = decoded.map { Loaded(url: url, stamp: stamp, image: $0) } } }