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"));
+});