diff --git a/slab/DMGIFY.md b/slab/DMGIFY.md index c4474225bb..9cbb097619 100644 --- a/slab/DMGIFY.md +++ b/slab/DMGIFY.md @@ -1,10 +1,20 @@ # Dmgify -`dmgify` turns a local, dependency-free HTML directory into a universal macOS -Electron application and distributable DMG. It is the reusable form of Menu -Band's certificate pipeline: +`dmgify` turns a local offline directory into a universal macOS application and +distributable DMG. It is the reusable form of Menu Band's certificate pipeline. +It has two runtimes: -1. wrap the directory in a sandboxed Electron shell; +- `electron` (default) wraps an arbitrary dependency-free HTML directory. +- `swift-gallery` builds a small native AppKit image archive from `manifest.json`. + It virtualizes the post grid, downsamples only visible images, caches decoded + thumbnails, offers a dense responsive thumbnail view and a sortable native + list, opens originals in Quick Look, and uses AppKit's dedicated sharing + toolbar item. Shares contain the original image files, untouched caption, + and canonical source URL. It has no WebView or Electron dependency. + +Both runtimes follow the same release chain: + +1. wrap the directory in its selected runtime; 2. build universal `arm64 + x86_64` code; 3. sign with the keychain's Developer ID Application identity and hardened runtime; 4. submit the app to Apple's notary service and staple its ticket; @@ -13,7 +23,7 @@ Band's certificate pipeline: 7. verify signature, staple, and Gatekeeper acceptance; and 8. emit a JSON receipt next to the DMG. -The generated shell also embeds a signed universal AppKit helper. Offline +The generated Electron shell also embeds a signed universal AppKit helper. Offline archives can opt into the preload's two narrow APIs through `window.archiveBridge`: `share(paths)` opens the native macOS sharing service picker, while `export(paths)` copies original bundled files to a user-selected @@ -53,6 +63,7 @@ node slab/bin/dmgify.mjs plan \ node slab/bin/dmgify.mjs build \ --source /absolute/path/to/archive \ + --runtime swift-gallery \ --entry index.html \ --name "Example Archive" \ --bundle-id computer.aesthetic.examplearchive \ @@ -67,3 +78,8 @@ node slab/bin/dmgify.mjs verify \ Use repeated `--include` flags (or the MCP `include` array) to narrow the files embedded under `Contents/Resources/archive/`. Defaults omit common build and repository directories. + +The native gallery expects the archive manifest to contain `title`, `account`, +`counts.posts`, `counts.stills`, and posts with `shortcode`, `date`, `caption`, +and `stills[].file`. The ordinary `index.html` remains in the archive as a +portable double-click fallback, but the native app does not load it. diff --git a/slab/bin/dmgify-mcp.mjs b/slab/bin/dmgify-mcp.mjs index 3600698805..6288445068 100755 --- a/slab/bin/dmgify-mcp.mjs +++ b/slab/bin/dmgify-mcp.mjs @@ -1,12 +1,13 @@ #!/usr/bin/env node -// MCP front door for the local signed/notarized Electron DMG pipeline. +// MCP front door for the local signed/notarized archive DMG pipeline. import { buildDmg, planDmg, verifyArtifact } from "./dmgify.mjs"; import { httpPort, serveHttp, serveStdio } from "../../toolchain/mcp/http-front.mjs"; const sharedProperties = { - source: { type: "string", description: "Absolute local directory containing the offline HTML app." }, + source: { type: "string", description: "Absolute local directory containing the offline archive." }, entry: { type: "string", default: "index.html", description: "HTML entry path relative to source." }, + runtime: { type: "string", enum: ["electron", "swift-gallery"], default: "electron", description: "Use Electron for arbitrary HTML, or a virtualized native Swift/AppKit gallery for manifest.json image archives." }, name: { type: "string", description: "macOS product/display name." }, bundleId: { type: "string", description: "Reverse-DNS application identifier." }, version: { type: "string", default: "1.0.0", description: "Semantic version." }, @@ -19,12 +20,12 @@ const sharedProperties = { const TOOLS = [ { name: "dmgify_plan", - description: "Validate and size a local offline HTML directory before packaging. Read-only: reports entry/icon/builder/certificate/credential readiness and payload size.", + description: "Validate and size a local offline archive before packaging. Read-only: reports runtime/entry/icon/certificate/credential readiness and payload size.", inputSchema: { type: "object", properties: sharedProperties, required: ["source"] }, }, { name: "dmgify_build", - description: "Build a universal Electron app, sign it with the local Developer ID, notarize and staple the app, create/sign/notarize/staple a drag-to-Applications DMG, verify Gatekeeper, and write a receipt. SIDE EFFECTS: writes app/DMG artifacts and submits both to Apple's notary service.", + description: "Build a universal Electron or native Swift gallery app, sign it with the local Developer ID, notarize and staple the app, create/sign/notarize/staple a drag-to-Applications DMG, verify Gatekeeper, and write a receipt. SIDE EFFECTS: writes app/DMG artifacts and submits both to Apple's notary service.", inputSchema: { type: "object", properties: { diff --git a/slab/bin/dmgify.mjs b/slab/bin/dmgify.mjs index f533416c3a..8b59110652 100755 --- a/slab/bin/dmgify.mjs +++ b/slab/bin/dmgify.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// dmgify.mjs — turn a local HTML directory into a signed, notarized Electron DMG. +// dmgify.mjs — turn a local offline archive into a signed, notarized macOS DMG. // // This is the reusable form of Menu Band's release discipline: // Developer ID + hardened runtime → notarize/staple app → DMG + Applications @@ -22,6 +22,7 @@ const pexec = promisify(execFile); const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); export const REPO = resolve(SCRIPT_DIR, "../.."); const BUILDER = join(REPO, "node_modules/.bin/electron-builder"); +const NATIVE_GALLERY_SOURCE = join(REPO, "slab/native/dmgify-gallery.swift"); const DEFAULT_CREDENTIALS = join(REPO, "aesthetic-computer-vault/apple/app-specific-password.env"); const MAX_OUTPUT = 16 * 1024 * 1024; @@ -72,6 +73,8 @@ function normalizeOptions(options = {}) { const source = assertPathUnderHome(options.source, "source"); const entry = String(options.entry || "index.html").replace(/^\/+/, ""); if (entry.includes("..")) throw new Error("entry may not traverse outside source"); + const runtime = String(options.runtime || "electron"); + if (!["electron", "swift-gallery"].includes(runtime)) throw new Error(`runtime must be electron or swift-gallery: ${runtime}`); const name = safeName(options.name || basename(source)); const bundleId = String(options.bundleId || `computer.aesthetic.${slug(name).toLowerCase()}`); if (!/^[A-Za-z0-9.-]+$/.test(bundleId) || !bundleId.includes(".")) throw new Error(`invalid bundleId: ${bundleId}`); @@ -86,7 +89,7 @@ function normalizeOptions(options = {}) { ? options.include.map(String) : ["**/*", "!release{,/**}", "!dist{,/**}", "!node_modules{,/**}", "!.git{,/**}", "!.dmgify-work{,/**}"]; return { - source, entry, name, bundleId, version, output, icon, credentials, include, + source, entry, runtime, name, bundleId, version, output, icon, credentials, include, notarize: options.notarize !== false, category: String(options.category || "public.app-category.photography"), }; @@ -96,10 +99,11 @@ export async function planDmg(options = {}) { const opts = normalizeOptions(options); if (!(await exists(opts.source))) throw new Error(`source directory not found: ${opts.source}`); if (!(await lstat(opts.source)).isDirectory()) throw new Error(`source is not a directory: ${opts.source}`); - const entryPath = join(opts.source, opts.entry); - if (!(await exists(entryPath))) throw new Error(`entry not found: ${entryPath}`); + const entryPath = join(opts.source, opts.runtime === "swift-gallery" ? "manifest.json" : opts.entry); + if (!(await exists(entryPath))) throw new Error(`${opts.runtime === "swift-gallery" ? "manifest" : "entry"} not found: ${entryPath}`); if (opts.icon && !(await exists(opts.icon))) throw new Error(`icon not found: ${opts.icon}`); - if (!(await exists(BUILDER))) throw new Error(`electron-builder not installed: ${BUILDER}`); + if (opts.runtime === "electron" && !(await exists(BUILDER))) throw new Error(`electron-builder not installed: ${BUILDER}`); + if (opts.runtime === "swift-gallery" && !(await exists(NATIVE_GALLERY_SOURCE))) throw new Error(`native gallery source not found: ${NATIVE_GALLERY_SOURCE}`); const payload = await walkStats(opts.source); const identity = await developerIdentity().catch(() => null); return { @@ -151,6 +155,62 @@ async function makeIcns(source, work) { return target; } +const plistEscape = (value) => String(value) + .replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") + .replaceAll('"', """).replaceAll("'", "'"); + +async function makeNativeGalleryApp(plan, work, icon, identityHash) { + const appDir = join(plan.output, "mac-universal", `${plan.name}.app`); + const contents = join(appDir, "Contents"); + const macos = join(contents, "MacOS"); + const resources = join(contents, "Resources"); + const archive = join(resources, "archive"); + await rm(appDir, { recursive: true, force: true }); + await mkdir(macos, { recursive: true }); + await mkdir(archive, { recursive: true }); + + const sdk = (await run("xcrun", ["--sdk", "macosx", "--show-sdk-path"])).trim(); + const arm64 = join(work, "ArchiveGallery-arm64"); + const x64 = join(work, "ArchiveGallery-x64"); + const executable = join(macos, "ArchiveGallery"); + const compile = (target, output) => run("xcrun", ["swiftc", "-O", "-whole-module-optimization", + "-sdk", sdk, "-target", target, "-framework", "AppKit", "-framework", "ImageIO", + "-framework", "Quartz", NATIVE_GALLERY_SOURCE, "-o", output], { timeout: 20 * 60_000 }); + await compile("arm64-apple-macos11.0", arm64); + await compile("x86_64-apple-macos11.0", x64); + await run("lipo", ["-create", arm64, x64, "-output", executable]); + + if (icon) await cp(icon, join(resources, "AppIcon.icns")); + await run("rsync", ["-a", "--delete", + "--exclude", "/release/", "--exclude", "/dist/", "--exclude", "/node_modules/", + "--exclude", "/.git/", "--exclude", "/.dmgify-work/", + `${plan.source}/`, `${archive}/`]); + + const info = ` + + + CFBundleDevelopmentRegionen + CFBundleExecutableArchiveGallery + CFBundleIdentifier${plistEscape(plan.bundleId)} + CFBundleInfoDictionaryVersion6.0 + ${icon ? "CFBundleIconFileAppIcon" : ""} + CFBundleName${plistEscape(plan.name)} + CFBundleDisplayName${plistEscape(plan.name)} + CFBundlePackageTypeAPPL + CFBundleShortVersionString${plistEscape(plan.version)} + CFBundleVersion1 + LSApplicationCategoryType${plistEscape(plan.category)} + LSMinimumSystemVersion11.0 + NSHighResolutionCapable + +`; + await writeFile(join(contents, "Info.plist"), info); + await run("plutil", ["-lint", join(contents, "Info.plist")]); + await run("codesign", ["--force", "--deep", "--options", "runtime", "--timestamp", + "--sign", identityHash, appDir], { timeout: 20 * 60_000 }); + return appDir; +} + const shareHelperSource = `import AppKit final class ShareDelegate: NSObject, NSApplicationDelegate, NSSharingServicePickerDelegate, NSSharingServiceDelegate { @@ -408,7 +468,7 @@ export async function buildDmg(options = {}) { // symlink. Canonicalize the temporary root before writing any project file. const work = await realpath(await mkdtemp(join(tmpdir(), "dmgify-build-"))); const receipt = { schema: 1, startedAt: new Date().toISOString(), plan: { - source: plan.source, entry: plan.entry, name: plan.name, bundleId: plan.bundleId, + source: plan.source, entry: plan.entry, runtime: plan.runtime, name: plan.name, bundleId: plan.bundleId, version: plan.version, payload: plan.payload, include: plan.include, developerId: plan.developerId.name, }, steps: [] }; @@ -431,48 +491,52 @@ export async function buildDmg(options = {}) { try { await mkdir(plan.output, { recursive: true }); const icon = await step("icon", () => makeIcns(plan.icon, work)); - const shareHelper = await step("native share helper", () => makeShareHelper(work, plan.developerId.hash)); - await writeFile(join(work, "main.cjs"), mainSource().replace("__ENTRY__", plan.entry)); - await writeFile(join(work, "preload.cjs"), preloadSource); - await writeFile(join(work, "package.json"), JSON.stringify({ - name: slug(plan.name).toLowerCase(), version: plan.version, private: true, main: "main.cjs", - devDependencies: { electron: "38.1.2", "electron-builder": "26.0.12" }, - }, null, 2) + "\n"); - await writeFile(join(work, "entitlements.mac.plist"), entitlements); - const config = { - appId: plan.bundleId, - productName: plan.name, - asar: true, - directories: { output: plan.output, buildResources: work }, - files: ["main.cjs", "preload.cjs", "package.json"], - extraResources: [ - { from: plan.source, to: "archive", filter: plan.include }, - { from: shareHelper, to: "bin/dmgify-share" }, - ], - mac: { - category: plan.category, hardenedRuntime: true, gatekeeperAssess: false, - entitlements: join(work, "entitlements.mac.plist"), - entitlementsInherit: join(work, "entitlements.mac.plist"), - icon: icon || undefined, - // The helper is already a lipo-created universal Mach-O, so the two - // architecture staging apps intentionally contain identical bytes. - x64ArchFiles: "Contents/Resources/bin/dmgify-share", - // electron-osx-sign otherwise attempts a separate codesign invocation - // for every JPG/JSON in a large extraResources archive. The outer app - // signature still seals these bytes; they simply are not executable - // code that needs its own nested signature. - signIgnore: "^.*\\/Contents\\/Resources\\/archive(?:\\/.*)?$", - target: ["dir"], - }, - }; - const configPath = join(work, "electron-builder.json"); - await writeFile(configPath, JSON.stringify(config, null, 2) + "\n"); - await step("electron-builder universal app", () => run(BUILDER, - ["--projectDir", work, "--config", configPath, "--mac", "dir", "--universal"], - { cwd: REPO, timeout: 45 * 60_000 })); - - const appPath = join(plan.output, "mac-universal", `${plan.name}.app`); - if (!(await exists(appPath))) throw new Error(`electron-builder did not produce ${appPath}`); + let appPath; + if (plan.runtime === "swift-gallery") { + appPath = await step("native Swift gallery", () => makeNativeGalleryApp(plan, work, icon, plan.developerId.hash)); + } else { + const shareHelper = await step("native share helper", () => makeShareHelper(work, plan.developerId.hash)); + await writeFile(join(work, "main.cjs"), mainSource().replace("__ENTRY__", plan.entry)); + await writeFile(join(work, "preload.cjs"), preloadSource); + await writeFile(join(work, "package.json"), JSON.stringify({ + name: slug(plan.name).toLowerCase(), version: plan.version, private: true, main: "main.cjs", + devDependencies: { electron: "38.1.2", "electron-builder": "26.0.12" }, + }, null, 2) + "\n"); + await writeFile(join(work, "entitlements.mac.plist"), entitlements); + const config = { + appId: plan.bundleId, + productName: plan.name, + asar: true, + directories: { output: plan.output, buildResources: work }, + files: ["main.cjs", "preload.cjs", "package.json"], + extraResources: [ + { from: plan.source, to: "archive", filter: plan.include }, + { from: shareHelper, to: "bin/dmgify-share" }, + ], + mac: { + category: plan.category, hardenedRuntime: true, gatekeeperAssess: false, + entitlements: join(work, "entitlements.mac.plist"), + entitlementsInherit: join(work, "entitlements.mac.plist"), + icon: icon || undefined, + // The helper is already a lipo-created universal Mach-O, so the two + // architecture staging apps intentionally contain identical bytes. + x64ArchFiles: "Contents/Resources/bin/dmgify-share", + // electron-osx-sign otherwise attempts a separate codesign invocation + // for every JPG/JSON in a large extraResources archive. The outer app + // signature still seals these bytes; they simply are not executable + // code that needs its own nested signature. + signIgnore: "^.*\\/Contents\\/Resources\\/archive(?:\\/.*)?$", + target: ["dir"], + }, + }; + const configPath = join(work, "electron-builder.json"); + await writeFile(configPath, JSON.stringify(config, null, 2) + "\n"); + await step("electron-builder universal app", () => run(BUILDER, + ["--projectDir", work, "--config", configPath, "--mac", "dir", "--universal"], + { cwd: REPO, timeout: 45 * 60_000 })); + appPath = join(plan.output, "mac-universal", `${plan.name}.app`); + if (!(await exists(appPath))) throw new Error(`electron-builder did not produce ${appPath}`); + } await step("verify signed app", () => run("codesign", ["--verify", "--deep", "--strict", "--verbose=2", appPath])); let creds = null; @@ -491,7 +555,16 @@ export async function buildDmg(options = {}) { await step("notarize DMG", () => notarize(dmgPath, creds)); await step("staple DMG", () => run("xcrun", ["stapler", "staple", dmgPath])); } - const verification = await verifyArtifact(dmgPath); + const verification = plan.notarize ? await verifyArtifact(dmgPath) : { + artifact: dmgPath, + kind: "dmg", + bytes: (await stat(dmgPath)).size, + human: humanSize((await stat(dmgPath)).size), + ok: true, + checks: [{ name: "dmg signature", ok: true, + output: (await run("codesign", ["--verify", "--verbose=2", dmgPath])).trim().slice(-4000) }], + localOnly: true, + }; if (!verification.ok) throw new Error(`final DMG verification failed: ${JSON.stringify(verification.checks)}`); receipt.finishedAt = new Date().toISOString(); receipt.artifact = dmgPath; diff --git a/slab/native/dmgify-gallery.swift b/slab/native/dmgify-gallery.swift new file mode 100644 index 0000000000..10cbba1881 --- /dev/null +++ b/slab/native/dmgify-gallery.swift @@ -0,0 +1,683 @@ +import AppKit +import ImageIO +import Quartz + +struct ArchiveManifest: Decodable { + struct Counts: Decodable { let posts: Int; let stills: Int } + let title: String + let account: String + let counts: Counts + let posts: [ArchivePost] +} + +struct ArchivePost: Decodable { + let shortcode: String + let url: String + let date: String + let caption: String + let stills: [ArchiveStill] +} + +struct ArchiveStill: Decodable { + let file: String? + let alt: String? +} + +final class ThumbnailLoader { + static let shared = ThumbnailLoader() + private let cache = NSCache() + private let queue = DispatchQueue(label: "computer.aesthetic.dmgify.thumbnails", qos: .userInitiated, attributes: .concurrent) + + private init() { + cache.countLimit = 96 + cache.totalCostLimit = 192 * 1024 * 1024 + } + + func load(_ url: URL, maximumPixelSize: Int, completion: @escaping (NSImage?) -> Void) { + let key = "\(url.path)#\(maximumPixelSize)" as NSString + if let image = cache.object(forKey: key) { + completion(image) + return + } + queue.async { + let options: [CFString: Any] = [ + kCGImageSourceShouldCache: false, + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize, + kCGImageSourceShouldCacheImmediately: true, + ] + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + DispatchQueue.main.async { completion(nil) } + return + } + let image = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + let cost = cgImage.bytesPerRow * cgImage.height + self.cache.setObject(image, forKey: key, cost: cost) + DispatchQueue.main.async { completion(image) } + } + } +} + +final class ResponsiveFlowLayout: NSCollectionViewFlowLayout { + override func shouldInvalidateLayout(forBoundsChange newBounds: NSRect) -> Bool { true } +} + +final class ThumbnailTableCell: NSTableCellView { + private let picture = NSImageView() + private var representedURL: URL? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + picture.translatesAutoresizingMaskIntoConstraints = false + picture.imageScaling = .scaleProportionallyUpOrDown + picture.wantsLayer = true + picture.layer?.cornerRadius = 5 + picture.layer?.masksToBounds = true + picture.layer?.backgroundColor = NSColor.quaternaryLabelColor.cgColor + addSubview(picture) + NSLayoutConstraint.activate([ + picture.centerXAnchor.constraint(equalTo: centerXAnchor), + picture.centerYAnchor.constraint(equalTo: centerYAnchor), + picture.widthAnchor.constraint(equalToConstant: 56), + picture.heightAnchor.constraint(equalToConstant: 56), + ]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(url: URL?) { + representedURL = url + picture.image = nil + guard let url else { return } + ThumbnailLoader.shared.load(url, maximumPixelSize: 160) { [weak self] image in + guard let self, self.representedURL == url else { return } + self.picture.image = image + } + } +} + +final class ArchiveCardItem: NSCollectionViewItem { + static let identifier = NSUserInterfaceItemIdentifier("ArchiveCardItem") + private let picture = NSImageView() + private let dateLabel = NSTextField(labelWithString: "") + private let captionLabel = NSTextField(wrappingLabelWithString: "") + private let countLabel = NSTextField(labelWithString: "") + private var representedURL: URL? + + override var isSelected: Bool { + didSet { updateSelection() } + } + + override func loadView() { + let card = NSView() + card.wantsLayer = true + card.layer?.cornerRadius = 12 + card.layer?.masksToBounds = true + card.layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor + card.layer?.borderWidth = 1 + card.layer?.borderColor = NSColor.separatorColor.cgColor + + picture.translatesAutoresizingMaskIntoConstraints = false + picture.imageScaling = .scaleProportionallyUpOrDown + picture.imageAlignment = .alignCenter + picture.wantsLayer = true + picture.layer?.backgroundColor = NSColor.quaternaryLabelColor.cgColor + + dateLabel.translatesAutoresizingMaskIntoConstraints = false + dateLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .medium) + dateLabel.textColor = .secondaryLabelColor + dateLabel.lineBreakMode = .byTruncatingTail + + captionLabel.translatesAutoresizingMaskIntoConstraints = false + captionLabel.font = .systemFont(ofSize: 12) + captionLabel.textColor = .labelColor + captionLabel.maximumNumberOfLines = 2 + captionLabel.lineBreakMode = .byTruncatingTail + + countLabel.translatesAutoresizingMaskIntoConstraints = false + countLabel.font = .systemFont(ofSize: 11, weight: .semibold) + countLabel.textColor = .white + countLabel.alignment = .center + countLabel.wantsLayer = true + countLabel.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.68).cgColor + countLabel.layer?.cornerRadius = 10 + countLabel.isHidden = true + + card.addSubview(picture) + card.addSubview(dateLabel) + card.addSubview(captionLabel) + card.addSubview(countLabel) + NSLayoutConstraint.activate([ + picture.topAnchor.constraint(equalTo: card.topAnchor), + picture.leadingAnchor.constraint(equalTo: card.leadingAnchor), + picture.trailingAnchor.constraint(equalTo: card.trailingAnchor), + picture.heightAnchor.constraint(equalTo: card.widthAnchor), + dateLabel.topAnchor.constraint(equalTo: picture.bottomAnchor, constant: 12), + dateLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 13), + dateLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -13), + captionLabel.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 7), + captionLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 13), + captionLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -13), + captionLabel.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -13), + countLabel.topAnchor.constraint(equalTo: picture.topAnchor, constant: 10), + countLabel.trailingAnchor.constraint(equalTo: picture.trailingAnchor, constant: -10), + countLabel.heightAnchor.constraint(equalToConstant: 20), + countLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 34), + ]) + self.view = card + updateSelection() + } + + override func prepareForReuse() { + super.prepareForReuse() + representedURL = nil + picture.image = nil + dateLabel.stringValue = "" + captionLabel.stringValue = "" + countLabel.isHidden = true + } + + func configure(post: ArchivePost, archiveRoot: URL) { + let day = String(post.date.prefix(10)) + dateLabel.stringValue = "\(day) · \(post.shortcode)" + captionLabel.stringValue = post.caption.isEmpty ? "No caption" : post.caption + captionLabel.textColor = post.caption.isEmpty ? .secondaryLabelColor : .labelColor + if post.stills.count > 1 { + countLabel.stringValue = " 1 / \(post.stills.count) " + countLabel.isHidden = false + } else { + countLabel.isHidden = true + } + guard let file = post.stills.first?.file else { return } + let url = archiveRoot.appendingPathComponent(file) + representedURL = url + ThumbnailLoader.shared.load(url, maximumPixelSize: 480) { [weak self] image in + guard let self, self.representedURL == url else { return } + self.picture.image = image + } + } + + private func updateSelection() { + guard isViewLoaded else { return } + view.layer?.borderWidth = isSelected ? 3 : 1 + view.layer?.borderColor = (isSelected ? NSColor.controlAccentColor : NSColor.separatorColor).cgColor + } +} + +final class GalleryController: NSViewController, NSCollectionViewDataSource, + NSCollectionViewDelegate, NSCollectionViewDelegateFlowLayout, NSSearchFieldDelegate, + NSTableViewDataSource, NSTableViewDelegate, NSToolbarDelegate, + NSSharingServicePickerToolbarItemDelegate, QLPreviewPanelDataSource { + + private static let searchID = NSToolbarItem.Identifier("ArchiveSearch") + private static let modeID = NSToolbarItem.Identifier("ArchiveViewMode") + private static let shareID = NSToolbarItem.Identifier("ArchiveShare") + private let manifest: ArchiveManifest + private let archiveRoot: URL + private var filteredPosts: [ArchivePost] + private let collectionView = NSCollectionView() + private let tableView = NSTableView() + private let gridScroll = NSScrollView() + private let listScroll = NSScrollView() + private let searchField = NSSearchField() + private let modeControl = NSSegmentedControl() + private var sortColumn = "date" + private var sortAscending = false + private lazy var shareItem: NSSharingServicePickerToolbarItem = { + let item = NSSharingServicePickerToolbarItem(itemIdentifier: Self.shareID) + item.label = "Share" + item.paletteLabel = "Share selected post" + item.toolTip = "Share selected post" + item.delegate = self + item.isEnabled = false + return item + }() + private var previewURLs: [URL] = [] + private let subtitle = NSTextField(labelWithString: "") + + init(manifest: ArchiveManifest, archiveRoot: URL) { + self.manifest = manifest + self.archiveRoot = archiveRoot + self.filteredPosts = manifest.posts + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func loadView() { + let root = NSView() + root.wantsLayer = true + root.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + + let heading = NSTextField(labelWithString: manifest.title.components(separatedBy: " —").first ?? manifest.title) + heading.translatesAutoresizingMaskIntoConstraints = false + heading.font = .systemFont(ofSize: 29, weight: .bold) + heading.textColor = .labelColor + heading.lineBreakMode = .byTruncatingTail + + subtitle.translatesAutoresizingMaskIntoConstraints = false + subtitle.font = .systemFont(ofSize: 13, weight: .regular) + subtitle.textColor = .secondaryLabelColor + updateSubtitle() + + let header = NSView() + header.translatesAutoresizingMaskIntoConstraints = false + header.addSubview(heading) + header.addSubview(subtitle) + + let layout = ResponsiveFlowLayout() + layout.minimumInteritemSpacing = 10 + layout.minimumLineSpacing = 10 + layout.sectionInset = NSEdgeInsets(top: 14, left: 16, bottom: 20, right: 16) + collectionView.collectionViewLayout = layout + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.backgroundColors = [.windowBackgroundColor] + collectionView.dataSource = self + collectionView.delegate = self + collectionView.isSelectable = true + collectionView.allowsMultipleSelection = false + collectionView.register(ArchiveCardItem.self, forItemWithIdentifier: ArchiveCardItem.identifier) + let doubleClick = NSClickGestureRecognizer(target: self, action: #selector(handleDoubleClick(_:))) + doubleClick.numberOfClicksRequired = 2 + collectionView.addGestureRecognizer(doubleClick) + + gridScroll.translatesAutoresizingMaskIntoConstraints = false + gridScroll.documentView = collectionView + gridScroll.hasVerticalScroller = true + gridScroll.autohidesScrollers = true + gridScroll.drawsBackground = false + + func makeColumn(_ id: String, _ title: String, _ width: CGFloat, _ minimum: CGFloat) -> NSTableColumn { + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(id)) + column.title = title + column.width = width + column.minWidth = minimum + column.sortDescriptorPrototype = NSSortDescriptor(key: id, ascending: id != "date") + return column + } + let thumbnailColumn = makeColumn("thumbnail", "", 76, 70) + thumbnailColumn.maxWidth = 82 + thumbnailColumn.sortDescriptorPrototype = nil + tableView.addTableColumn(thumbnailColumn) + tableView.addTableColumn(makeColumn("date", "Date", 106, 96)) + tableView.addTableColumn(makeColumn("caption", "Original caption", 420, 220)) + let imagesColumn = makeColumn("images", "Images", 68, 62) + imagesColumn.maxWidth = 100 + tableView.addTableColumn(imagesColumn) + tableView.addTableColumn(makeColumn("shortcode", "Shortcode", 124, 110)) + tableView.delegate = self + tableView.dataSource = self + tableView.rowHeight = 68 + tableView.intercellSpacing = NSSize(width: 10, height: 2) + tableView.usesAlternatingRowBackgroundColors = true + tableView.allowsMultipleSelection = false + tableView.allowsEmptySelection = true + tableView.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle + tableView.target = self + tableView.doubleAction = #selector(openSelection) + tableView.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)] + + listScroll.translatesAutoresizingMaskIntoConstraints = false + listScroll.documentView = tableView + listScroll.hasVerticalScroller = true + listScroll.autohidesScrollers = true + listScroll.drawsBackground = false + listScroll.isHidden = true + + root.addSubview(header) + root.addSubview(gridScroll) + root.addSubview(listScroll) + NSLayoutConstraint.activate([ + header.topAnchor.constraint(equalTo: root.topAnchor), + header.leadingAnchor.constraint(equalTo: root.leadingAnchor), + header.trailingAnchor.constraint(equalTo: root.trailingAnchor), + header.heightAnchor.constraint(equalToConstant: 86), + heading.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 22), + heading.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -22), + heading.topAnchor.constraint(equalTo: header.topAnchor, constant: 15), + subtitle.leadingAnchor.constraint(equalTo: heading.leadingAnchor), + subtitle.trailingAnchor.constraint(equalTo: heading.trailingAnchor), + subtitle.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: 6), + gridScroll.topAnchor.constraint(equalTo: header.bottomAnchor), + gridScroll.leadingAnchor.constraint(equalTo: root.leadingAnchor), + gridScroll.trailingAnchor.constraint(equalTo: root.trailingAnchor), + gridScroll.bottomAnchor.constraint(equalTo: root.bottomAnchor), + listScroll.topAnchor.constraint(equalTo: header.bottomAnchor), + listScroll.leadingAnchor.constraint(equalTo: root.leadingAnchor), + listScroll.trailingAnchor.constraint(equalTo: root.trailingAnchor), + listScroll.bottomAnchor.constraint(equalTo: root.bottomAnchor), + collectionView.widthAnchor.constraint(equalTo: gridScroll.contentView.widthAnchor), + ]) + self.view = root + + searchField.placeholderString = "Search captions, dates, or shortcodes" + searchField.delegate = self + searchField.sendsSearchStringImmediately = true + searchField.translatesAutoresizingMaskIntoConstraints = false + searchField.widthAnchor.constraint(equalToConstant: 260).isActive = true + + modeControl.segmentCount = 2 + modeControl.segmentStyle = .texturedRounded + modeControl.setImage(NSImage(systemSymbolName: "square.grid.3x3", accessibilityDescription: "Thumbnails"), forSegment: 0) + modeControl.setImage(NSImage(systemSymbolName: "list.bullet", accessibilityDescription: "List"), forSegment: 1) + modeControl.setToolTip("Tiny thumbnails", forSegment: 0) + modeControl.setToolTip("Sortable list", forSegment: 1) + modeControl.selectedSegment = min(1, max(0, UserDefaults.standard.integer(forKey: "ArchiveViewMode"))) + modeControl.target = self + modeControl.action = #selector(changeViewMode) + gridScroll.isHidden = modeControl.selectedSegment == 1 + listScroll.isHidden = modeControl.selectedSegment == 0 + + } + + func installToolbar(on window: NSWindow) { + let toolbar = NSToolbar(identifier: "ArchiveToolbar") + toolbar.delegate = self + toolbar.displayMode = .iconOnly + toolbar.allowsUserCustomization = false + window.toolbar = toolbar + window.toolbarStyle = .unified + } + + func showListView() { + _ = view + modeControl.selectedSegment = 1 + changeViewMode() + } + + func numberOfSections(in collectionView: NSCollectionView) -> Int { 1 } + func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int { filteredPosts.count } + + func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem { + let item = collectionView.makeItem(withIdentifier: ArchiveCardItem.identifier, for: indexPath) as! ArchiveCardItem + item.configure(post: filteredPosts[indexPath.item], archiveRoot: archiveRoot) + return item + } + + func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, + sizeForItemAt indexPath: IndexPath) -> NSSize { + let available = max(520, collectionView.bounds.width - 32) + let columns = max(3, Int((available + 10) / 190)) + let width = floor((available - CGFloat(columns - 1) * 10) / CGFloat(columns)) + return NSSize(width: width, height: width + 88) + } + + func collectionView(_ collectionView: NSCollectionView, didSelectItemsAt indexPaths: Set) { + shareItem.isEnabled = !indexPaths.isEmpty + } + + func collectionView(_ collectionView: NSCollectionView, didDeselectItemsAt indexPaths: Set) { + shareItem.isEnabled = !collectionView.selectionIndexPaths.isEmpty + } + + func numberOfRows(in tableView: NSTableView) -> Int { filteredPosts.count } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard filteredPosts.indices.contains(row), let column = tableColumn else { return nil } + let post = filteredPosts[row] + let id = column.identifier + if id.rawValue == "thumbnail" { + let cell = (tableView.makeView(withIdentifier: id, owner: self) as? ThumbnailTableCell) ?? { + let created = ThumbnailTableCell() + created.identifier = id + return created + }() + let url = post.stills.first?.file.map { archiveRoot.appendingPathComponent($0) } + cell.configure(url: url) + return cell + } + let cell = (tableView.makeView(withIdentifier: id, owner: self) as? NSTableCellView) ?? { + let created = NSTableCellView() + created.identifier = id + let label = NSTextField(labelWithString: "") + label.translatesAutoresizingMaskIntoConstraints = false + label.lineBreakMode = .byTruncatingTail + label.maximumNumberOfLines = 2 + created.textField = label + created.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: created.leadingAnchor, constant: 4), + label.trailingAnchor.constraint(equalTo: created.trailingAnchor, constant: -4), + label.centerYAnchor.constraint(equalTo: created.centerYAnchor), + ]) + return created + }() + switch id.rawValue { + case "date": + cell.textField?.stringValue = String(post.date.prefix(10)) + cell.textField?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) + cell.textField?.textColor = .secondaryLabelColor + case "caption": + cell.textField?.stringValue = post.caption.isEmpty ? "No caption" : post.caption + cell.textField?.font = .systemFont(ofSize: 13) + cell.textField?.textColor = post.caption.isEmpty ? .secondaryLabelColor : .labelColor + case "images": + cell.textField?.stringValue = String(post.stills.count) + cell.textField?.alignment = .center + cell.textField?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .medium) + cell.textField?.textColor = .secondaryLabelColor + default: + cell.textField?.stringValue = post.shortcode + cell.textField?.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + cell.textField?.textColor = .secondaryLabelColor + } + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) { + shareItem.isEnabled = tableView.selectedRow >= 0 + } + + func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { + guard let descriptor = tableView.sortDescriptors.first, let key = descriptor.key else { return } + sortColumn = key + sortAscending = descriptor.ascending + applyFilterAndSort() + } + + func controlTextDidChange(_ obj: Notification) { + applyFilterAndSort() + } + + private func applyFilterAndSort() { + let needle = searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let matches = needle.isEmpty ? manifest.posts : manifest.posts.filter { + $0.shortcode.lowercased().contains(needle) || $0.date.lowercased().contains(needle) || $0.caption.lowercased().contains(needle) + } + filteredPosts = matches.sorted { left, right in + let comparison: ComparisonResult + switch sortColumn { + case "caption": comparison = left.caption.localizedCaseInsensitiveCompare(right.caption) + case "images": + if left.stills.count == right.stills.count { comparison = left.shortcode.compare(right.shortcode) } + else { comparison = left.stills.count < right.stills.count ? .orderedAscending : .orderedDescending } + case "shortcode": comparison = left.shortcode.compare(right.shortcode) + default: comparison = left.date.compare(right.date) + } + if comparison == .orderedSame { return left.shortcode < right.shortcode } + return sortAscending ? comparison == .orderedAscending : comparison == .orderedDescending + } + collectionView.selectionIndexPaths = [] + tableView.deselectAll(nil) + shareItem.isEnabled = false + collectionView.reloadData() + collectionView.collectionViewLayout?.invalidateLayout() + tableView.reloadData() + updateSubtitle() + } + + @objc private func changeViewMode() { + let list = modeControl.selectedSegment == 1 + UserDefaults.standard.set(modeControl.selectedSegment, forKey: "ArchiveViewMode") + gridScroll.isHidden = list + listScroll.isHidden = !list + collectionView.selectionIndexPaths = [] + tableView.deselectAll(nil) + shareItem.isEnabled = false + if !list { collectionView.collectionViewLayout?.invalidateLayout() } + } + + private func updateSubtitle() { + let visibleStills = filteredPosts.reduce(0) { $0 + $1.stills.count } + subtitle.stringValue = "@\(manifest.account) · \(filteredPosts.count) posts · \(visibleStills) stills · offline archive" + } + + private func selectedPost() -> ArchivePost? { + let index = modeControl.selectedSegment == 1 ? tableView.selectedRow : (collectionView.selectionIndexPaths.first?.item ?? -1) + guard filteredPosts.indices.contains(index) else { return nil } + return filteredPosts[index] + } + + private func urls(for post: ArchivePost) -> [URL] { + post.stills.compactMap { $0.file }.map { archiveRoot.appendingPathComponent($0) } + } + + @objc private func openSelection() { + guard let post = selectedPost() else { return } + previewURLs = urls(for: post) + guard !previewURLs.isEmpty, let panel = QLPreviewPanel.shared() else { return } + panel.dataSource = self + panel.currentPreviewItemIndex = 0 + panel.makeKeyAndOrderFront(nil) + } + + @objc private func handleDoubleClick(_ recognizer: NSClickGestureRecognizer) { + let point = recognizer.location(in: collectionView) + if let indexPath = collectionView.indexPathForItem(at: point) { + collectionView.selectItems(at: [indexPath], scrollPosition: []) + shareItem.isEnabled = true + } + openSelection() + } + + func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { previewURLs.count } + func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> QLPreviewItem! { previewURLs[index] as NSURL } + + func items(for pickerToolbarItem: NSSharingServicePickerToolbarItem) -> [Any] { + guard let post = selectedPost() else { return [] } + var items: [Any] = urls(for: post) + if !post.caption.isEmpty { items.append(post.caption as NSString) } + if let source = URL(string: post.url) { items.append(source as NSURL) } + return items + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [Self.searchID, .flexibleSpace, Self.modeID, Self.shareID] + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [Self.searchID, .flexibleSpace, Self.modeID, Self.shareID] + } + + func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + if itemIdentifier == Self.searchID { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.label = "Search" + item.view = searchField + return item + } + if itemIdentifier == Self.shareID { + return shareItem + } + if itemIdentifier == Self.modeID { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.label = "View" + item.paletteLabel = "View mode" + item.view = modeControl + return item + } + return nil + } +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + private var window: NSWindow? + private var controller: GalleryController? + + func applicationDidFinishLaunching(_ notification: Notification) { + do { + guard let resources = Bundle.main.resourceURL else { throw CocoaError(.fileNoSuchFile) } + let archiveRoot = resources.appendingPathComponent("archive", isDirectory: true) + let data = try Data(contentsOf: archiveRoot.appendingPathComponent("manifest.json")) + let manifest = try JSONDecoder().decode(ArchiveManifest.self, from: data) + let controller = GalleryController(manifest: manifest, archiveRoot: archiveRoot) + installMainMenu() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1240, height: 860), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.title = manifest.title.components(separatedBy: " —").first ?? manifest.title + window.titleVisibility = .hidden + window.isRestorable = false + window.minSize = NSSize(width: 720, height: 560) + window.contentViewController = controller + controller.installToolbar(on: window) + if CommandLine.arguments.contains("--list") { controller.showListView() } + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + self.controller = controller + self.window = window + } catch { + let alert = NSAlert(error: error) + alert.messageText = "The archive could not be opened" + alert.runModal() + NSApp.terminate(nil) + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + + private func installMainMenu() { + let main = NSMenu() + + let appItem = NSMenuItem() + let appMenu = NSMenu() + appMenu.addItem(withTitle: "About \(ProcessInfo.processInfo.processName)", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Hide \(ProcessInfo.processInfo.processName)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + appMenu.addItem(withTitle: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h").keyEquivalentModifierMask = [.command, .option] + appMenu.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Quit \(ProcessInfo.processInfo.processName)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + appItem.submenu = appMenu + main.addItem(appItem) + + let fileItem = NSMenuItem() + let fileMenu = NSMenu(title: "File") + fileMenu.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + fileItem.submenu = fileMenu + main.addItem(fileItem) + + let editItem = NSMenuItem() + let editMenu = NSMenu(title: "Edit") + editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") + editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z") + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + editItem.submenu = editMenu + main.addItem(editItem) + + let windowItem = NSMenuItem() + let windowMenu = NSMenu(title: "Window") + windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + windowMenu.addItem(.separator()) + windowMenu.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") + windowItem.submenu = windowMenu + main.addItem(windowItem) + NSApp.windowsMenu = windowMenu + NSApp.mainMenu = main + } +} + +let app = NSApplication.shared +let delegate = AppDelegate() +app.setActivationPolicy(.regular) +app.delegate = delegate +app.run() diff --git a/slab/test/dmgify.test.mjs b/slab/test/dmgify.test.mjs index 954fbb97fa..bfc93c0915 100644 --- a/slab/test/dmgify.test.mjs +++ b/slab/test/dmgify.test.mjs @@ -24,3 +24,15 @@ test("dmgify rejects entry traversal", async () => { /entry may not traverse/, ); }); + +test("dmgify plans a native Swift gallery from manifest.json", async () => { + const dir = await mkdtemp(join(homedir(), ".dmgify-native-test-")); + await writeFile(join(dir, "manifest.json"), JSON.stringify({ + title: "Native Test", account: "test", counts: { posts: 0, stills: 0 }, posts: [], + })); + const plan = await planDmg({ + source: dir, runtime: "swift-gallery", name: "Native Test", bundleId: "computer.aesthetic.nativetest", + }); + assert.equal(plan.runtime, "swift-gallery"); + assert.equal(plan.entryPath, join(dir, "manifest.json")); +});