quicklook-kit 0.5.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 +146 -60
- package/dist/cli.mjs +250 -34
- package/dist/config-qdxXRvMf.mjs +271 -0
- package/dist/config.d.mts +64 -9
- package/dist/config.mjs +1 -1
- package/dist/runtime.d.mts +3 -3
- package/dist/runtime.mjs +1 -2
- package/dist/vite.d.mts +1 -3
- package/dist/vite.mjs +32 -9
- package/native/App/HostApp.swift +224 -8
- package/native/Extension/PreviewViewController.swift +8 -1
- package/native/Shared/Settings.swift +129 -0
- package/native/Windows/Plugin.cs +232 -0
- package/package.json +6 -4
- package/src/virtual.d.ts +2 -0
- package/dist/config-BQ-9qYNV.mjs +0 -113
|
@@ -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,13 +53,15 @@
|
|
|
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
|
+
"vitest": "5.0.5"
|
|
57
58
|
},
|
|
58
59
|
"peerDependencies": {
|
|
59
60
|
"vite": ">=7"
|
|
60
61
|
},
|
|
61
62
|
"scripts": {
|
|
62
63
|
"build": "tsdown",
|
|
63
|
-
"check": "tsc --noEmit"
|
|
64
|
+
"check": "tsc --noEmit",
|
|
65
|
+
"test": "vitest run"
|
|
64
66
|
}
|
|
65
67
|
}
|
package/src/virtual.d.ts
CHANGED
|
@@ -3,5 +3,7 @@
|
|
|
3
3
|
declare module 'virtual:quicklook' {
|
|
4
4
|
/** Build-time values from quicklook.config.*. */
|
|
5
5
|
export const config: import('quicklook-kit').PageConfig;
|
|
6
|
+
/** The user's settings: stored values over the config's defaults, as nested objects. */
|
|
7
|
+
export const settings: () => Record<string, unknown>;
|
|
6
8
|
export { browserFile, nativeFile, type QuickLookFile } from 'quicklook-kit/runtime';
|
|
7
9
|
}
|
package/dist/config-BQ-9qYNV.mjs
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
|
-
import process from "node:process";
|
|
3
|
-
import { loadConfig } from "c12";
|
|
4
|
-
import { createJiti } from "jiti";
|
|
5
|
-
//#region src/config.ts
|
|
6
|
-
/** The previewed file is untrusted input; nothing on the page needs the network. */
|
|
7
|
-
const defaultCsp = {
|
|
8
|
-
"default-src": ["'none'"],
|
|
9
|
-
"script-src": ["'self'"],
|
|
10
|
-
"style-src": ["'self'", "'unsafe-inline'"],
|
|
11
|
-
"img-src": [
|
|
12
|
-
"'self'",
|
|
13
|
-
"data:",
|
|
14
|
-
"blob:"
|
|
15
|
-
],
|
|
16
|
-
"font-src": ["'self'", "data:"],
|
|
17
|
-
"media-src": [
|
|
18
|
-
"'self'",
|
|
19
|
-
"data:",
|
|
20
|
-
"blob:"
|
|
21
|
-
]
|
|
22
|
-
};
|
|
23
|
-
const defineConfig = (config) => config;
|
|
24
|
-
const serializeCsp = (csp) => Object.entries(csp).map(([directive, sources = []]) => [directive, ...sources].join(" ")).join("; ");
|
|
25
|
-
/** `csp` with `sources` appended to `directive`. */
|
|
26
|
-
const addSources = (csp, directive, ...sources) => ({
|
|
27
|
-
...csp,
|
|
28
|
-
[directive]: [...csp[directive] ?? [], ...sources]
|
|
29
|
-
});
|
|
30
|
-
/**
|
|
31
|
-
* Built-in frameworks: their dev and build commands, and the default `outDir`
|
|
32
|
-
* of a static, client-side build. Each takes `{ outDir }` to override it.
|
|
33
|
-
*/
|
|
34
|
-
const frameworkBuilds = {
|
|
35
|
-
analog: [
|
|
36
|
-
["vite", "dev"],
|
|
37
|
-
["vite", "build"],
|
|
38
|
-
"dist"
|
|
39
|
-
],
|
|
40
|
-
astro: [
|
|
41
|
-
["astro", "dev"],
|
|
42
|
-
["astro", "build"],
|
|
43
|
-
"dist"
|
|
44
|
-
],
|
|
45
|
-
nuxt: [
|
|
46
|
-
["nuxt", "dev"],
|
|
47
|
-
["nuxt", "generate"],
|
|
48
|
-
".output/public"
|
|
49
|
-
],
|
|
50
|
-
"solid-start": [
|
|
51
|
-
["vite", "dev"],
|
|
52
|
-
["vite", "build"],
|
|
53
|
-
".output/public"
|
|
54
|
-
],
|
|
55
|
-
sveltekit: [
|
|
56
|
-
["vite", "dev"],
|
|
57
|
-
["vite", "build"],
|
|
58
|
-
"build"
|
|
59
|
-
]
|
|
60
|
-
};
|
|
61
|
-
function builtinFramework(name, options) {
|
|
62
|
-
const builtin = frameworkBuilds[name];
|
|
63
|
-
if (!builtin) return;
|
|
64
|
-
const [dev, command, outDir] = builtin;
|
|
65
|
-
return {
|
|
66
|
-
name,
|
|
67
|
-
build: {
|
|
68
|
-
dev,
|
|
69
|
-
command,
|
|
70
|
-
outDir: options?.outDir ?? outDir
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
async function resolveIntegration(ref, root) {
|
|
75
|
-
if (typeof ref === "object" && !Array.isArray(ref)) return ref;
|
|
76
|
-
const [name, options] = typeof ref === "string" ? [ref] : ref;
|
|
77
|
-
const builtin = builtinFramework(name, options);
|
|
78
|
-
if (builtin) return builtin;
|
|
79
|
-
const exported = await createJiti(join(root, "quicklook.config")).import(name, { default: true });
|
|
80
|
-
return typeof exported === "function" ? exported(options) : exported;
|
|
81
|
-
}
|
|
82
|
-
async function loadConfig$1(root = process.cwd()) {
|
|
83
|
-
const { config } = await loadConfig({
|
|
84
|
-
name: "quicklook",
|
|
85
|
-
cwd: root
|
|
86
|
-
});
|
|
87
|
-
if (!config?.name) throw new Error(`No quicklook.config.* with a name found in ${root}`);
|
|
88
|
-
const { framework: frameworkRef, integrations: refs = [], ...rest } = config;
|
|
89
|
-
const framework = await resolveIntegration(frameworkRef, root);
|
|
90
|
-
if (!framework.build) throw new Error(`Framework "${framework.name}" doesn't say how to build the page`);
|
|
91
|
-
const integrations = [framework];
|
|
92
|
-
for (const ref of refs) integrations.push(await resolveIntegration(ref, root));
|
|
93
|
-
const resolved = {
|
|
94
|
-
macOS: "14.0",
|
|
95
|
-
maxFileSize: 1e7,
|
|
96
|
-
public: {},
|
|
97
|
-
...rest,
|
|
98
|
-
csp: {
|
|
99
|
-
...defaultCsp,
|
|
100
|
-
...config.csp
|
|
101
|
-
},
|
|
102
|
-
root,
|
|
103
|
-
framework: {
|
|
104
|
-
...framework,
|
|
105
|
-
build: framework.build
|
|
106
|
-
},
|
|
107
|
-
integrations
|
|
108
|
-
};
|
|
109
|
-
for (const integration of integrations) await integration.hooks?.["config:resolved"]?.(resolved);
|
|
110
|
-
return resolved;
|
|
111
|
-
}
|
|
112
|
-
//#endregion
|
|
113
|
-
export { serializeCsp as a, loadConfig$1 as i, defaultCsp as n, defineConfig as r, addSources as t };
|