quicklook-kit 0.6.0 → 0.7.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/README.markdown +106 -62
- package/dist/cli.mjs +213 -21
- package/dist/{config-BVPHnCWr.mjs → config-qdxXRvMf.mjs} +29 -7
- package/dist/config.d.mts +26 -9
- package/dist/config.mjs +1 -1
- package/dist/runtime.d.mts +1 -3
- package/dist/runtime.mjs +1 -2
- package/dist/vite.d.mts +1 -3
- package/dist/vite.mjs +25 -8
- package/native/Windows/Plugin.cs +232 -0
- package/package.json +3 -3
package/README.markdown
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
[](https://www.npmjs.org/package/quicklook-kit)
|
|
5
5
|
[](https://github.com/idleberg/quicklook-kit/actions)
|
|
6
6
|
|
|
7
|
-
> Build
|
|
7
|
+
> Build Quick Look previews for macOS and Windows with Vite-based web frameworks, no Swift or C# required
|
|
8
8
|
|
|
9
|
-
Your preview is a web page. quicklook-kit builds it with your framework
|
|
9
|
+
Your preview is a web page. quicklook-kit builds it with your framework and wraps it in a small native host: a Quick Look extension on macOS, a [QuickLook](https://github.com/QL-Win/QuickLook) plugin on Windows. You never touch Swift, C# 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
|
|
|
13
13
|
## Requirements
|
|
14
14
|
|
|
15
|
-
- macOS 14 or later, with [Xcode](https://apps.apple.com/app/xcode/id497799835) installed
|
|
16
|
-
- [XcodeGen](https://github.com/yonaskolb/XcodeGen)
|
|
17
15
|
- [Node.js](https://nodejs.org) 22.17 or later, which runs the `quicklook` CLI
|
|
16
|
+
- For macOS: macOS 14 or later, with [Xcode](https://apps.apple.com/app/xcode/id497799835) and [XcodeGen](https://github.com/yonaskolb/XcodeGen) installed
|
|
17
|
+
- For Windows: the [.NET SDK](https://dotnet.microsoft.com/download), on any OS. Testing the plugin needs Windows with [QuickLook](https://github.com/QL-Win/QuickLook) and the Microsoft Edge WebView2 Runtime, which ships with Windows 11
|
|
18
18
|
|
|
19
19
|
## Installation
|
|
20
20
|
|
|
@@ -31,23 +31,31 @@ To start from scratch, run `pnpm create quicklook` instead.
|
|
|
31
31
|
Create `quicklook.config.ts` in your project root:
|
|
32
32
|
|
|
33
33
|
```ts
|
|
34
|
-
import { defineConfig } from
|
|
34
|
+
import { defineConfig } from 'quicklook-kit';
|
|
35
35
|
|
|
36
36
|
export default defineConfig({
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
name: 'Quick Look Example',
|
|
38
|
+
macos: {
|
|
39
|
+
bundleId: 'com.example.QuickLookExample',
|
|
40
|
+
types: [
|
|
41
|
+
{
|
|
42
|
+
id: 'com.example.QuickLookExample.example',
|
|
43
|
+
description: 'Example File',
|
|
44
|
+
extensions: ['example'],
|
|
45
|
+
conformsTo: ['public.plain-text'],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
windows: {
|
|
50
|
+
id: 'QuickLook.Plugin.Example',
|
|
51
|
+
extensions: ['example'],
|
|
52
|
+
},
|
|
53
|
+
framework: 'sveltekit',
|
|
48
54
|
});
|
|
49
55
|
```
|
|
50
56
|
|
|
57
|
+
Each platform group is optional, but you need at least one: leave out `windows` to build for macOS only, or `macos` for Windows only.
|
|
58
|
+
|
|
51
59
|
A new type conforms to `public.plain-text` for text formats, or `public.data` for binary ones. To preview a type macOS already knows about, use a plain string like `'public.json'` in place of an object.
|
|
52
60
|
|
|
53
61
|
The config can also be `quicklook.config.js`, `.json`, `.yaml` or `.toml`, or a `.quicklookrc`. The keys are the same in every format.
|
|
@@ -55,16 +63,33 @@ The config can also be `quicklook.config.js`, `.json`, `.yaml` or `.toml`, or a
|
|
|
55
63
|
| Key | Default | |
|
|
56
64
|
| -------------- | ------------ | ---------------------------------------------------------------------------------- |
|
|
57
65
|
| `name` | required | App name, shown in Finder and System Settings |
|
|
58
|
-
| `
|
|
59
|
-
| `
|
|
66
|
+
| `macos` | none | macOS options, see below |
|
|
67
|
+
| `windows` | none | Windows options, see below |
|
|
60
68
|
| `framework` | required | `'sveltekit'`, `'astro'`, `'solid-start'`, `'nuxt'`, `'analog'`, or an integration |
|
|
61
|
-
| `icon` | none | Path to an `.icns` file for the app |
|
|
62
|
-
| `macOS` | `'14.0'` | Minimum macOS version |
|
|
63
69
|
| `integrations` | `[]` | See [Integrations](#integrations) |
|
|
64
70
|
| `csp` | locked down | Directives here replace the defaults of the same name |
|
|
65
71
|
| `maxFileSize` | `10_000_000` | Larger files get Quick Look's generic preview |
|
|
66
72
|
| `public` | `{}` | Values the page can read at build time |
|
|
67
73
|
| `settings` | `{}` | Settings the user edits in the app, see [Settings](#settings) |
|
|
74
|
+
| `devFile` | none | A file the dev server previews until you drop another, relative to the project |
|
|
75
|
+
|
|
76
|
+
Under `macos`:
|
|
77
|
+
|
|
78
|
+
| Key | Default | |
|
|
79
|
+
| ---------------- | -------- | ----------------------------------------- |
|
|
80
|
+
| `bundleId` | required | The extension gets `<bundleId>.Extension` |
|
|
81
|
+
| `types` | required | File types to preview |
|
|
82
|
+
| `icon` | none | Path to an `.icns` file for the app |
|
|
83
|
+
| `minimumVersion` | `'14.0'` | Minimum macOS version |
|
|
84
|
+
|
|
85
|
+
Under `windows`:
|
|
86
|
+
|
|
87
|
+
| Key | Default | |
|
|
88
|
+
| ------------ | -------- | ---------------------------------------------------------------------------------- |
|
|
89
|
+
| `id` | required | Plugin name, starting with `QuickLook.Plugin.`, e.g. `QuickLook.Plugin.Example` |
|
|
90
|
+
| `extensions` | required | File extensions to preview, without the dot. They win over QuickLook's own viewers |
|
|
91
|
+
|
|
92
|
+
Upgrading from a version without platform groups? The old top-level `bundleId`, `icon`, `macOS` and `types` now live under `macos`, with `macOS` renamed to `minimumVersion`.
|
|
68
93
|
|
|
69
94
|
A framework can take options as a `[name, options]` pair, e.g. `['sveltekit', { outDir: 'dist' }]` when your adapter writes somewhere other than the default.
|
|
70
95
|
|
|
@@ -81,7 +106,7 @@ Each framework needs a static, client-side build:
|
|
|
81
106
|
### 2. Add the Vite plugin
|
|
82
107
|
|
|
83
108
|
```ts
|
|
84
|
-
import { quicklook } from
|
|
109
|
+
import { quicklook } from 'quicklook-kit/vite';
|
|
85
110
|
|
|
86
111
|
// in the framework's Vite config
|
|
87
112
|
plugins: [quicklook()];
|
|
@@ -98,9 +123,9 @@ or in `tsconfig.json` under `compilerOptions.types` as `"quicklook-kit/virtual"`
|
|
|
98
123
|
### 3. Read the file
|
|
99
124
|
|
|
100
125
|
```ts
|
|
101
|
-
import { browserFile, config, nativeFile } from
|
|
126
|
+
import { browserFile, config, nativeFile } from 'virtual:quicklook';
|
|
102
127
|
|
|
103
|
-
const file = nativeFile(); // the previewed file
|
|
128
|
+
const file = nativeFile(); // the previewed file, undefined in the browser
|
|
104
129
|
config.public; // values from quicklook.config.*
|
|
105
130
|
```
|
|
106
131
|
|
|
@@ -112,48 +137,57 @@ config.public; // values from quicklook.config.*
|
|
|
112
137
|
|
|
113
138
|
Decode `bytes` however your format needs: `new TextDecoder().decode(file.bytes)` for text, or a parser for binary formats.
|
|
114
139
|
|
|
115
|
-
In the dev server, `nativeFile()` returns `undefined
|
|
140
|
+
In the dev server, `nativeFile()` returns `devFile` if you set one, and `undefined` otherwise. Pass a dropped or picked `File` to `browserFile()` to get the same shape.
|
|
116
141
|
|
|
117
142
|
### 4. Add scripts
|
|
118
143
|
|
|
119
144
|
```json
|
|
120
145
|
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
146
|
+
"scripts": {
|
|
147
|
+
"dev": "quicklook dev",
|
|
148
|
+
"build": "quicklook build",
|
|
149
|
+
"register": "quicklook build && quicklook register",
|
|
150
|
+
"unregister": "quicklook unregister"
|
|
151
|
+
}
|
|
127
152
|
}
|
|
128
153
|
```
|
|
129
154
|
|
|
130
155
|
## CLI
|
|
131
156
|
|
|
132
|
-
| Command | Description
|
|
133
|
-
| ---------------------- |
|
|
134
|
-
| `quicklook dev` | Starts the framework's dev server
|
|
135
|
-
| `quicklook build:web` | Builds the page only, into `.quicklook/web`
|
|
136
|
-
| `quicklook build` | Builds
|
|
137
|
-
| `quicklook register` |
|
|
138
|
-
| `quicklook unregister` | Removes
|
|
157
|
+
| Command | Description |
|
|
158
|
+
| ---------------------- | ----------------------------------------------------------------------------- |
|
|
159
|
+
| `quicklook dev` | Starts the framework's dev server |
|
|
160
|
+
| `quicklook build:web` | Builds the page only, into `.quicklook/web` |
|
|
161
|
+
| `quicklook build` | Builds every configured platform into `.quicklook/build/<platform>` |
|
|
162
|
+
| `quicklook register` | Installs the build on the current OS: the app on macOS, the plugin on Windows |
|
|
163
|
+
| `quicklook unregister` | Removes it again |
|
|
139
164
|
|
|
140
|
-
|
|
165
|
+
`build` takes `--platform macos` or `--platform windows` to build just one. The Windows plugin builds on any OS; the macOS app only on a Mac, so elsewhere `build` skips it. Run `build` and `register` again after every change.
|
|
141
166
|
|
|
142
|
-
If the preview doesn't show up, check `pluginkit -mAv | grep <your bundleId>`, and make sure no other app claims the same file type.
|
|
167
|
+
On macOS, `register` copies the app to `/Applications` and registers its extension. Select a file in Finder and press Space. If the preview doesn't show up, check `pluginkit -mAv | grep <your macos.bundleId>`, and make sure no other app claims the same file type.
|
|
168
|
+
|
|
169
|
+
On Windows, `register` copies the plugin to `%APPDATA%\pooi.moe\QuickLook\QuickLook.Plugin\<windows.id>`. Quit and restart QuickLook, then select a file in Explorer and press Space.
|
|
143
170
|
|
|
144
171
|
Add `.quicklook` to your `.gitignore`.
|
|
145
172
|
|
|
146
173
|
## Things to know
|
|
147
174
|
|
|
148
|
-
- **Nothing can be loaded from the network.** The
|
|
175
|
+
- **Nothing can be loaded from the network.** The native host serves your build from disk with a strict Content Security Policy, on both platforms. Import images, fonts and CSS normally and they get bundled. The dev server only reports violations in the console, without blocking them.
|
|
149
176
|
- **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.
|
|
150
|
-
- **Test in Safari.**
|
|
177
|
+
- **Test in Safari and Edge.** macOS previews run in WebKit, like Safari; Windows previews in WebView2, like Edge.
|
|
151
178
|
- **The light/dark theme follows the system.** Use `prefers-color-scheme` in CSS.
|
|
152
|
-
- **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.
|
|
179
|
+
- **The macOS 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.
|
|
180
|
+
- **Windows users install the `.qlplugin`** from `.quicklook/build/windows` by previewing it in QuickLook and clicking the install button.
|
|
181
|
+
|
|
182
|
+
## Releasing
|
|
183
|
+
|
|
184
|
+
Projects made with `create quicklook` include a release workflow. Push a tag like `v1.0.0` and it builds every configured platform and attaches `<App>-v1.0.0-macos.zip` and `<App>-v1.0.0-windows.qlplugin` to a GitHub release.
|
|
185
|
+
|
|
186
|
+
If you publish a Homebrew cask, its URL needs the `-macos` suffix once you move to a release with platform groups.
|
|
153
187
|
|
|
154
188
|
## Settings
|
|
155
189
|
|
|
156
|
-
`settings` declares options the user can change
|
|
190
|
+
`settings` declares options the user can change. On macOS, the app shows a control for each entry, picked by its `type`:
|
|
157
191
|
|
|
158
192
|
```ts
|
|
159
193
|
settings: {
|
|
@@ -185,37 +219,47 @@ Every entry except `object` needs a `default`. `title` defaults to the key in wo
|
|
|
185
219
|
The page reads the values, nested like the schema, with `settings()`:
|
|
186
220
|
|
|
187
221
|
```ts
|
|
188
|
-
import { settings } from
|
|
222
|
+
import { settings } from 'virtual:quicklook';
|
|
189
223
|
|
|
190
224
|
const { wrap, editor } = settings();
|
|
191
225
|
```
|
|
192
226
|
|
|
193
227
|
In the dev server, `settings()` returns the defaults.
|
|
194
228
|
|
|
229
|
+
Windows has no settings window. Users set values in `%APPDATA%\pooi.moe\QuickLook\<windows.id>.config`, with nested keys joined by dots, and restart QuickLook. Values that don't fit the schema fall back to the default.
|
|
230
|
+
|
|
231
|
+
```xml
|
|
232
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
233
|
+
<Settings>
|
|
234
|
+
<theme>dark</theme>
|
|
235
|
+
<editor.tabSize>4</editor.tabSize>
|
|
236
|
+
</Settings>
|
|
237
|
+
```
|
|
238
|
+
|
|
195
239
|
## Integrations
|
|
196
240
|
|
|
197
241
|
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`.
|
|
198
242
|
|
|
199
243
|
```ts
|
|
200
|
-
import type { Integration } from
|
|
244
|
+
import type { Integration } from 'quicklook-kit';
|
|
201
245
|
|
|
202
246
|
export default (options: { key: string }): Integration => ({
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
247
|
+
name: 'my-integration',
|
|
248
|
+
hooks: {
|
|
249
|
+
// Adjust the resolved config.
|
|
250
|
+
'config:resolved'(config) {},
|
|
251
|
+
|
|
252
|
+
// Post-process the framework's static output before it's bundled.
|
|
253
|
+
async 'build:done'({ outDir, config }) {},
|
|
254
|
+
|
|
255
|
+
// Edit the generated XcodeGen spec: Info.plist keys, entitlements, targets. macOS only.
|
|
256
|
+
'xcode:spec'(spec, config) {
|
|
257
|
+
spec.targets.QuickLookExampleExtension.info.properties.MyKey = options.key;
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
// Add Vite plugins wherever quicklook() is used.
|
|
261
|
+
vite: () => [],
|
|
262
|
+
},
|
|
219
263
|
});
|
|
220
264
|
```
|
|
221
265
|
|
|
@@ -223,7 +267,7 @@ Add it as an object, or by name so JSON and YAML configs can use it too. A name
|
|
|
223
267
|
|
|
224
268
|
```jsonc
|
|
225
269
|
{
|
|
226
|
-
|
|
270
|
+
"integrations": ["quicklook-some-package", ["./my-integration.ts", { "key": "value" }]]
|
|
227
271
|
}
|
|
228
272
|
```
|
|
229
273
|
|
package/dist/cli.mjs
CHANGED
|
@@ -1,23 +1,110 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as serializeCsp, i as loadConfig, t as addSources } from "./config-
|
|
2
|
+
import { a as serializeCsp, i as loadConfig, t as addSources } from "./config-qdxXRvMf.mjs";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
|
-
import { cp, glob, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { cp, glob, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
7
7
|
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
8
8
|
import process from "node:process";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
-
|
|
10
|
+
import { parseArgs } from "node:util";
|
|
11
|
+
// src/windows.ts
|
|
12
|
+
const xmlEscapes = {
|
|
13
|
+
"&": "&",
|
|
14
|
+
"<": "<",
|
|
15
|
+
">": ">"
|
|
16
|
+
};
|
|
17
|
+
const xmlSpecial = /[&<>]/g;
|
|
18
|
+
function xml(text) {
|
|
19
|
+
return text.replace(xmlSpecial, (char) => xmlEscapes[char] ?? char);
|
|
20
|
+
}
|
|
21
|
+
/** The settings schema as a flat list keyed like QL-Win's settings file, `editor.fontSize`. */
|
|
22
|
+
function flatSettings(settings, prefix = "") {
|
|
23
|
+
return Object.entries(settings).flatMap(([name, setting]) => {
|
|
24
|
+
const key = prefix + name;
|
|
25
|
+
if (setting.type === "object") return flatSettings(setting.properties, `${key}.`);
|
|
26
|
+
const { type, default: fallback } = setting;
|
|
27
|
+
const { enum: options, minimum, maximum } = setting;
|
|
28
|
+
return [{
|
|
29
|
+
key,
|
|
30
|
+
type,
|
|
31
|
+
default: fallback,
|
|
32
|
+
...options && { enum: options.map((option) => typeof option === "object" && option ? option.value : option) },
|
|
33
|
+
...minimum !== void 0 && { minimum },
|
|
34
|
+
...maximum !== void 0 && { maximum }
|
|
35
|
+
}];
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** The plugin project's generated files, by name. The CLI adds the host source and `web/`. */
|
|
39
|
+
function windowsProject(config, version) {
|
|
40
|
+
const { id, extensions } = config.windows;
|
|
41
|
+
return {
|
|
42
|
+
[`${id}.csproj`]: `<Project Sdk="Microsoft.NET.Sdk">
|
|
43
|
+
<PropertyGroup>
|
|
44
|
+
<TargetFramework>net462</TargetFramework>
|
|
45
|
+
<AssemblyName>${id}</AssemblyName>
|
|
46
|
+
<RootNamespace>${id}</RootNamespace>
|
|
47
|
+
<Version>${xml(version)}</Version>
|
|
48
|
+
<LangVersion>latest</LangVersion>
|
|
49
|
+
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
|
50
|
+
</PropertyGroup>
|
|
51
|
+
|
|
52
|
+
<ItemGroup>
|
|
53
|
+
<!-- Lets a non-Windows SDK compile against .NET Framework, WPF included, without UseWPF -->
|
|
54
|
+
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
|
55
|
+
<!-- QL-Win loads QuickLook.Common itself, so compile against it but don't ship it -->
|
|
56
|
+
<PackageReference Include="QuickLook.Common" Version="4.5.0" ExcludeAssets="runtime" />
|
|
57
|
+
<!-- Same version as QL-Win's built-in viewers -->
|
|
58
|
+
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4078.44" />
|
|
59
|
+
<Reference Include="PresentationCore" />
|
|
60
|
+
<Reference Include="PresentationFramework" />
|
|
61
|
+
<Reference Include="WindowsBase" />
|
|
62
|
+
<Reference Include="System.Xaml" />
|
|
63
|
+
<Reference Include="System.Web" />
|
|
64
|
+
<Reference Include="System.Web.Extensions" />
|
|
65
|
+
</ItemGroup>
|
|
66
|
+
|
|
67
|
+
<ItemGroup>
|
|
68
|
+
<None Include="QuickLook.Plugin.Metadata.config" CopyToOutputDirectory="PreserveNewest" />
|
|
69
|
+
<None Include="quicklook.json" CopyToOutputDirectory="PreserveNewest" />
|
|
70
|
+
<None Include="web/**" CopyToOutputDirectory="PreserveNewest" />
|
|
71
|
+
</ItemGroup>
|
|
72
|
+
</Project>
|
|
73
|
+
`,
|
|
74
|
+
"QuickLook.Plugin.Metadata.config": `<?xml version="1.0" encoding="utf-8"?>
|
|
75
|
+
<Metadata>
|
|
76
|
+
<Namespace>${id}</Namespace>
|
|
77
|
+
<Version>${xml(version)}</Version>
|
|
78
|
+
<Description>${xml(config.name)}</Description>
|
|
79
|
+
</Metadata>
|
|
80
|
+
`,
|
|
81
|
+
"quicklook.json": JSON.stringify({
|
|
82
|
+
id,
|
|
83
|
+
extensions: extensions.map((extension) => extension.toLowerCase()),
|
|
84
|
+
maxFileSize: config.maxFileSize,
|
|
85
|
+
csp: serializeCsp(config.csp),
|
|
86
|
+
settings: flatSettings(config.settings)
|
|
87
|
+
})
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// src/cli.ts
|
|
91
|
+
const platforms = ["macos", "windows"];
|
|
92
|
+
const { positionals: [subcommand], values: { platform } } = parseArgs({
|
|
93
|
+
allowPositionals: true,
|
|
94
|
+
options: { platform: { type: "string" } }
|
|
95
|
+
});
|
|
96
|
+
if (platform !== void 0 && !platforms.includes(platform)) throw new Error(`--platform: expected "macos" or "windows", got "${platform}"`);
|
|
11
97
|
const config = await loadConfig();
|
|
12
|
-
const { root, name,
|
|
98
|
+
const { root, name, framework } = config;
|
|
13
99
|
const target = name.replace(/[^A-Za-z0-9]/g, "");
|
|
14
100
|
const work = `${root}/.quicklook`;
|
|
15
101
|
const web = `${work}/web`;
|
|
16
|
-
const built = `${work}/build/${name}.app`;
|
|
102
|
+
const built = `${work}/build/macos/${name}.app`;
|
|
17
103
|
const installed = `/Applications/${name}.app`;
|
|
104
|
+
const version = process.env.VERSION?.replace(/^v/, "") ?? "1.0";
|
|
105
|
+
const onWindows = process.platform === "win32";
|
|
18
106
|
const native = fileURLToPath(new URL("../native", import.meta.url));
|
|
19
107
|
const lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
|
20
|
-
const leadingV = /^v/;
|
|
21
108
|
const inlineScript = /<script\b(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi;
|
|
22
109
|
/**
|
|
23
110
|
* The settings schema as nested arrays, each entry with its own `key`: Swift's
|
|
@@ -34,10 +121,11 @@ function settingsSchema(settings) {
|
|
|
34
121
|
});
|
|
35
122
|
}
|
|
36
123
|
/** Runs a command, exiting with its status if it fails unless `nothrow` is set. */
|
|
37
|
-
function exec([file = "", ...args], { quiet = false, nothrow = false, env = process.env } = {}) {
|
|
124
|
+
function exec([file = "", ...args], { quiet = false, nothrow = false, env = process.env, cwd = root, shell = false } = {}) {
|
|
38
125
|
const { status, error } = spawnSync(file, args, {
|
|
39
|
-
cwd
|
|
126
|
+
cwd,
|
|
40
127
|
env,
|
|
128
|
+
shell,
|
|
41
129
|
stdio: quiet ? "ignore" : "inherit"
|
|
42
130
|
});
|
|
43
131
|
if (error) throw error;
|
|
@@ -51,12 +139,15 @@ function binPath() {
|
|
|
51
139
|
if (dir === dirname(dir)) return [...dirs, process.env.PATH].join(delimiter);
|
|
52
140
|
}
|
|
53
141
|
}
|
|
54
|
-
/** Runs a framework command with the project's own binaries. */
|
|
142
|
+
/** Runs a framework command with the project's own binaries, which are `.cmd` shims on Windows. */
|
|
55
143
|
function run(command) {
|
|
56
|
-
exec(command
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
144
|
+
exec(onWindows ? [command.join(" ")] : command, {
|
|
145
|
+
env: {
|
|
146
|
+
...process.env,
|
|
147
|
+
PATH: binPath()
|
|
148
|
+
},
|
|
149
|
+
shell: onWindows
|
|
150
|
+
});
|
|
60
151
|
}
|
|
61
152
|
/** Frameworks bootstrap with inline scripts; the CSP allows exactly those, by hash. */
|
|
62
153
|
async function inlineScriptHashes() {
|
|
@@ -81,17 +172,35 @@ async function buildWeb() {
|
|
|
81
172
|
});
|
|
82
173
|
await cp(outDir, web, { recursive: true });
|
|
83
174
|
}
|
|
175
|
+
function group(key) {
|
|
176
|
+
const options = config[key];
|
|
177
|
+
if (!options) throw new Error(`quicklook.config has no "${key}" group, so there is nothing to build for it.`);
|
|
178
|
+
return options;
|
|
179
|
+
}
|
|
180
|
+
/** Builds every configured platform, or just `--platform`. */
|
|
84
181
|
async function build() {
|
|
182
|
+
const targets = platform ? [platform] : platforms.filter((p) => config[p]);
|
|
183
|
+
targets.forEach(group);
|
|
184
|
+
if (targets.includes("macos") && process.platform !== "darwin") {
|
|
185
|
+
if (platform) throw new Error("Building for macOS needs a Mac.");
|
|
186
|
+
console.log("Skipping macOS, which only builds on a Mac.");
|
|
187
|
+
targets.splice(targets.indexOf("macos"), 1);
|
|
188
|
+
}
|
|
85
189
|
await buildWeb();
|
|
86
190
|
const csp = addSources(config.csp, "script-src", ...await inlineScriptHashes());
|
|
191
|
+
if (targets.includes("macos")) await buildMacos(csp);
|
|
192
|
+
if (targets.includes("windows")) await buildWindows(csp);
|
|
193
|
+
}
|
|
194
|
+
async function buildMacos(csp) {
|
|
195
|
+
const { bundleId, minimumVersion, types, icon } = group("macos");
|
|
87
196
|
const hasSettings = Object.keys(config.settings).length > 0;
|
|
88
197
|
const qlSettings = hasSettings && { QLSettings: JSON.stringify(settingsSchema(config.settings)) };
|
|
89
198
|
const spec = {
|
|
90
199
|
name: target,
|
|
91
|
-
options: { deploymentTarget: { macOS } },
|
|
200
|
+
options: { deploymentTarget: { macOS: minimumVersion } },
|
|
92
201
|
settings: { base: {
|
|
93
202
|
SWIFT_VERSION: "6.0",
|
|
94
|
-
MARKETING_VERSION:
|
|
203
|
+
MARKETING_VERSION: version,
|
|
95
204
|
CURRENT_PROJECT_VERSION: "1",
|
|
96
205
|
CODE_SIGN_STYLE: "Manual",
|
|
97
206
|
CODE_SIGN_IDENTITY: "-"
|
|
@@ -122,7 +231,7 @@ async function build() {
|
|
|
122
231
|
properties: {
|
|
123
232
|
CFBundleName: name,
|
|
124
233
|
...icon && { CFBundleIconFile: basename(icon, ".icns") },
|
|
125
|
-
LSMinimumSystemVersion:
|
|
234
|
+
LSMinimumSystemVersion: minimumVersion,
|
|
126
235
|
...qlSettings,
|
|
127
236
|
UTExportedTypeDeclarations: types.filter((type) => typeof type !== "string").map((type) => ({
|
|
128
237
|
UTTypeIdentifier: type.id,
|
|
@@ -198,13 +307,82 @@ async function build() {
|
|
|
198
307
|
"Release",
|
|
199
308
|
"-destination",
|
|
200
309
|
"generic/platform=macOS",
|
|
201
|
-
`CONFIGURATION_BUILD_DIR=${
|
|
310
|
+
`CONFIGURATION_BUILD_DIR=${dirname(built)}`,
|
|
202
311
|
"build"
|
|
203
312
|
]);
|
|
204
313
|
exec(["touch", built]);
|
|
205
314
|
console.log(`Built ${built}`);
|
|
206
315
|
}
|
|
207
|
-
|
|
316
|
+
/** The plugin folder `register` copies, and the `.qlplugin` users install. */
|
|
317
|
+
function windowsBuild() {
|
|
318
|
+
const { id } = group("windows");
|
|
319
|
+
return {
|
|
320
|
+
plugin: `${work}/build/windows/${id}`,
|
|
321
|
+
archive: `${work}/build/windows/${name}.qlplugin`
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
async function buildWindows(csp) {
|
|
325
|
+
const windows = group("windows");
|
|
326
|
+
const { plugin, archive } = windowsBuild();
|
|
327
|
+
const project = `${work}/windows`;
|
|
328
|
+
await rm(`${project}/web`, {
|
|
329
|
+
recursive: true,
|
|
330
|
+
force: true
|
|
331
|
+
});
|
|
332
|
+
await cp(web, `${project}/web`, { recursive: true });
|
|
333
|
+
await cp(`${native}/Windows/Plugin.cs`, `${project}/Plugin.cs`);
|
|
334
|
+
for (const [file, content] of Object.entries(windowsProject({
|
|
335
|
+
...config,
|
|
336
|
+
csp,
|
|
337
|
+
windows
|
|
338
|
+
}, version))) await writeFile(`${project}/${file}`, content);
|
|
339
|
+
await rm(plugin, {
|
|
340
|
+
recursive: true,
|
|
341
|
+
force: true
|
|
342
|
+
});
|
|
343
|
+
exec([
|
|
344
|
+
"dotnet",
|
|
345
|
+
"build",
|
|
346
|
+
`${project}/${windows.id}.csproj`,
|
|
347
|
+
"--configuration",
|
|
348
|
+
"Release",
|
|
349
|
+
"--output",
|
|
350
|
+
plugin,
|
|
351
|
+
"--nologo",
|
|
352
|
+
"--verbosity",
|
|
353
|
+
"quiet"
|
|
354
|
+
]);
|
|
355
|
+
const tar = onWindows ? join(process.env.SystemRoot ?? "C:\\Windows", "System32\\tar.exe") : "tar";
|
|
356
|
+
await rm(archive, { force: true });
|
|
357
|
+
exec([
|
|
358
|
+
tar,
|
|
359
|
+
"--create",
|
|
360
|
+
"--format",
|
|
361
|
+
"zip",
|
|
362
|
+
"--file",
|
|
363
|
+
archive,
|
|
364
|
+
"--exclude",
|
|
365
|
+
"*.pdb",
|
|
366
|
+
...await readdir(plugin)
|
|
367
|
+
], { cwd: plugin });
|
|
368
|
+
console.log(`Built ${archive}`);
|
|
369
|
+
}
|
|
370
|
+
/** QL-Win's folder for plugins installed by the user. */
|
|
371
|
+
const qlWinPlugins = join(process.env.APPDATA ?? "", "pooi.moe/QuickLook/QuickLook.Plugin");
|
|
372
|
+
/** Removes an installed plugin, whose DLL is locked while QuickLook runs. */
|
|
373
|
+
async function removePlugin(folder) {
|
|
374
|
+
try {
|
|
375
|
+
await rm(folder, {
|
|
376
|
+
recursive: true,
|
|
377
|
+
force: true
|
|
378
|
+
});
|
|
379
|
+
} catch (error) {
|
|
380
|
+
const { code } = error;
|
|
381
|
+
if (code === "EBUSY" || code === "EPERM") throw new Error(`Quit QuickLook first, it has ${folder} in use.`, { cause: error });
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
switch (subcommand) {
|
|
208
386
|
case "dev":
|
|
209
387
|
run(framework.build.dev);
|
|
210
388
|
break;
|
|
@@ -215,6 +393,15 @@ switch (process.argv[2]) {
|
|
|
215
393
|
await build();
|
|
216
394
|
break;
|
|
217
395
|
case "register":
|
|
396
|
+
if (onWindows) {
|
|
397
|
+
const { plugin } = windowsBuild();
|
|
398
|
+
if (!existsSync(plugin)) throw new Error(`No build at ${plugin}, run 'quicklook build' first.`);
|
|
399
|
+
const folder = join(qlWinPlugins, group("windows").id);
|
|
400
|
+
await removePlugin(folder);
|
|
401
|
+
await cp(plugin, folder, { recursive: true });
|
|
402
|
+
console.log(`Installed ${folder}\nQuit and restart QuickLook to load it.`);
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
218
405
|
if (!existsSync(built)) throw new Error(`No build at ${built}, run 'quicklook build' first.`);
|
|
219
406
|
await rm(installed, {
|
|
220
407
|
recursive: true,
|
|
@@ -249,9 +436,15 @@ switch (process.argv[2]) {
|
|
|
249
436
|
"-g",
|
|
250
437
|
installed
|
|
251
438
|
]);
|
|
252
|
-
console.log(`Installed ${installed}\nVerify with: pluginkit -mAv | grep ${bundleId}`);
|
|
439
|
+
console.log(`Installed ${installed}\nVerify with: pluginkit -mAv | grep ${group("macos").bundleId}`);
|
|
253
440
|
break;
|
|
254
441
|
case "unregister":
|
|
442
|
+
if (onWindows) {
|
|
443
|
+
const folder = join(qlWinPlugins, group("windows").id);
|
|
444
|
+
await removePlugin(folder);
|
|
445
|
+
console.log(`Removed ${folder}\nQuit and restart QuickLook to unload it.`);
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
255
448
|
exec([
|
|
256
449
|
lsregister,
|
|
257
450
|
"-u",
|
|
@@ -270,7 +463,6 @@ switch (process.argv[2]) {
|
|
|
270
463
|
});
|
|
271
464
|
console.log(`Removed ${installed}`);
|
|
272
465
|
break;
|
|
273
|
-
default: throw new Error("Usage: quicklook dev|build:web|build|register|unregister");
|
|
466
|
+
default: throw new Error("Usage: quicklook dev|build:web|build|register|unregister [--platform macos|windows]");
|
|
274
467
|
}
|
|
275
|
-
//#endregion
|
|
276
468
|
export {};
|
|
@@ -2,7 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { loadConfig } from "c12";
|
|
4
4
|
import { createJiti } from "jiti";
|
|
5
|
-
|
|
5
|
+
// src/settings.ts
|
|
6
6
|
/** The fields each setting type allows, besides `type`, `title`, `description` and `order`. */
|
|
7
7
|
const settingFields = {
|
|
8
8
|
boolean: ["default"],
|
|
@@ -80,6 +80,7 @@ const fieldChecks = [
|
|
|
80
80
|
function isRecord(value) {
|
|
81
81
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
82
82
|
}
|
|
83
|
+
const settingKey = /^[A-Za-z_][\w-]*$/;
|
|
83
84
|
/**
|
|
84
85
|
* Throws on anything in a `settings` schema the app can't show, naming the key
|
|
85
86
|
* path, e.g. `settings.editor.tabSize: default 0 is below minimum 1`.
|
|
@@ -88,7 +89,7 @@ function validateSettings(settings, path = "settings") {
|
|
|
88
89
|
if (!isRecord(settings)) throw new Error(`${path}: expected an object of settings`);
|
|
89
90
|
for (const [key, entry] of Object.entries(settings)) {
|
|
90
91
|
const at = `${path}.${key}`;
|
|
91
|
-
if (
|
|
92
|
+
if (!settingKey.test(key)) throw new Error(`${at}: keys need a letter or _ first, then letters, digits, _ or -`);
|
|
92
93
|
if (!isRecord(entry)) throw new Error(`${at}: expected an object with a type`);
|
|
93
94
|
const problem = settingProblem(entry);
|
|
94
95
|
if (problem) throw new Error(`${at}: ${problem}`);
|
|
@@ -135,8 +136,7 @@ function enumProblem(options, value, type) {
|
|
|
135
136
|
}
|
|
136
137
|
return values.includes(value) ? void 0 : `default ${JSON.stringify(value)} isn't in the enum`;
|
|
137
138
|
}
|
|
138
|
-
|
|
139
|
-
//#region src/config.ts
|
|
139
|
+
// src/config.ts
|
|
140
140
|
/** The previewed file is untrusted input; nothing on the page needs the network. */
|
|
141
141
|
const defaultCsp = {
|
|
142
142
|
"default-src": ["'none'"],
|
|
@@ -213,24 +213,47 @@ async function resolveIntegration(ref, root) {
|
|
|
213
213
|
const exported = await createJiti(join(root, "quicklook.config")).import(name, { default: true });
|
|
214
214
|
return typeof exported === "function" ? exported(options) : exported;
|
|
215
215
|
}
|
|
216
|
+
/** Top-level fields from before the platform groups, and where they went. */
|
|
217
|
+
const movedFields = {
|
|
218
|
+
bundleId: "macos.bundleId",
|
|
219
|
+
icon: "macos.icon",
|
|
220
|
+
macOS: "macos.minimumVersion",
|
|
221
|
+
types: "macos.types, Windows uses windows.extensions"
|
|
222
|
+
};
|
|
223
|
+
const windowsId = /^QuickLook\.Plugin\.[A-Za-z0-9_.]+$/;
|
|
224
|
+
function validatePlatforms(config) {
|
|
225
|
+
const moved = Object.keys(movedFields).filter((field) => field in config).map((field) => `${field}: moved to ${movedFields[field]}`);
|
|
226
|
+
if (moved.length > 0) throw new Error(moved.join("\n"));
|
|
227
|
+
if (!(config.macos || config.windows)) throw new Error("quicklook.config: add a \"macos\" or \"windows\" group for each platform to build for");
|
|
228
|
+
if (!config.windows) return;
|
|
229
|
+
const { id, extensions } = config.windows;
|
|
230
|
+
if (typeof id !== "string" || !windowsId.test(id)) throw new Error(`windows.id: expected "QuickLook.Plugin." followed by a name, got ${JSON.stringify(id)}`);
|
|
231
|
+
if (!Array.isArray(extensions) || extensions.length === 0) throw new Error("windows.extensions: expected at least one extension");
|
|
232
|
+
const dotted = extensions.find((extension) => extension.startsWith("."));
|
|
233
|
+
if (dotted) throw new Error(`windows.extensions: write "${dotted.slice(1)}" without the dot`);
|
|
234
|
+
}
|
|
216
235
|
async function loadConfig$1(root = process.cwd()) {
|
|
217
236
|
const { config } = await loadConfig({
|
|
218
237
|
name: "quicklook",
|
|
219
238
|
cwd: root
|
|
220
239
|
});
|
|
221
240
|
if (!config?.name) throw new Error(`No quicklook.config.* with a name found in ${root}`);
|
|
241
|
+
validatePlatforms(config);
|
|
222
242
|
validateSettings(config.settings ?? {});
|
|
223
|
-
const { framework: frameworkRef, integrations: refs = [], ...rest } = config;
|
|
243
|
+
const { framework: frameworkRef, integrations: refs = [], macos, ...rest } = config;
|
|
224
244
|
const framework = await resolveIntegration(frameworkRef, root);
|
|
225
245
|
if (!framework.build) throw new Error(`Framework "${framework.name}" doesn't say how to build the page`);
|
|
226
246
|
const integrations = [framework];
|
|
227
247
|
for (const ref of refs) integrations.push(await resolveIntegration(ref, root));
|
|
228
248
|
const resolved = {
|
|
229
|
-
macOS: "14.0",
|
|
230
249
|
maxFileSize: 1e7,
|
|
231
250
|
public: {},
|
|
232
251
|
settings: {},
|
|
233
252
|
...rest,
|
|
253
|
+
...macos && { macos: {
|
|
254
|
+
minimumVersion: "14.0",
|
|
255
|
+
...macos
|
|
256
|
+
} },
|
|
234
257
|
csp: {
|
|
235
258
|
...defaultCsp,
|
|
236
259
|
...config.csp
|
|
@@ -245,5 +268,4 @@ async function loadConfig$1(root = process.cwd()) {
|
|
|
245
268
|
for (const integration of integrations) await integration.hooks?.["config:resolved"]?.(resolved);
|
|
246
269
|
return resolved;
|
|
247
270
|
}
|
|
248
|
-
//#endregion
|
|
249
271
|
export { serializeCsp as a, loadConfig$1 as i, defaultCsp as n, defineConfig as r, addSources as t };
|
package/dist/config.d.mts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Plugin } from "vite";
|
|
2
|
-
//#region src/config.d.ts
|
|
3
2
|
/** An existing type id such as "public.json", or a new type the app declares. */
|
|
4
3
|
export type FileType = string | DeclaredType;
|
|
5
4
|
/** A file type the app declares itself, as a UTExportedTypeDeclaration. */
|
|
@@ -76,15 +75,27 @@ export interface Integration {
|
|
|
76
75
|
* either an integration or a function that takes the options and returns one.
|
|
77
76
|
*/
|
|
78
77
|
export type IntegrationRef = Integration | string | [name: string, options?: unknown];
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
name: string;
|
|
78
|
+
/** Builds a Quick Look extension for macOS. */
|
|
79
|
+
export interface MacosConfig {
|
|
82
80
|
bundleId: string;
|
|
83
81
|
/** Path to the app's .icns icon, relative to the project root. */
|
|
84
82
|
icon?: string;
|
|
85
|
-
/**
|
|
86
|
-
|
|
83
|
+
/** Defaults to 14.0. */
|
|
84
|
+
minimumVersion?: string;
|
|
87
85
|
types: FileType[];
|
|
86
|
+
}
|
|
87
|
+
/** Builds a plugin for QuickLook on Windows. */
|
|
88
|
+
export interface WindowsConfig {
|
|
89
|
+
/** The plugin's namespace and assembly name, e.g. `QuickLook.Plugin.Example`. */
|
|
90
|
+
id: string;
|
|
91
|
+
/** Extensions to preview, without the dot. */
|
|
92
|
+
extensions: string[];
|
|
93
|
+
}
|
|
94
|
+
export interface QuickLookConfig {
|
|
95
|
+
/** App name, shown in Finder and System Settings. */
|
|
96
|
+
name: string;
|
|
97
|
+
macos?: MacosConfig;
|
|
98
|
+
windows?: WindowsConfig;
|
|
88
99
|
/** `sveltekit`, `astro`, `solid-start`, `nuxt`, `analog`, or any integration that sets `build`. */
|
|
89
100
|
framework: IntegrationRef;
|
|
90
101
|
integrations?: IntegrationRef[];
|
|
@@ -96,11 +107,18 @@ export interface QuickLookConfig {
|
|
|
96
107
|
public?: Record<string, unknown>;
|
|
97
108
|
/** Settings the user edits in the app, read on the page with `settings()`. */
|
|
98
109
|
settings?: Record<string, Setting>;
|
|
110
|
+
/** A file the dev server previews until one is dropped, relative to the project root. */
|
|
111
|
+
devFile?: string;
|
|
99
112
|
}
|
|
100
113
|
/** What the page sees as `config` from `virtual:quicklook`. */
|
|
101
|
-
export type PageConfig = Pick<ResolvedConfig, 'name' | '
|
|
102
|
-
|
|
114
|
+
export type PageConfig = Pick<ResolvedConfig, 'name' | 'public'> & {
|
|
115
|
+
macos?: Pick<MacosConfig, 'bundleId' | 'types'>;
|
|
116
|
+
};
|
|
117
|
+
export interface ResolvedConfig extends Required<Omit<QuickLookConfig, 'framework' | 'integrations' | 'macos' | 'windows' | 'devFile'>>, Pick<QuickLookConfig, 'windows' | 'devFile'> {
|
|
103
118
|
root: string;
|
|
119
|
+
macos?: MacosConfig & {
|
|
120
|
+
minimumVersion: string;
|
|
121
|
+
};
|
|
104
122
|
framework: Integration & {
|
|
105
123
|
build: NonNullable<Integration['build']>;
|
|
106
124
|
};
|
|
@@ -114,5 +132,4 @@ export declare const serializeCsp: (csp: Csp) => string;
|
|
|
114
132
|
/** `csp` with `sources` appended to `directive`. */
|
|
115
133
|
export declare const addSources: (csp: Csp, directive: string, ...sources: string[]) => Csp;
|
|
116
134
|
export declare function loadConfig(root?: string): Promise<ResolvedConfig>;
|
|
117
|
-
//#endregion
|
|
118
135
|
export {};
|
package/dist/config.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { t as addSources, n as defaultCsp, r as defineConfig, i as loadConfig, a as serializeCsp } from "./config-
|
|
1
|
+
export { t as addSources, n as defaultCsp, r as defineConfig, i as loadConfig, a as serializeCsp } from "./config-qdxXRvMf.mjs";
|
package/dist/runtime.d.mts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
//#region src/runtime.d.ts
|
|
2
1
|
/** The file Quick Look asked to preview. */
|
|
3
2
|
export interface QuickLookFile {
|
|
4
3
|
name: string;
|
|
@@ -21,5 +20,4 @@ declare global {
|
|
|
21
20
|
/** The previewed file inside Quick Look; `undefined` in the browser. */
|
|
22
21
|
export declare function nativeFile(): QuickLookFile | undefined;
|
|
23
22
|
/** The same shape from a file picked or dropped in the browser, for the dev server. */
|
|
24
|
-
export declare function browserFile(file: File): Promise<QuickLookFile>;
|
|
25
|
-
//#endregion
|
|
23
|
+
export declare function browserFile(file: File): Promise<QuickLookFile>;
|
package/dist/runtime.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// src/runtime.ts
|
|
2
2
|
/** The previewed file inside Quick Look; `undefined` in the browser. */
|
|
3
3
|
function nativeFile() {
|
|
4
4
|
const injected = window.quicklook;
|
|
@@ -17,5 +17,4 @@ async function browserFile(file) {
|
|
|
17
17
|
bytes: new Uint8Array(await file.arrayBuffer())
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
-
//#endregion
|
|
21
20
|
export { browserFile, nativeFile };
|
package/dist/vite.d.mts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { Plugin } from "vite";
|
|
2
|
-
//#region src/vite.d.ts
|
|
3
2
|
/**
|
|
4
3
|
* Provides `virtual:quicklook`, adds the integrations' Vite plugins, and makes
|
|
5
4
|
* the dev server report what the extension's CSP would block.
|
|
6
5
|
*/
|
|
7
|
-
export declare function quicklook(): Promise<Plugin[]>;
|
|
8
|
-
//#endregion
|
|
6
|
+
export declare function quicklook(): Promise<Plugin[]>;
|
package/dist/vite.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { a as serializeCsp, i as loadConfig, t as addSources } from "./config-
|
|
2
|
-
|
|
1
|
+
import { a as serializeCsp, i as loadConfig, t as addSources } from "./config-qdxXRvMf.mjs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, extname, resolve } from "node:path";
|
|
4
|
+
// src/vite.ts
|
|
3
5
|
const virtualId = "virtual:quicklook";
|
|
4
6
|
/** The schema's defaults as nested objects, the shape the extension injects. */
|
|
5
7
|
function settingDefaults(settings) {
|
|
@@ -11,22 +13,38 @@ function settingDefaults(settings) {
|
|
|
11
13
|
*/
|
|
12
14
|
async function quicklook() {
|
|
13
15
|
const config = await loadConfig();
|
|
14
|
-
const { name,
|
|
16
|
+
const { name, macos } = config;
|
|
15
17
|
const pageConfig = {
|
|
16
18
|
name,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
public: config.public,
|
|
20
|
+
...macos && { macos: {
|
|
21
|
+
bundleId: macos.bundleId,
|
|
22
|
+
types: macos.types
|
|
23
|
+
} }
|
|
20
24
|
};
|
|
21
25
|
const virtualSource = `export const config = ${JSON.stringify(pageConfig)};
|
|
22
26
|
export { browserFile, nativeFile } from 'quicklook-kit/runtime';
|
|
23
27
|
// The injected values in Quick Look, the defaults in the dev server and when prerendering.
|
|
24
28
|
export const settings = () => globalThis.window?.quicklook?.settings ?? ${JSON.stringify(settingDefaults(config.settings))};`;
|
|
25
29
|
const devCsp = serializeCsp(addSources(addSources(config.csp, "script-src", "'unsafe-inline'"), "connect-src", "'self'", "ws:"));
|
|
30
|
+
let serve = false;
|
|
26
31
|
return [{
|
|
27
32
|
name: "quicklook",
|
|
33
|
+
config: (_, { command }) => {
|
|
34
|
+
serve = command === "serve";
|
|
35
|
+
},
|
|
28
36
|
resolveId: (source) => source === virtualId ? `\0${virtualId}` : void 0,
|
|
29
|
-
load
|
|
37
|
+
async load(source) {
|
|
38
|
+
if (source !== `\0${virtualId}`) return;
|
|
39
|
+
if (!(serve && config.devFile)) return virtualSource;
|
|
40
|
+
const path = resolve(config.root, config.devFile);
|
|
41
|
+
const file = {
|
|
42
|
+
name: basename(path),
|
|
43
|
+
extension: extname(path).slice(1).toLowerCase(),
|
|
44
|
+
base64: (await readFile(path)).toString("base64")
|
|
45
|
+
};
|
|
46
|
+
return `if (globalThis.window && !window.quicklook) window.quicklook = ${JSON.stringify(file)};\n${virtualSource}`;
|
|
47
|
+
},
|
|
30
48
|
configureServer(server) {
|
|
31
49
|
server.middlewares.use((_request, response, next) => {
|
|
32
50
|
response.setHeader("Content-Security-Policy-Report-Only", devCsp);
|
|
@@ -35,5 +53,4 @@ export const settings = () => globalThis.window?.quicklook?.settings ?? ${JSON.s
|
|
|
35
53
|
}
|
|
36
54
|
}, ...config.integrations.flatMap((integration) => integration.hooks?.vite?.() ?? [])];
|
|
37
55
|
}
|
|
38
|
-
//#endregion
|
|
39
56
|
export { quicklook };
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections;
|
|
3
|
+
using System.Collections.Generic;
|
|
4
|
+
using System.Globalization;
|
|
5
|
+
using System.IO;
|
|
6
|
+
using System.Linq;
|
|
7
|
+
using System.Threading.Tasks;
|
|
8
|
+
using System.Web;
|
|
9
|
+
using System.Web.Script.Serialization;
|
|
10
|
+
using System.Windows;
|
|
11
|
+
using System.Windows.Controls;
|
|
12
|
+
using Microsoft.Web.WebView2.Core;
|
|
13
|
+
using Microsoft.Web.WebView2.Wpf;
|
|
14
|
+
using QuickLook.Common.Helpers;
|
|
15
|
+
using QuickLook.Common.Plugin;
|
|
16
|
+
|
|
17
|
+
namespace QuickLookKit;
|
|
18
|
+
|
|
19
|
+
/// <summary>
|
|
20
|
+
/// Loads the bundled page from a fake HTTPS origin and hands it the previewed
|
|
21
|
+
/// file as <c>window.quicklook</c>, like native/Extension/PreviewViewController.swift.
|
|
22
|
+
/// Everything project-specific comes from quicklook.json, written by src/windows.ts.
|
|
23
|
+
/// </summary>
|
|
24
|
+
public class Plugin : IViewer
|
|
25
|
+
{
|
|
26
|
+
private const string Origin = "https://quicklook.example/";
|
|
27
|
+
|
|
28
|
+
private static readonly string Folder = Path.GetDirectoryName(typeof(Plugin).Assembly.Location);
|
|
29
|
+
private static readonly string Web = Path.Combine(Folder, "web") + Path.DirectorySeparatorChar;
|
|
30
|
+
private static readonly JavaScriptSerializer Json = new() { MaxJsonLength = int.MaxValue };
|
|
31
|
+
|
|
32
|
+
private static readonly Dictionary<string, object> Manifest =
|
|
33
|
+
Json.Deserialize<Dictionary<string, object>>(File.ReadAllText(Path.Combine(Folder, "quicklook.json")));
|
|
34
|
+
|
|
35
|
+
private static readonly string Id = (string)Manifest["id"];
|
|
36
|
+
private static readonly string Csp = (string)Manifest["csp"];
|
|
37
|
+
private static readonly long MaxFileSize = Convert.ToInt64(Manifest["maxFileSize"]);
|
|
38
|
+
// JavaScriptSerializer turns JSON arrays into ArrayLists, which aren't IEnumerable<object>.
|
|
39
|
+
private static readonly HashSet<string> Extensions =
|
|
40
|
+
new(((IEnumerable)Manifest["extensions"]).Cast<string>(), StringComparer.OrdinalIgnoreCase);
|
|
41
|
+
|
|
42
|
+
private WebView2 _webView;
|
|
43
|
+
|
|
44
|
+
// Above QL-Win's built-in viewers, which claim common extensions like .json too.
|
|
45
|
+
public int Priority => 1;
|
|
46
|
+
|
|
47
|
+
public void Init()
|
|
48
|
+
{
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Larger files fall through to QL-Win's own viewers instead of stalling
|
|
52
|
+
// while megabytes of base64 cross into the page.
|
|
53
|
+
public bool CanHandle(string path) =>
|
|
54
|
+
!Directory.Exists(path)
|
|
55
|
+
&& Extensions.Contains(Path.GetExtension(path).TrimStart('.'))
|
|
56
|
+
&& new FileInfo(path).Length <= MaxFileSize;
|
|
57
|
+
|
|
58
|
+
public void Prepare(string path, ContextObject context)
|
|
59
|
+
{
|
|
60
|
+
context.PreferredSize = new Size(1000, 700);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public void View(string path, ContextObject context)
|
|
64
|
+
{
|
|
65
|
+
context.Title = Path.GetFileName(path);
|
|
66
|
+
_ = ShowAsync(path, context);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async Task ShowAsync(string path, ContextObject context)
|
|
70
|
+
{
|
|
71
|
+
// QL-Win reuses this viewer and its context for the next file, calling
|
|
72
|
+
// Cleanup() in between; an outdated preview must leave the context alone.
|
|
73
|
+
WebView2 webView = null;
|
|
74
|
+
bool Current() => webView == _webView;
|
|
75
|
+
try
|
|
76
|
+
{
|
|
77
|
+
try
|
|
78
|
+
{
|
|
79
|
+
CoreWebView2Environment.GetAvailableBrowserVersionString();
|
|
80
|
+
}
|
|
81
|
+
catch (WebView2RuntimeNotFoundException)
|
|
82
|
+
{
|
|
83
|
+
context.ViewerContent = Notice(
|
|
84
|
+
"This preview needs the Microsoft Edge WebView2 Runtime.\n\n" +
|
|
85
|
+
"Install it from https://developer.microsoft.com/microsoft-edge/webview2/ and preview the file again.");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
webView = _webView = new WebView2
|
|
90
|
+
{
|
|
91
|
+
CreationProperties = new CoreWebView2CreationProperties
|
|
92
|
+
{
|
|
93
|
+
UserDataFolder = Path.Combine(Path.GetTempPath(), Id),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
context.ViewerContent = webView;
|
|
97
|
+
await webView.EnsureCoreWebView2Async();
|
|
98
|
+
if (!Current()) return;
|
|
99
|
+
var core = webView.CoreWebView2;
|
|
100
|
+
|
|
101
|
+
// Served by hand rather than mapped to the folder, so every response carries the CSP.
|
|
102
|
+
core.AddWebResourceRequestedFilter(Origin + "*", CoreWebView2WebResourceContext.All);
|
|
103
|
+
core.WebResourceRequested += (_, e) => e.Response = Serve(core.Environment, e.Request.Uri);
|
|
104
|
+
core.NavigationStarting += (_, e) => e.Cancel = !e.Uri.StartsWith(Origin, StringComparison.Ordinal);
|
|
105
|
+
core.NewWindowRequested += (_, e) => e.Handled = true;
|
|
106
|
+
|
|
107
|
+
var file = new Dictionary<string, object>
|
|
108
|
+
{
|
|
109
|
+
["name"] = Path.GetFileName(path),
|
|
110
|
+
["extension"] = Path.GetExtension(path).TrimStart('.').ToLowerInvariant(),
|
|
111
|
+
["base64"] = Convert.ToBase64String(File.ReadAllBytes(path)),
|
|
112
|
+
};
|
|
113
|
+
var settings = ((IEnumerable)Manifest["settings"]).Cast<Dictionary<string, object>>();
|
|
114
|
+
if (settings.Any())
|
|
115
|
+
{
|
|
116
|
+
file["settings"] = ResolveSettings(settings);
|
|
117
|
+
}
|
|
118
|
+
// JSON is a valid JavaScript expression, so this needs no escaping of its own.
|
|
119
|
+
// WebView2 runs this in child frames too, which mustn't see the file.
|
|
120
|
+
await core.AddScriptToExecuteOnDocumentCreatedAsync(
|
|
121
|
+
$"if (window === window.top && location.origin + '/' === '{Origin}') window.quicklook = {Json.Serialize(file)};");
|
|
122
|
+
if (!Current()) return;
|
|
123
|
+
|
|
124
|
+
core.Navigate(Origin);
|
|
125
|
+
}
|
|
126
|
+
catch (Exception e)
|
|
127
|
+
{
|
|
128
|
+
if (Current())
|
|
129
|
+
{
|
|
130
|
+
context.ViewerContent = Notice(e.ToString());
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
finally
|
|
134
|
+
{
|
|
135
|
+
if (Current())
|
|
136
|
+
{
|
|
137
|
+
context.IsBusy = false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private static CoreWebView2WebResourceResponse Serve(CoreWebView2Environment environment, string uri)
|
|
143
|
+
{
|
|
144
|
+
var path = Uri.UnescapeDataString(new Uri(uri).AbsolutePath).TrimStart('/');
|
|
145
|
+
if (path.Length == 0 || path.EndsWith("/"))
|
|
146
|
+
{
|
|
147
|
+
path += "index.html";
|
|
148
|
+
}
|
|
149
|
+
var file = Path.GetFullPath(Path.Combine(Web, path));
|
|
150
|
+
// `..%2f` arrives decoded as a real `..`; never serve anything outside web\.
|
|
151
|
+
var found = file.StartsWith(Web, StringComparison.OrdinalIgnoreCase) && File.Exists(file);
|
|
152
|
+
var headers =
|
|
153
|
+
$"Content-Type: {(found ? MimeMapping.GetMimeMapping(file) : "text/plain")}\r\n" +
|
|
154
|
+
$"Content-Security-Policy: {Csp}";
|
|
155
|
+
return environment.CreateWebResourceResponse(
|
|
156
|
+
found ? new MemoryStream(File.ReadAllBytes(file)) : null,
|
|
157
|
+
found ? 200 : 404,
|
|
158
|
+
found ? "OK" : "Not Found",
|
|
159
|
+
headers);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// <summary>
|
|
163
|
+
/// Defaults overridden by valid values from QL-Win's settings file for this
|
|
164
|
+
/// plugin, <c>%APPDATA%\pooi.moe\QuickLook\<id>.config</c>, as nested objects.
|
|
165
|
+
/// </summary>
|
|
166
|
+
private static Dictionary<string, object> ResolveSettings(IEnumerable<Dictionary<string, object>> specs)
|
|
167
|
+
{
|
|
168
|
+
var values = new Dictionary<string, object>();
|
|
169
|
+
foreach (var spec in specs)
|
|
170
|
+
{
|
|
171
|
+
var key = (string)spec["key"];
|
|
172
|
+
var type = (string)spec["type"];
|
|
173
|
+
var fallback = Parse(Convert.ToString(spec["default"], CultureInfo.InvariantCulture), type);
|
|
174
|
+
var stored = SettingHelper.Get<string>(key, null, Id);
|
|
175
|
+
// Strings stay verbatim; other types tolerate whitespace around the value.
|
|
176
|
+
var value = stored == null ? null : Parse(type == "string" ? stored : stored.Trim(), type);
|
|
177
|
+
if (value == null || !IsValid(value, type, spec))
|
|
178
|
+
{
|
|
179
|
+
value = fallback;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
var parts = key.Split('.');
|
|
183
|
+
var parent = values;
|
|
184
|
+
foreach (var part in parts.Take(parts.Length - 1))
|
|
185
|
+
{
|
|
186
|
+
if (!parent.TryGetValue(part, out var child))
|
|
187
|
+
{
|
|
188
|
+
parent[part] = child = new Dictionary<string, object>();
|
|
189
|
+
}
|
|
190
|
+
parent = (Dictionary<string, object>)child;
|
|
191
|
+
}
|
|
192
|
+
parent[parts.Last()] = value;
|
|
193
|
+
}
|
|
194
|
+
return values;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// <summary>The setting's value as bool, string, long or double, or null if it isn't one.</summary>
|
|
198
|
+
private static object Parse(string text, string type) => type switch
|
|
199
|
+
{
|
|
200
|
+
"boolean" => bool.TryParse(text, out var b) ? b : null,
|
|
201
|
+
"string" => text,
|
|
202
|
+
"integer" => long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) ? l : null,
|
|
203
|
+
"number" => double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? d : null,
|
|
204
|
+
_ => null,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
private static bool IsValid(object value, string type, Dictionary<string, object> spec)
|
|
208
|
+
{
|
|
209
|
+
if (spec.TryGetValue("enum", out var options)
|
|
210
|
+
&& !((IEnumerable)options).Cast<object>().Any(o =>
|
|
211
|
+
Equals(Parse(Convert.ToString(o, CultureInfo.InvariantCulture), type), value)))
|
|
212
|
+
{
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if (value is long or double)
|
|
216
|
+
{
|
|
217
|
+
var number = Convert.ToDouble(value);
|
|
218
|
+
if (spec.TryGetValue("minimum", out var min) && number < Convert.ToDouble(min)) return false;
|
|
219
|
+
if (spec.TryGetValue("maximum", out var max) && number > Convert.ToDouble(max)) return false;
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private static TextBox Notice(string text) =>
|
|
225
|
+
new() { Text = text, IsReadOnly = true, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(16) };
|
|
226
|
+
|
|
227
|
+
public void Cleanup()
|
|
228
|
+
{
|
|
229
|
+
_webView?.Dispose();
|
|
230
|
+
_webView = null;
|
|
231
|
+
}
|
|
232
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quicklook-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Build
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Build Quick Look previews for macOS and Windows with Vite-based web frameworks",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"quicklook",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"@types/node": "24.19.2",
|
|
54
54
|
"tsdown": "0.23.1",
|
|
55
55
|
"typescript": "6.0.3",
|
|
56
|
-
"vite": "8.3.
|
|
56
|
+
"vite": "8.3.5",
|
|
57
57
|
"vitest": "5.0.5"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|