quicklook-kit 0.4.0 → 0.5.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.
- package/LICENSE +4 -0
- package/README.markdown +11 -7
- package/dist/config.d.mts +1 -1
- package/dist/runtime.d.mts +2 -2
- package/dist/runtime.mjs +1 -1
- package/native/App/HostApp.swift +155 -6
- package/native/Extension/PreviewViewController.swift +3 -3
- package/package.json +2 -2
package/LICENSE
CHANGED
package/README.markdown
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
[](https://www.npmjs.org/package/quicklook-kit)
|
|
5
5
|
[](https://github.com/idleberg/quicklook-kit/actions)
|
|
6
6
|
|
|
7
|
-
> Build macOS
|
|
7
|
+
> Build macOS Quick Look previews with any Vite-based web framework, no Swift required
|
|
8
8
|
|
|
9
|
-
Your preview is a web page. quicklook-kit builds it with your framework, wraps it in a small native
|
|
9
|
+
Your preview is a web page. quicklook-kit builds it with your framework, wraps it in a small native Quick Look extension and installs the resulting app. You never touch Swift or Xcode.
|
|
10
10
|
|
|
11
11
|
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.
|
|
12
12
|
|
|
@@ -34,7 +34,7 @@ Create `quicklook.config.ts` in your project root:
|
|
|
34
34
|
import { defineConfig } from "quicklook-kit";
|
|
35
35
|
|
|
36
36
|
export default defineConfig({
|
|
37
|
-
name: "
|
|
37
|
+
name: "Quick Look Example",
|
|
38
38
|
bundleId: "com.example.QuickLookExample",
|
|
39
39
|
types: [
|
|
40
40
|
{
|
|
@@ -62,7 +62,7 @@ The config can also be `quicklook.config.js`, `.json`, `.yaml` or `.toml`, or a
|
|
|
62
62
|
| `macOS` | `'14.0'` | Minimum macOS version |
|
|
63
63
|
| `integrations` | `[]` | See [Integrations](#integrations) |
|
|
64
64
|
| `csp` | locked down | Directives here replace the defaults of the same name |
|
|
65
|
-
| `maxFileSize` | `10_000_000` | Larger files get
|
|
65
|
+
| `maxFileSize` | `10_000_000` | Larger files get Quick Look's generic preview |
|
|
66
66
|
| `public` | `{}` | Values the page can read at build time |
|
|
67
67
|
|
|
68
68
|
A framework can take options as a `[name, options]` pair, e.g. `['sveltekit', { outDir: 'dist' }]` when your adapter writes somewhere other than the default.
|
|
@@ -99,7 +99,7 @@ or in `tsconfig.json` under `compilerOptions.types` as `"quicklook-kit/virtual"`
|
|
|
99
99
|
```ts
|
|
100
100
|
import { browserFile, config, nativeFile } from "virtual:quicklook";
|
|
101
101
|
|
|
102
|
-
const file = nativeFile(); // the previewed file inside
|
|
102
|
+
const file = nativeFile(); // the previewed file inside Quick Look, undefined in the browser
|
|
103
103
|
config.public; // values from quicklook.config.*
|
|
104
104
|
```
|
|
105
105
|
|
|
@@ -146,7 +146,7 @@ Add `.quicklook` to your `.gitignore`.
|
|
|
146
146
|
|
|
147
147
|
- **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.
|
|
148
148
|
- **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.
|
|
149
|
-
- **Test in Safari.** It uses the same WebKit engine as
|
|
149
|
+
- **Test in Safari.** It uses the same WebKit engine as Quick Look's WKWebView.
|
|
150
150
|
- **The light/dark theme follows the system.** Use `prefers-color-scheme` in CSS.
|
|
151
151
|
- **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.
|
|
152
152
|
|
|
@@ -190,7 +190,11 @@ A framework is an integration with a `build` field:
|
|
|
190
190
|
```ts
|
|
191
191
|
framework: {
|
|
192
192
|
name: 'my-framework',
|
|
193
|
-
build: {
|
|
193
|
+
build: {
|
|
194
|
+
dev: ['vite', 'dev'],
|
|
195
|
+
command: ['vite', 'build'],
|
|
196
|
+
outDir: 'dist'
|
|
197
|
+
}
|
|
194
198
|
}
|
|
195
199
|
```
|
|
196
200
|
|
package/dist/config.d.mts
CHANGED
|
@@ -55,7 +55,7 @@ export interface QuickLookConfig {
|
|
|
55
55
|
integrations?: IntegrationRef[];
|
|
56
56
|
/** Directives given here replace the defaults of the same name. */
|
|
57
57
|
csp?: Csp;
|
|
58
|
-
/** Larger files get
|
|
58
|
+
/** Larger files get Quick Look's generic preview. Defaults to 10 MB. */
|
|
59
59
|
maxFileSize?: number;
|
|
60
60
|
/** Exposed to the page as `config.public` from `virtual:quicklook`. */
|
|
61
61
|
public?: Record<string, unknown>;
|
package/dist/runtime.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region src/runtime.d.ts
|
|
2
|
-
/** The file
|
|
2
|
+
/** The file Quick Look asked to preview. */
|
|
3
3
|
export interface QuickLookFile {
|
|
4
4
|
name: string;
|
|
5
5
|
/** Lowercase, without the dot. */
|
|
@@ -16,7 +16,7 @@ declare global {
|
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
/** The previewed file inside
|
|
19
|
+
/** The previewed file inside Quick Look; `undefined` in the browser. */
|
|
20
20
|
export declare function nativeFile(): QuickLookFile | undefined;
|
|
21
21
|
/** The same shape from a file picked or dropped in the browser, for the dev server. */
|
|
22
22
|
export declare function browserFile(file: File): Promise<QuickLookFile>;
|
package/dist/runtime.mjs
CHANGED
package/native/App/HostApp.swift
CHANGED
|
@@ -1,21 +1,170 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
|
+
import UniformTypeIdentifiers
|
|
2
3
|
|
|
3
|
-
///
|
|
4
|
-
/// when that app is first launched.
|
|
4
|
+
/// Quick Look extensions have to ship inside an app, and macOS registers them
|
|
5
|
+
/// when that app is first launched. Beyond that, the app only lets the user
|
|
6
|
+
/// enable or disable the extension.
|
|
5
7
|
@main
|
|
6
8
|
struct HostApp: App {
|
|
7
9
|
private let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? ""
|
|
8
10
|
|
|
9
11
|
var body: some Scene {
|
|
10
12
|
Window(name, id: "main") {
|
|
13
|
+
ContentView(name: name)
|
|
14
|
+
.padding(32)
|
|
15
|
+
.fixedSize()
|
|
16
|
+
}
|
|
17
|
+
.windowResizability(.contentSize)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private struct ContentView: View {
|
|
22
|
+
let name: String
|
|
23
|
+
|
|
24
|
+
// Matches the extension's PRODUCT_BUNDLE_IDENTIFIER in cli.ts.
|
|
25
|
+
private let extensionId = "\(Bundle.main.bundleIdentifier ?? "").Extension"
|
|
26
|
+
|
|
27
|
+
@State private var isEnabled = true
|
|
28
|
+
@State private var status: String?
|
|
29
|
+
@State private var conflicts: [String] = []
|
|
30
|
+
|
|
31
|
+
var body: some View {
|
|
32
|
+
VStack(spacing: 16) {
|
|
11
33
|
VStack(spacing: 8) {
|
|
12
34
|
Text(name).font(.headline)
|
|
13
|
-
Text("
|
|
35
|
+
Text("Previews supported files when you press Space in Finder.")
|
|
14
36
|
.foregroundStyle(.secondary)
|
|
37
|
+
.multilineTextAlignment(.center)
|
|
15
38
|
}
|
|
16
|
-
|
|
17
|
-
|
|
39
|
+
|
|
40
|
+
Button(isEnabled ? "Disable Preview" : "Enable Preview", action: toggle)
|
|
41
|
+
|
|
42
|
+
if let status {
|
|
43
|
+
Text(status).font(.caption).foregroundStyle(.secondary)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if isEnabled && !conflicts.isEmpty {
|
|
47
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
48
|
+
Text("⚠️ \(conflicts.count == 1 ? "There is a conflict:" : "There are conflicts:")")
|
|
49
|
+
.bold()
|
|
50
|
+
ForEach(conflicts, id: \.self) { conflict in
|
|
51
|
+
Label {
|
|
52
|
+
Text(conflict)
|
|
53
|
+
} icon: {
|
|
54
|
+
Text("•")
|
|
55
|
+
}
|
|
56
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
.font(.callout)
|
|
60
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
Divider()
|
|
64
|
+
|
|
65
|
+
Text("Made with [quicklook-kit](https://github.com/idleberg/quicklook-kit)")
|
|
66
|
+
.font(.caption)
|
|
67
|
+
.foregroundStyle(.tertiary)
|
|
68
|
+
.tint(.secondary)
|
|
18
69
|
}
|
|
19
|
-
.
|
|
70
|
+
// Fixed so the window only grows in height as text comes and goes.
|
|
71
|
+
.frame(width: 360)
|
|
72
|
+
.onAppear(perform: refresh)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private func refresh() {
|
|
76
|
+
isEnabled = currentlyEnabled()
|
|
77
|
+
// The same app installed twice yields the same line twice; ForEach needs unique ids.
|
|
78
|
+
var seen = Set<String>()
|
|
79
|
+
conflicts = findConflicts().filter { seen.insert($0).inserted }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Other apps that would win over this preview for some of its file types.
|
|
83
|
+
/// ponytail: how Quick Look picks between conflicting extensions isn't documented, so
|
|
84
|
+
/// this is a best guess. Any overlap is reported, even if we'd happen to win.
|
|
85
|
+
private func findConflicts() -> [String] {
|
|
86
|
+
var conflicts: [String] = []
|
|
87
|
+
|
|
88
|
+
// Same file extension, two types: LaunchServices resolves the extension to only one.
|
|
89
|
+
// UTIs are case-insensitive, and LaunchServices returns them lowercased.
|
|
90
|
+
let declarations = Bundle.main.object(forInfoDictionaryKey: "UTExportedTypeDeclarations")
|
|
91
|
+
for declaration in declarations as? [[String: Any]] ?? [] {
|
|
92
|
+
guard let id = declaration["UTTypeIdentifier"] as? String,
|
|
93
|
+
let tags = declaration["UTTypeTagSpecification"] as? [String: Any],
|
|
94
|
+
let extensions = tags["public.filename-extension"] as? [String]
|
|
95
|
+
else { continue }
|
|
96
|
+
for ext in extensions {
|
|
97
|
+
guard let other = UTType(filenameExtension: ext)?.identifier,
|
|
98
|
+
other.lowercased() != id.lowercased()
|
|
99
|
+
else { continue }
|
|
100
|
+
conflicts.append("“.\(ext)” files are claimed by another app’s type “\(other)”.")
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Same type, two extensions.
|
|
105
|
+
let ourTypes = supportedTypes(Bundle.main.builtInPlugInsURL?.appendingPathComponent(appexName))
|
|
106
|
+
guard let output = pluginkit("-m", "-v", "-p", "com.apple.quicklook.preview") else {
|
|
107
|
+
return conflicts
|
|
108
|
+
}
|
|
109
|
+
for line in output.split(separator: "\n") {
|
|
110
|
+
guard let path = line.split(separator: "\t").last.map(String.init),
|
|
111
|
+
!line.trimmingCharacters(in: .whitespaces).hasPrefix("-"),
|
|
112
|
+
!line.contains(" \(extensionId)("),
|
|
113
|
+
!path.hasPrefix("/System/")
|
|
114
|
+
else { continue }
|
|
115
|
+
let theirTypes = supportedTypes(URL(fileURLWithPath: path))
|
|
116
|
+
let overlaps = ourTypes.contains { ours in theirTypes.contains { ours.conforms(to: $0) } }
|
|
117
|
+
guard overlaps else { continue }
|
|
118
|
+
let app = path.split(separator: "/").first { $0.hasSuffix(".app") }?.dropLast(4)
|
|
119
|
+
conflicts.append(
|
|
120
|
+
"“\(app ?? Substring(path))” also previews these files. Turn it off in System Settings → "
|
|
121
|
+
+ "Login Items & Extensions → Quick Look.")
|
|
122
|
+
}
|
|
123
|
+
return conflicts
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private var appexName: String {
|
|
127
|
+
let plugins = Bundle.main.builtInPlugInsPath ?? ""
|
|
128
|
+
let items = (try? FileManager.default.contentsOfDirectory(atPath: plugins)) ?? []
|
|
129
|
+
return items.first { $0.hasSuffix(".appex") } ?? ""
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private func supportedTypes(_ appex: URL?) -> [UTType] {
|
|
133
|
+
guard let appex, let info = Bundle(url: appex)?.infoDictionary,
|
|
134
|
+
let attributes = (info["NSExtension"] as? [String: Any])?["NSExtensionAttributes"]
|
|
135
|
+
as? [String: Any],
|
|
136
|
+
let ids = attributes["QLSupportedContentTypes"] as? [String]
|
|
137
|
+
else { return [] }
|
|
138
|
+
return ids.compactMap(UTType.init)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// `pluginkit -mv` prefixes a line with `-` when the extension is ignored,
|
|
142
|
+
/// `+` when explicitly enabled, and nothing when enabled by default.
|
|
143
|
+
private func currentlyEnabled() -> Bool {
|
|
144
|
+
guard let output = pluginkit("-m", "-v", "-i", extensionId) else { return true }
|
|
145
|
+
return !output.trimmingCharacters(in: .whitespaces).hasPrefix("-")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private func toggle() {
|
|
149
|
+
guard pluginkit("-e", isEnabled ? "ignore" : "use", "-i", extensionId) != nil else {
|
|
150
|
+
status = "Couldn't change the preview. Try again from System Settings."
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
refresh()
|
|
154
|
+
status = isEnabled ? "Preview enabled." : "Preview disabled."
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// Returns stdout, or nil if pluginkit failed to run or exited non-zero.
|
|
158
|
+
private func pluginkit(_ args: String...) -> String? {
|
|
159
|
+
let process = Process()
|
|
160
|
+
process.executableURL = URL(fileURLWithPath: "/usr/bin/pluginkit")
|
|
161
|
+
process.arguments = args
|
|
162
|
+
let pipe = Pipe()
|
|
163
|
+
process.standardOutput = pipe
|
|
164
|
+
guard (try? process.run()) != nil else { return nil }
|
|
165
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
166
|
+
process.waitUntilExit()
|
|
167
|
+
guard process.terminationStatus == 0 else { return nil }
|
|
168
|
+
return String(data: data, encoding: .utf8)
|
|
20
169
|
}
|
|
21
170
|
}
|
|
@@ -6,7 +6,7 @@ import WebKit
|
|
|
6
6
|
/// file as `window.quicklook`. See packages/quicklook-kit/src/runtime.ts.
|
|
7
7
|
@objc(PreviewViewController)
|
|
8
8
|
final class PreviewViewController: NSViewController, QLPreviewingController {
|
|
9
|
-
/// Larger files throw, which makes
|
|
9
|
+
/// Larger files throw, which makes Quick Look fall back to its generic preview
|
|
10
10
|
/// instead of stalling while megabytes of base64 cross into the page.
|
|
11
11
|
private static let maxFileSize =
|
|
12
12
|
Bundle.main.object(forInfoDictionaryKey: "QLMaxFileSize") as? Int ?? 10_000_000
|
|
@@ -43,7 +43,7 @@ final class PreviewViewController: NSViewController, QLPreviewingController {
|
|
|
43
43
|
source: "window.quicklook = \(json);", injectionTime: .atDocumentStart,
|
|
44
44
|
forMainFrameOnly: true))
|
|
45
45
|
|
|
46
|
-
//
|
|
46
|
+
// Quick Look displays the view as soon as this returns, so wait for the
|
|
47
47
|
// page to actually finish loading — otherwise larger files show blank.
|
|
48
48
|
let page = URLRequest(url: URL(string: "quicklook://app/")!)
|
|
49
49
|
try await loadWaiter.load { webView.load(page) }
|
|
@@ -117,7 +117,7 @@ private final class LoadWaiter: NSObject, WKNavigationDelegate {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
/// A crashed content process fires no navigation callback at all — without
|
|
120
|
-
/// this the await never returns and
|
|
120
|
+
/// this the await never returns and Quick Look spins forever.
|
|
121
121
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
|
122
122
|
finish(.failure(CocoaError(.featureUnsupported)))
|
|
123
123
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quicklook-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Build macOS
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Build macOS Quick Look previews with any Vite-based web framework",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"quicklook",
|