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 CHANGED
@@ -4,17 +4,17 @@
4
4
  [![Version](https://img.shields.io/npm/v/quicklook-kit?style=for-the-badge)](https://www.npmjs.org/package/quicklook-kit)
5
5
  [![Build](https://img.shields.io/github/actions/workflow/status/idleberg/quicklook-kit/ci.yml?style=for-the-badge)](https://github.com/idleberg/quicklook-kit/actions)
6
6
 
7
- > Build macOS Quick Look previews with any Vite-based web framework, no Swift required
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, wraps it in a small native Quick Look extension and installs the resulting app. You never touch Swift or Xcode.
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 "quicklook-kit";
34
+ import { defineConfig } from 'quicklook-kit';
35
35
 
36
36
  export default defineConfig({
37
- name: "Quick Look Example",
38
- bundleId: "com.example.QuickLookExample",
39
- types: [
40
- {
41
- id: "com.example.QuickLookExample.example",
42
- description: "Example File",
43
- extensions: ["example"],
44
- conformsTo: ["public.plain-text"],
45
- },
46
- ],
47
- framework: "sveltekit",
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
- | `bundleId` | required | The extension gets `<bundleId>.Extension` |
59
- | `types` | required | File types to preview |
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 "quicklook-kit/vite";
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 "virtual:quicklook";
126
+ import { browserFile, config, nativeFile } from 'virtual:quicklook';
102
127
 
103
- const file = nativeFile(); // the previewed file inside Quick Look, undefined in the browser
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`. Pass a dropped or picked `File` to `browserFile()` to get the same shape.
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
- "scripts": {
122
- "dev": "quicklook dev",
123
- "build": "quicklook build",
124
- "register": "quicklook build && quicklook register",
125
- "unregister": "quicklook unregister"
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 the app with its extension into `.quicklook/build` |
137
- | `quicklook register` | Copies the built app to `/Applications` and registers the extension |
138
- | `quicklook unregister` | Removes the app from `/Applications` |
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
- After `register`, select a file in Finder and press Space. Run `build` and `register` again after every change.
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 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.
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.** It uses the same WebKit engine as Quick Look's WKWebView.
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 in the app. Each entry's `type` picks the control the app shows:
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 "virtual:quicklook";
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 "quicklook-kit";
244
+ import type { Integration } from 'quicklook-kit';
201
245
 
202
246
  export default (options: { key: string }): Integration => ({
203
- name: "my-integration",
204
- hooks: {
205
- // Adjust the resolved config.
206
- "config:resolved"(config) {},
207
-
208
- // Post-process the framework's static output before it's bundled.
209
- async "build:done"({ outDir, config }) {},
210
-
211
- // Edit the generated XcodeGen spec: Info.plist keys, entitlements, targets.
212
- "xcode:spec"(spec, config) {
213
- spec.targets.QuickLookExampleExtension.info.properties.MyKey = options.key;
214
- },
215
-
216
- // Add Vite plugins wherever quicklook() is used.
217
- vite: () => [],
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
- "integrations": ["quicklook-some-package", ["./my-integration.ts", { "key": "value" }]],
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-BVPHnCWr.mjs";
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
- //#region src/cli.ts
10
+ import { parseArgs } from "node:util";
11
+ // src/windows.ts
12
+ const xmlEscapes = {
13
+ "&": "&amp;",
14
+ "<": "&lt;",
15
+ ">": "&gt;"
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, bundleId, macOS, types, framework, icon } = config;
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: root,
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, { env: {
57
- ...process.env,
58
- PATH: binPath()
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: process.env.VERSION?.replace(leadingV, "") ?? "1.0",
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: macOS,
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=${work}/build`,
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
- switch (process.argv[2]) {
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
- //#region src/settings.ts
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 (key.includes(".")) throw new Error(`${at}: keys can't contain "."`);
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
- //#endregion
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
- export interface QuickLookConfig {
80
- /** App name, shown in Finder and System Settings. */
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
- /** Minimum macOS version. Defaults to 14.0. */
86
- macOS?: string;
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' | 'bundleId' | 'types' | 'public'>;
102
- export interface ResolvedConfig extends Required<Omit<QuickLookConfig, 'framework' | 'integrations' | 'icon'>>, Pick<QuickLookConfig, 'icon'> {
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-BVPHnCWr.mjs";
1
+ export { t as addSources, n as defaultCsp, r as defineConfig, i as loadConfig, a as serializeCsp } from "./config-qdxXRvMf.mjs";
@@ -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
- //#region src/runtime.ts
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-BVPHnCWr.mjs";
2
- //#region src/vite.ts
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, bundleId, types } = config;
16
+ const { name, macos } = config;
15
17
  const pageConfig = {
16
18
  name,
17
- bundleId,
18
- types,
19
- public: config.public
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: (source) => source === `\0${virtualId}` ? virtualSource : void 0,
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\&lt;id&gt;.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.6.0",
4
- "description": "Build macOS Quick Look previews with any Vite-based web framework",
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.4",
56
+ "vite": "8.3.5",
57
57
  "vitest": "5.0.5"
58
58
  },
59
59
  "peerDependencies": {