quicklook-kit 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ import SwiftUI
2
+
3
+ /// QuickLook extensions have to ship inside an app, and macOS registers them
4
+ /// when that app is first launched. This is all the app does.
5
+ @main
6
+ struct HostApp: App {
7
+ private let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? ""
8
+
9
+ var body: some Scene {
10
+ Window(name, id: "main") {
11
+ VStack(spacing: 8) {
12
+ Text(name).font(.headline)
13
+ Text("The QuickLook preview is installed. You can close this window.")
14
+ .foregroundStyle(.secondary)
15
+ }
16
+ .padding(32)
17
+ .fixedSize()
18
+ }
19
+ .windowResizability(.contentSize)
20
+ }
21
+ }
@@ -0,0 +1,124 @@
1
+ import QuickLookUI
2
+ import UniformTypeIdentifiers
3
+ import WebKit
4
+
5
+ /// Loads the bundled page over `quicklook://app/` and hands it the previewed
6
+ /// file as `window.quicklook`. See packages/quicklook-kit/src/runtime.ts.
7
+ @objc(PreviewViewController)
8
+ final class PreviewViewController: NSViewController, QLPreviewingController {
9
+ /// Larger files throw, which makes QuickLook fall back to its generic preview
10
+ /// instead of stalling while megabytes of base64 cross into the page.
11
+ private static let maxFileSize =
12
+ Bundle.main.object(forInfoDictionaryKey: "QLMaxFileSize") as? Int ?? 10_000_000
13
+
14
+ private let loadWaiter = LoadWaiter()
15
+
16
+ private lazy var webView: WKWebView = {
17
+ let configuration = WKWebViewConfiguration()
18
+ configuration.setURLSchemeHandler(BundleSchemeHandler(), forURLScheme: "quicklook")
19
+ let webView = WKWebView(frame: .zero, configuration: configuration)
20
+ webView.navigationDelegate = loadWaiter
21
+ return webView
22
+ }()
23
+
24
+ override func loadView() {
25
+ view = webView
26
+ }
27
+
28
+ func preparePreviewOfFile(at url: URL) async throws {
29
+ let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
30
+ guard size <= Self.maxFileSize else { throw CocoaError(.fileReadTooLarge) }
31
+
32
+ let file = [
33
+ "name": url.lastPathComponent,
34
+ "extension": url.pathExtension.lowercased(),
35
+ "base64": try Data(contentsOf: url).base64EncodedString(),
36
+ ]
37
+ // JSON is a valid JavaScript expression, so this needs no escaping of its own.
38
+ let json = String(decoding: try JSONSerialization.data(withJSONObject: file), as: UTF8.self)
39
+ let scripts = webView.configuration.userContentController
40
+ scripts.removeAllUserScripts()
41
+ scripts.addUserScript(
42
+ WKUserScript(
43
+ source: "window.quicklook = \(json);", injectionTime: .atDocumentStart,
44
+ forMainFrameOnly: true))
45
+
46
+ // QuickLook displays the view as soon as this returns, so wait for the
47
+ // page to actually finish loading — otherwise larger files show blank.
48
+ let page = URLRequest(url: URL(string: "quicklook://app/")!)
49
+ try await loadWaiter.load { webView.load(page) }
50
+ }
51
+ }
52
+
53
+ /// Serves the bundled `web` folder. A real origin, unlike file://, lets any
54
+ /// framework's build load its ES modules and chunks as `'self'`, and every
55
+ /// response carries the CSP the build wrote into Info.plist.
56
+ private final class BundleSchemeHandler: NSObject, WKURLSchemeHandler {
57
+ private let root = Bundle.main.resourceURL!.appendingPathComponent("web").standardizedFileURL
58
+ private let csp =
59
+ Bundle.main.object(forInfoDictionaryKey: "QLWebContentSecurityPolicy") as? String
60
+ ?? "default-src 'none'"
61
+
62
+ func webView(_ webView: WKWebView, start task: any WKURLSchemeTask) {
63
+ guard let url = task.request.url else {
64
+ task.didFailWithError(URLError(.badURL))
65
+ return
66
+ }
67
+ var path = url.path(percentEncoded: false)
68
+ if path.isEmpty || path.hasSuffix("/") { path += "index.html" }
69
+ let file = root.appendingPathComponent(path).standardizedFileURL
70
+ // `..%2f` arrives decoded as a real `..`; never serve anything outside web/.
71
+ let data = file.path.hasPrefix(root.path + "/") ? try? Data(contentsOf: file) : nil
72
+ let type =
73
+ UTType(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
74
+ let response = HTTPURLResponse(
75
+ url: url, statusCode: data == nil ? 404 : 200, httpVersion: "HTTP/1.1",
76
+ headerFields: ["Content-Type": type, "Content-Security-Policy": csp])!
77
+ task.didReceive(response)
78
+ task.didReceive(data ?? Data())
79
+ task.didFinish()
80
+ }
81
+
82
+ func webView(_ webView: WKWebView, stop task: any WKURLSchemeTask) {}
83
+ }
84
+
85
+ /// Bridges `WKNavigationDelegate` callbacks back to `async`.
86
+ @MainActor
87
+ private final class LoadWaiter: NSObject, WKNavigationDelegate {
88
+ private var continuation: CheckedContinuation<Void, Error>?
89
+
90
+ func load(_ start: () -> Void) async throws {
91
+ try await withCheckedThrowingContinuation { continuation in
92
+ self.continuation = continuation
93
+ start()
94
+ }
95
+ }
96
+
97
+ private func finish(_ result: Result<Void, Error>) {
98
+ continuation?.resume(with: result)
99
+ continuation = nil
100
+ }
101
+
102
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
103
+ finish(.success(()))
104
+ }
105
+
106
+ func webView(
107
+ _ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error
108
+ ) {
109
+ finish(.failure(error))
110
+ }
111
+
112
+ func webView(
113
+ _ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
114
+ withError error: Error
115
+ ) {
116
+ finish(.failure(error))
117
+ }
118
+
119
+ /// A crashed content process fires no navigation callback at all — without
120
+ /// this the await never returns and QuickLook spins forever.
121
+ func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
122
+ finish(.failure(CocoaError(.featureUnsupported)))
123
+ }
124
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "quicklook-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Build macOS QuickLook previews with any Vite-based web framework",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "quicklook": "./src/cli.ts"
9
+ },
10
+ "exports": {
11
+ ".": "./src/config.ts",
12
+ "./vite": "./src/vite.ts",
13
+ "./runtime": "./src/runtime.ts",
14
+ "./virtual": {
15
+ "types": "./src/virtual.d.ts"
16
+ }
17
+ },
18
+ "dependencies": {
19
+ "c12": "3.3.4",
20
+ "jiti": "2.7.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/bun": "1.3.9",
24
+ "typescript": "6.0.3",
25
+ "vite": "8.3.0"
26
+ },
27
+ "peerDependencies": {
28
+ "vite": ">=7"
29
+ },
30
+ "scripts": {
31
+ "check": "tsc --noEmit"
32
+ }
6
33
  }
@@ -0,0 +1,193 @@
1
+ # quicklook-kit
2
+
3
+ > Build macOS QuickLook previews with any Vite-based web framework, no Swift required
4
+
5
+ Your preview is a web page. quicklook-kit builds it with your framework, wraps it in a small native QuickLook extension and installs the resulting app. You never touch Swift or Xcode.
6
+
7
+ Built-in support for SvelteKit, Astro, SolidStart, Nuxt and Analog. Any other framework that builds with Vite and writes a static `index.html` works through an integration.
8
+
9
+ ## Requirements
10
+
11
+ - macOS 14 or later, with [Xcode](https://apps.apple.com/app/xcode/id497799835) installed
12
+ - [XcodeGen](https://github.com/yonaskolb/XcodeGen)
13
+ - [Bun](https://bun.sh), which runs the `quicklook` CLI
14
+
15
+ ## Installation
16
+
17
+ ```sh
18
+ bun add --dev quicklook-kit
19
+ ```
20
+
21
+ To start from scratch, use one of the [templates](https://github.com/idleberg/quicklook-kit/tree/main/templates) instead.
22
+
23
+ ## Setup
24
+
25
+ ### 1. Configure
26
+
27
+ Create `quicklook.config.ts` in your project root:
28
+
29
+ ```ts
30
+ import { defineConfig } from 'quicklook-kit';
31
+
32
+ export default defineConfig({
33
+ name: 'Example Preview', // app name, shown in Finder and System Settings
34
+ bundleId: 'com.example.ExamplePreview',
35
+ types: [
36
+ {
37
+ id: 'com.example.ExamplePreview.example',
38
+ description: 'Example File',
39
+ extensions: ['example'],
40
+ conformsTo: ['public.plain-text'] // or 'public.data' for binary formats
41
+ }
42
+ ],
43
+ framework: 'sveltekit' // or 'astro', 'solid-start', 'nuxt', 'analog'
44
+ });
45
+ ```
46
+
47
+ To preview a type macOS already knows about, use a plain string like `'public.json'` in place of an object.
48
+
49
+ The config can also be `quicklook.config.js`, `.json`, `.yaml` or `.toml`, or a `.quicklookrc`. The keys are the same in every format.
50
+
51
+ | Key | Default | |
52
+ | -------------- | ------------ | ---------------------------------------------------------------------------------- |
53
+ | `name` | required | App name |
54
+ | `bundleId` | required | The extension gets `<bundleId>.Extension` |
55
+ | `types` | required | File types to preview |
56
+ | `framework` | required | `'sveltekit'`, `'astro'`, `'solid-start'`, `'nuxt'`, `'analog'`, or an integration |
57
+ | `macOS` | `'14.0'` | Minimum macOS version |
58
+ | `integrations` | `[]` | See [Integrations](#integrations) |
59
+ | `csp` | locked down | Directives here replace the defaults of the same name |
60
+ | `maxFileSize` | `10_000_000` | Larger files get QuickLook's generic preview |
61
+ | `public` | `{}` | Values the page can read at build time |
62
+
63
+ A framework can take options as a `[name, options]` pair, e.g. `['sveltekit', { outDir: 'dist' }]` when your adapter writes somewhere other than the default.
64
+
65
+ Each framework needs a static, client-side build:
66
+
67
+ | Framework | Requirement |
68
+ | ---------- | ------------------------------------------------------ |
69
+ | SvelteKit | `@sveltejs/adapter-static` |
70
+ | Astro | the default `output: 'static'` |
71
+ | SolidStart | `ssr: false`, Nitro's `static` preset prerendering `/` |
72
+ | Nuxt | `ssr: false` |
73
+ | Analog | `@analogjs/vite-plugin-angular` |
74
+
75
+ ### 2. Add the Vite plugin
76
+
77
+ ```ts
78
+ import { quicklook } from 'quicklook-kit/vite';
79
+
80
+ // in the framework's Vite config
81
+ plugins: [quicklook()];
82
+ ```
83
+
84
+ For TypeScript, add the types of the virtual module, either in a `.d.ts` file:
85
+
86
+ ```ts
87
+ /// <reference types="quicklook-kit/virtual" />
88
+ ```
89
+
90
+ or in `tsconfig.json` under `compilerOptions.types` as `"quicklook-kit/virtual"`.
91
+
92
+ ### 3. Read the file
93
+
94
+ ```ts
95
+ import { browserFile, config, nativeFile } from 'virtual:quicklook';
96
+
97
+ const file = nativeFile(); // the previewed file inside QuickLook, undefined in the browser
98
+ config.public; // values from quicklook.config.*
99
+ ```
100
+
101
+ | Field | Type | |
102
+ | ----------- | ------------ | ------------------------------ |
103
+ | `name` | `string` | `"notes.example"` |
104
+ | `extension` | `string` | `"example"`, lowercase, no dot |
105
+ | `bytes` | `Uint8Array` | the raw file contents |
106
+
107
+ Decode `bytes` however your format needs: `new TextDecoder().decode(file.bytes)` for text, or a parser for binary formats.
108
+
109
+ In the dev server, `nativeFile()` returns `undefined`. Pass a dropped or picked `File` to `browserFile()` to get the same shape.
110
+
111
+ ### 4. Add scripts
112
+
113
+ ```json
114
+ {
115
+ "scripts": {
116
+ "dev": "quicklook dev",
117
+ "build": "quicklook build",
118
+ "register": "quicklook build && quicklook register",
119
+ "unregister": "quicklook unregister"
120
+ }
121
+ }
122
+ ```
123
+
124
+ ## CLI
125
+
126
+ | Command | Description |
127
+ | ---------------------- | --------------------------------------------------------------------- |
128
+ | `quicklook dev` | Starts the framework's dev server |
129
+ | `quicklook build:web` | Builds the page only, into `.quicklook/web` |
130
+ | `quicklook build` | Builds the app with its extension into `.quicklook/build` |
131
+ | `quicklook register` | Copies the built app to `/Applications` and registers the extension |
132
+ | `quicklook unregister` | Removes the app from `/Applications` |
133
+
134
+ After `register`, select a file in Finder and press Space. Run `build` and `register` again after every change.
135
+
136
+ If the preview doesn't show up, check `pluginkit -mAv | grep <your bundleId>`, and make sure no other app claims the same file type.
137
+
138
+ Add `.quicklook` to your `.gitignore`.
139
+
140
+ ## Things to know
141
+
142
+ - **Nothing can be loaded from the network.** The extension serves your build from inside the app with a strict Content Security Policy. Import images, fonts and CSS normally and they get bundled. The dev server only reports violations in the console, without blocking them.
143
+ - **Inline scripts are allowed by hash.** Frameworks that bootstrap with inline `<script>` tags keep working; the hashes are added to the CSP at build time.
144
+ - **Test in Safari.** It uses the same WebKit engine as QuickLook's WKWebView.
145
+ - **The light/dark theme follows the system.** Use `prefers-color-scheme` in CSS.
146
+ - **The app is signed ad hoc.** Other people have to allow it in System Settings → Privacy & Security on first launch. Distributing it without that step requires an Apple Developer account and notarization.
147
+
148
+ ## Integrations
149
+
150
+ An integration is an object with a `name` and any of these hooks. They run in order: the framework first, then each entry in `integrations`.
151
+
152
+ ```ts
153
+ import type { Integration } from 'quicklook-kit';
154
+
155
+ export default (options: { key: string }): Integration => ({
156
+ name: 'my-integration',
157
+ hooks: {
158
+ // Adjust the resolved config.
159
+ 'config:resolved'(config) {},
160
+ // Post-process the framework's static output before it's bundled.
161
+ async 'build:done'({ outDir, config }) {},
162
+ // Edit the generated XcodeGen spec: Info.plist keys, entitlements, targets.
163
+ 'xcode:spec'(spec, config) {
164
+ spec.targets.ExamplePreviewExtension.info.properties.MyKey = options.key;
165
+ },
166
+ // Add Vite plugins wherever quicklook() is used.
167
+ vite: () => []
168
+ }
169
+ });
170
+ ```
171
+
172
+ Add it as an object, or by name so JSON and YAML configs can use it too. A name is imported from the project: a package or a relative path, whose default export is the integration or a function returning it.
173
+
174
+ ```jsonc
175
+ {
176
+ "integrations": ["quicklook-some-package", ["./my-integration.ts", { "key": "value" }]]
177
+ }
178
+ ```
179
+
180
+ A framework is an integration with a `build` field:
181
+
182
+ ```ts
183
+ framework: {
184
+ name: 'my-framework',
185
+ build: { dev: ['vite', 'dev'], command: ['vite', 'build'], outDir: 'dist' }
186
+ }
187
+ ```
188
+
189
+ The commands run with the project's own binaries.
190
+
191
+ ## License
192
+
193
+ This work is licensed under [The MIT License](https://opensource.org/licenses/MIT).
package/src/cli.ts ADDED
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env bun
2
+ // Builds the page with the configured framework, wraps it in a QuickLook
3
+ // extension, and installs it. Usage: quicklook dev|build:web|build|register|unregister
4
+ import { createHash } from 'node:crypto';
5
+ import { existsSync } from 'node:fs';
6
+ import { cp, rm } from 'node:fs/promises';
7
+ import { resolve } from 'node:path';
8
+ import process from 'node:process';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { $, Glob } from 'bun';
11
+ import {
12
+ addSources,
13
+ type DeclaredType,
14
+ loadConfig,
15
+ serializeCsp,
16
+ type XcodeSpec
17
+ } from './config.ts';
18
+
19
+ const config = await loadConfig();
20
+ const { root, name, bundleId, macOS, types, framework } = config;
21
+ const target = name.replace(/[^A-Za-z0-9]/g, '');
22
+ // Everything generated lives here, away from the framework's own output.
23
+ const work = `${root}/.quicklook`;
24
+ const web = `${work}/web`;
25
+ const built = `${work}/build/${name}.app`;
26
+ const installed = `/Applications/${name}.app`;
27
+ const native = fileURLToPath(new URL('../native', import.meta.url));
28
+ const lsregister =
29
+ '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister';
30
+
31
+ const leadingV = /^v/;
32
+ const inlineScript = /<script\b(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi;
33
+
34
+ /** Runs a framework command with the project's own binaries. */
35
+ async function run(command: string[]) {
36
+ const child = Bun.spawn(['bun', 'run', ...command], {
37
+ cwd: root,
38
+ stdio: ['inherit', 'inherit', 'inherit']
39
+ });
40
+ const code = await child.exited;
41
+ if (code !== 0) {
42
+ process.exit(code);
43
+ }
44
+ }
45
+
46
+ /** Frameworks bootstrap with inline scripts; the CSP allows exactly those, by hash. */
47
+ async function inlineScriptHashes() {
48
+ const hashes = new Set<string>();
49
+ for await (const file of new Glob('**/*.html').scan(web)) {
50
+ const html = await Bun.file(`${web}/${file}`).text();
51
+ for (const [, body = ''] of html.matchAll(inlineScript)) {
52
+ hashes.add(`'sha256-${createHash('sha256').update(body).digest('base64')}'`);
53
+ }
54
+ }
55
+ return [...hashes];
56
+ }
57
+
58
+ /** Builds the page with the framework and copies it to `.quicklook/web`. */
59
+ async function buildWeb() {
60
+ await run(framework.build.command);
61
+ const outDir = resolve(root, framework.build.outDir);
62
+ for (const integration of config.integrations) {
63
+ await integration.hooks?.['build:done']?.({ outDir, config });
64
+ }
65
+ // Copied so the bundle's folder is always named `web`, whatever the framework calls it.
66
+ await rm(web, { recursive: true, force: true });
67
+ await cp(outDir, web, { recursive: true });
68
+ }
69
+
70
+ async function build() {
71
+ await buildWeb();
72
+
73
+ const csp = addSources(config.csp, 'script-src', ...(await inlineScriptHashes()));
74
+ const spec: XcodeSpec = {
75
+ name: target,
76
+ options: { deploymentTarget: { macOS }, createIntermediateGroups: true },
77
+ settings: {
78
+ base: {
79
+ SWIFT_VERSION: '6.0',
80
+ MARKETING_VERSION: process.env.VERSION?.replace(leadingV, '') ?? '1.0',
81
+ CURRENT_PROJECT_VERSION: '1',
82
+ // Ad-hoc signing: enough for QuickLook to load it, no Apple Developer account needed.
83
+ CODE_SIGN_STYLE: 'Manual',
84
+ CODE_SIGN_IDENTITY: '-'
85
+ }
86
+ },
87
+ targets: {
88
+ [target]: {
89
+ type: 'application',
90
+ platform: 'macOS',
91
+ sources: [`${native}/App`],
92
+ settings: { PRODUCT_NAME: name, PRODUCT_BUNDLE_IDENTIFIER: bundleId },
93
+ dependencies: [{ target: `${target}Extension`, embed: true, codeSign: true }],
94
+ info: {
95
+ path: `${work}/App/Info.plist`,
96
+ properties: {
97
+ CFBundleName: name,
98
+ LSMinimumSystemVersion: macOS,
99
+ UTExportedTypeDeclarations: types
100
+ .filter((type): type is DeclaredType => typeof type !== 'string')
101
+ .map((type) => ({
102
+ UTTypeIdentifier: type.id,
103
+ UTTypeDescription: type.description,
104
+ UTTypeConformsTo: type.conformsTo,
105
+ UTTypeTagSpecification: { 'public.filename-extension': type.extensions }
106
+ }))
107
+ }
108
+ }
109
+ },
110
+ [`${target}Extension`]: {
111
+ type: 'app-extension',
112
+ platform: 'macOS',
113
+ sources: [`${native}/Extension`, { path: web, type: 'folder', buildPhase: 'resources' }],
114
+ // macOS silently ignores an extension whose bundle id isn't prefixed by its app's.
115
+ settings: { PRODUCT_BUNDLE_IDENTIFIER: `${bundleId}.Extension` },
116
+ dependencies: [{ sdk: 'QuickLookUI.framework' }, { sdk: 'WebKit.framework' }],
117
+ entitlements: {
118
+ path: `${work}/Extension/Extension.entitlements`,
119
+ properties: {
120
+ 'com.apple.security.app-sandbox': true,
121
+ 'com.apple.security.files.user-selected.read-only': true,
122
+ // WKWebView's content process won't launch in a sandbox without this,
123
+ // even for a page that loads nothing. The page's CSP blocks all requests.
124
+ 'com.apple.security.network.client': true
125
+ }
126
+ },
127
+ info: {
128
+ path: `${work}/Extension/Info.plist`,
129
+ properties: {
130
+ CFBundleDisplayName: name,
131
+ // Read by PreviewViewController.swift.
132
+ QLWebContentSecurityPolicy: serializeCsp(csp),
133
+ QLMaxFileSize: config.maxFileSize,
134
+ NSExtension: {
135
+ NSExtensionPointIdentifier: 'com.apple.quicklook.preview',
136
+ NSExtensionPrincipalClass: 'PreviewViewController',
137
+ NSExtensionAttributes: {
138
+ QLSupportedContentTypes: types.map((type) =>
139
+ typeof type === 'string' ? type : type.id
140
+ ),
141
+ QLSupportsSearchableItems: false
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+ };
149
+ for (const integration of config.integrations) {
150
+ await integration.hooks?.['xcode:spec']?.(spec, config);
151
+ }
152
+
153
+ // JSON is valid YAML.
154
+ await Bun.write(`${work}/project.yml`, JSON.stringify(spec, null, 2));
155
+ await $`xcodegen generate --quiet --spec ${work}/project.yml --project ${work}`;
156
+ await $`xcodebuild -quiet -project ${work}/${target}.xcodeproj -scheme ${target} -configuration Release -destination generic/platform=macOS CONFIGURATION_BUILD_DIR=${work}/build build`;
157
+ // Finder keys its icon cache on the bundle's mtime, which xcodebuild leaves alone.
158
+ await $`touch ${built}`;
159
+ console.log(`Built ${built}`);
160
+ }
161
+
162
+ switch (process.argv[2]) {
163
+ case 'dev':
164
+ await run(framework.build.dev);
165
+ break;
166
+
167
+ case 'build:web':
168
+ await buildWeb();
169
+ break;
170
+
171
+ case 'build':
172
+ await build();
173
+ break;
174
+
175
+ // macOS only loads extensions from apps in a standard location, and caches
176
+ // registrations aggressively, including for apps since renamed or deleted.
177
+ case 'register':
178
+ if (!existsSync(built)) {
179
+ throw new Error(`No build at ${built}, run 'quicklook build' first.`);
180
+ }
181
+ await $`rm -rf ${installed}`;
182
+ await $`cp -R ${built} ${installed}`;
183
+ await $`${lsregister} -f -R ${installed}`;
184
+ await $`qlmanage -r`.quiet().nothrow();
185
+ await $`qlmanage -r cache`.quiet().nothrow();
186
+ // Launching the app is what registers its embedded extension.
187
+ await $`open -g ${installed}`;
188
+ console.log(`Installed ${installed}\nVerify with: pluginkit -mAv | grep ${bundleId}`);
189
+ break;
190
+
191
+ case 'unregister':
192
+ await $`${lsregister} -u ${installed}`.quiet().nothrow();
193
+ await $`rm -rf ${installed}`;
194
+ await $`qlmanage -r`.quiet().nothrow();
195
+ console.log(`Removed ${installed}`);
196
+ break;
197
+
198
+ default:
199
+ throw new Error('Usage: quicklook dev|build:web|build|register|unregister');
200
+ }
package/src/config.ts ADDED
@@ -0,0 +1,176 @@
1
+ // Loads quicklook.config.* (any format c12 reads: ts, js, json, yaml, toml…)
2
+ // and resolves its framework and integrations.
3
+
4
+ import { join } from 'node:path';
5
+ import process from 'node:process';
6
+ import { loadConfig as loadC12 } from 'c12';
7
+ import { createJiti } from 'jiti';
8
+ import type { Plugin } from 'vite';
9
+
10
+ /** An existing type id such as "public.json", or a new type the app declares. */
11
+ export type FileType = string | DeclaredType;
12
+
13
+ /** A file type the app declares itself, as a UTExportedTypeDeclaration. */
14
+ export interface DeclaredType {
15
+ id: string;
16
+ description: string;
17
+ extensions: string[];
18
+ conformsTo: string[];
19
+ }
20
+
21
+ /** CSP directives, e.g. `{ 'img-src': ["'self'", 'https:'] }`. */
22
+ export type Csp = Partial<Record<string, string[]>>;
23
+
24
+ /** The xcodegen project spec, written as project.yml. */
25
+ // biome-ignore lint/suspicious/noExplicitAny: hooks edit arbitrary xcodegen keys.
26
+ export type XcodeSpec = Record<string, any>;
27
+
28
+ export interface Integration {
29
+ name: string;
30
+ /** Set by frameworks only: how to run and build the page, and where it lands. */
31
+ build?: { dev: string[]; command: string[]; outDir: string };
32
+ hooks?: {
33
+ 'config:resolved'?: (config: ResolvedConfig) => void | Promise<void>;
34
+ /** Runs on the framework's static output, before it's bundled into the app. */
35
+ 'build:done'?: (context: { outDir: string; config: ResolvedConfig }) => void | Promise<void>;
36
+ /** Edit the generated Xcode project: Info.plist keys, entitlements, targets. */
37
+ 'xcode:spec'?: (spec: XcodeSpec, config: ResolvedConfig) => void | Promise<void>;
38
+ /** Vite plugins to add wherever the `quicklook()` Vite plugin is used. */
39
+ vite?: () => Plugin[];
40
+ };
41
+ }
42
+
43
+ /**
44
+ * A built-in framework or package name, the same with options, or an
45
+ * integration object. Names are imported from the project, so JSON and YAML
46
+ * configs can use third-party integrations too: the module's default export is
47
+ * either an integration or a function that takes the options and returns one.
48
+ */
49
+ export type IntegrationRef = Integration | string | [name: string, options?: unknown];
50
+
51
+ export interface QuickLookConfig {
52
+ /** App name, shown in Finder and System Settings. */
53
+ name: string;
54
+ bundleId: string;
55
+ /** Minimum macOS version. Defaults to 14.0. */
56
+ macOS?: string;
57
+ types: FileType[];
58
+ /** `sveltekit`, `astro`, `solid-start`, `nuxt`, `analog`, or any integration that sets `build`. */
59
+ framework: IntegrationRef;
60
+ integrations?: IntegrationRef[];
61
+ /** Directives given here replace the defaults of the same name. */
62
+ csp?: Csp;
63
+ /** Larger files get QuickLook's generic preview. Defaults to 10 MB. */
64
+ maxFileSize?: number;
65
+ /** Exposed to the page as `config.public` from `virtual:quicklook`. */
66
+ public?: Record<string, unknown>;
67
+ }
68
+
69
+ /** What the page sees as `config` from `virtual:quicklook`. */
70
+ export type PageConfig = Pick<ResolvedConfig, 'name' | 'bundleId' | 'types' | 'public'>;
71
+
72
+ export interface ResolvedConfig
73
+ extends Required<Omit<QuickLookConfig, 'framework' | 'integrations'>> {
74
+ root: string;
75
+ framework: Integration & { build: NonNullable<Integration['build']> };
76
+ /** The framework first, then the configured integrations, in order. */
77
+ integrations: Integration[];
78
+ }
79
+
80
+ /** The previewed file is untrusted input; nothing on the page needs the network. */
81
+ export const defaultCsp: Csp = {
82
+ 'default-src': ["'none'"],
83
+ 'script-src': ["'self'"],
84
+ 'style-src': ["'self'", "'unsafe-inline'"],
85
+ 'img-src': ["'self'", 'data:', 'blob:'],
86
+ 'font-src': ["'self'", 'data:'],
87
+ 'media-src': ["'self'", 'data:', 'blob:']
88
+ };
89
+
90
+ export const defineConfig = (config: QuickLookConfig) => config;
91
+
92
+ export const serializeCsp = (csp: Csp) =>
93
+ Object.entries(csp)
94
+ .map(([directive, sources = []]) => [directive, ...sources].join(' '))
95
+ .join('; ');
96
+
97
+ /** `csp` with `sources` appended to `directive`. */
98
+ export const addSources = (csp: Csp, directive: string, ...sources: string[]): Csp => ({
99
+ ...csp,
100
+ [directive]: [...(csp[directive] ?? []), ...sources]
101
+ });
102
+
103
+ /**
104
+ * Built-in frameworks: their dev and build commands, and the default `outDir`
105
+ * of a static, client-side build. Each takes `{ outDir }` to override it.
106
+ */
107
+ const frameworkBuilds: Record<string, [dev: string[], command: string[], outDir: string]> = {
108
+ // @analogjs/vite-plugin-angular, writing Vite's default `dist`.
109
+ analog: [['vite', 'dev'], ['vite', 'build'], 'dist'],
110
+ // Astro's default `output: 'static'`.
111
+ astro: [['astro', 'dev'], ['astro', 'build'], 'dist'],
112
+ // `ssr: false`; `nuxt generate` writes `.output/public`.
113
+ nuxt: [['nuxt', 'dev'], ['nuxt', 'generate'], '.output/public'],
114
+ // `solidStart({ ssr: false })` and Nitro's static preset prerendering `/`.
115
+ 'solid-start': [['vite', 'dev'], ['vite', 'build'], '.output/public'],
116
+ // @sveltejs/adapter-static, writing `build`.
117
+ sveltekit: [['vite', 'dev'], ['vite', 'build'], 'build']
118
+ };
119
+
120
+ function builtinFramework(name: string, options?: unknown): Integration | undefined {
121
+ const builtin = frameworkBuilds[name];
122
+ if (!builtin) {
123
+ return;
124
+ }
125
+ const [dev, command, outDir] = builtin;
126
+ return {
127
+ name,
128
+ build: { dev, command, outDir: (options as { outDir?: string } | undefined)?.outDir ?? outDir }
129
+ };
130
+ }
131
+
132
+ async function resolveIntegration(ref: IntegrationRef, root: string): Promise<Integration> {
133
+ if (typeof ref === 'object' && !Array.isArray(ref)) {
134
+ return ref;
135
+ }
136
+ const [name, options] = typeof ref === 'string' ? [ref] : ref;
137
+ const builtin = builtinFramework(name, options);
138
+ if (builtin) {
139
+ return builtin;
140
+ }
141
+ const exported = await createJiti(join(root, 'quicklook.config')).import<
142
+ Integration | ((options?: unknown) => Integration)
143
+ >(name, { default: true });
144
+ return typeof exported === 'function' ? exported(options) : exported;
145
+ }
146
+
147
+ export async function loadConfig(root = process.cwd()): Promise<ResolvedConfig> {
148
+ const { config } = await loadC12<QuickLookConfig>({ name: 'quicklook', cwd: root });
149
+ if (!config?.name) {
150
+ throw new Error(`No quicklook.config.* with a name found in ${root}`);
151
+ }
152
+ const { framework: frameworkRef, integrations: refs = [], ...rest } = config;
153
+ const framework = await resolveIntegration(frameworkRef, root);
154
+ if (!framework.build) {
155
+ throw new Error(`Framework "${framework.name}" doesn't say how to build the page`);
156
+ }
157
+ const integrations = [framework];
158
+ for (const ref of refs) {
159
+ integrations.push(await resolveIntegration(ref, root));
160
+ }
161
+
162
+ const resolved: ResolvedConfig = {
163
+ macOS: '14.0',
164
+ maxFileSize: 10_000_000,
165
+ public: {},
166
+ ...rest,
167
+ csp: { ...defaultCsp, ...config.csp },
168
+ root,
169
+ framework: { ...framework, build: framework.build },
170
+ integrations
171
+ };
172
+ for (const integration of integrations) {
173
+ await integration.hooks?.['config:resolved']?.(resolved);
174
+ }
175
+ return resolved;
176
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,36 @@
1
+ /** The file QuickLook asked to preview. */
2
+ export interface QuickLookFile {
3
+ name: string;
4
+ /** Lowercase, without the dot. */
5
+ extension: string;
6
+ bytes: Uint8Array;
7
+ }
8
+
9
+ declare global {
10
+ interface Window {
11
+ /** Injected by the extension before the page loads. See native/Extension/PreviewViewController.swift. */
12
+ quicklook?: { name: string; extension: string; base64: string };
13
+ }
14
+ }
15
+
16
+ /** The previewed file inside QuickLook; `undefined` in the browser. */
17
+ export function nativeFile(): QuickLookFile | undefined {
18
+ const injected = window.quicklook;
19
+ if (!injected) {
20
+ return;
21
+ }
22
+ return {
23
+ name: injected.name,
24
+ extension: injected.extension,
25
+ bytes: Uint8Array.from(atob(injected.base64), (c) => c.charCodeAt(0))
26
+ };
27
+ }
28
+
29
+ /** The same shape from a file picked or dropped in the browser, for the dev server. */
30
+ export async function browserFile(file: File): Promise<QuickLookFile> {
31
+ return {
32
+ name: file.name,
33
+ extension: file.name.includes('.') ? (file.name.split('.').pop() ?? '').toLowerCase() : '',
34
+ bytes: new Uint8Array(await file.arrayBuffer())
35
+ };
36
+ }
@@ -0,0 +1,7 @@
1
+ // Types for the module the `quicklook()` Vite plugin provides. Reference it
2
+ // with `/// <reference types="quicklook-kit/virtual" />`.
3
+ declare module 'virtual:quicklook' {
4
+ /** Build-time values from quicklook.config.*. */
5
+ export const config: import('./config.ts').PageConfig;
6
+ export { browserFile, nativeFile, type QuickLookFile } from 'quicklook-kit/runtime';
7
+ }
package/src/vite.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { Plugin } from 'vite';
2
+ import { addSources, loadConfig, type PageConfig, serializeCsp } from './config.ts';
3
+
4
+ const virtualId = 'virtual:quicklook';
5
+
6
+ /**
7
+ * Provides `virtual:quicklook`, adds the integrations' Vite plugins, and makes
8
+ * the dev server report what the extension's CSP would block.
9
+ */
10
+ export async function quicklook(): Promise<Plugin[]> {
11
+ const config = await loadConfig();
12
+ const { name, bundleId, types } = config;
13
+ const pageConfig: PageConfig = { name, bundleId, types, public: config.public };
14
+ const virtualSource = `export const config = ${JSON.stringify(pageConfig)};
15
+ export { browserFile, nativeFile } from 'quicklook-kit/runtime';`;
16
+ // Report-only, so violations show in the console without breaking HMR. Inline
17
+ // scripts are only hashed at build time, and the dev server needs its socket.
18
+ const devCsp = serializeCsp(
19
+ addSources(
20
+ addSources(config.csp, 'script-src', "'unsafe-inline'"),
21
+ 'connect-src',
22
+ "'self'",
23
+ 'ws:'
24
+ )
25
+ );
26
+
27
+ return [
28
+ {
29
+ name: 'quicklook',
30
+ resolveId: (source) => (source === virtualId ? `\0${virtualId}` : undefined),
31
+ load: (source) => (source === `\0${virtualId}` ? virtualSource : undefined),
32
+ configureServer(server) {
33
+ server.middlewares.use((_request, response, next) => {
34
+ response.setHeader('Content-Security-Policy-Report-Only', devCsp);
35
+ next();
36
+ });
37
+ }
38
+ },
39
+ ...config.integrations.flatMap((integration) => integration.hooks?.vite?.() ?? [])
40
+ ];
41
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2023",
4
+ "module": "preserve",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["es2023", "dom"],
7
+ "types": ["bun"],
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "noEmit": true,
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": true,
13
+ "skipLibCheck": true
14
+ },
15
+ "include": ["src"]
16
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.