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
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. */
|
|
@@ -13,6 +12,41 @@ export interface DeclaredType {
|
|
|
13
12
|
export type Csp = Partial<Record<string, string[]>>;
|
|
14
13
|
/** The xcodegen project spec, written as project.yml. */
|
|
15
14
|
export type XcodeSpec = Record<string, any>;
|
|
15
|
+
/** An `enum` entry: a plain value, or one with a label for the picker. */
|
|
16
|
+
export type EnumEntry<T> = T | {
|
|
17
|
+
value: T;
|
|
18
|
+
description?: string;
|
|
19
|
+
};
|
|
20
|
+
interface BaseSetting {
|
|
21
|
+
/** Defaults to the key, turned into words (`fontSize` → "Font Size"). */
|
|
22
|
+
title?: string;
|
|
23
|
+
/** Inline Markdown. */
|
|
24
|
+
description?: string;
|
|
25
|
+
order?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A user-editable setting. The type decides the control the app shows; the
|
|
29
|
+
* page gets the resolved values from `settings()`.
|
|
30
|
+
*/
|
|
31
|
+
export type Setting = (BaseSetting & {
|
|
32
|
+
type: 'boolean';
|
|
33
|
+
default: boolean;
|
|
34
|
+
}) | (BaseSetting & {
|
|
35
|
+
type: 'string';
|
|
36
|
+
default: string;
|
|
37
|
+
enum?: EnumEntry<string>[];
|
|
38
|
+
radio?: boolean;
|
|
39
|
+
}) | (BaseSetting & {
|
|
40
|
+
type: 'integer' | 'number';
|
|
41
|
+
default: number;
|
|
42
|
+
enum?: EnumEntry<number>[];
|
|
43
|
+
radio?: boolean;
|
|
44
|
+
minimum?: number;
|
|
45
|
+
maximum?: number;
|
|
46
|
+
}) | (BaseSetting & {
|
|
47
|
+
type: 'object';
|
|
48
|
+
properties: Record<string, Setting>;
|
|
49
|
+
});
|
|
16
50
|
export interface Integration {
|
|
17
51
|
name: string;
|
|
18
52
|
/** Set by frameworks only: how to run and build the page, and where it lands. */
|
|
@@ -41,15 +75,27 @@ export interface Integration {
|
|
|
41
75
|
* either an integration or a function that takes the options and returns one.
|
|
42
76
|
*/
|
|
43
77
|
export type IntegrationRef = Integration | string | [name: string, options?: unknown];
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
name: string;
|
|
78
|
+
/** Builds a Quick Look extension for macOS. */
|
|
79
|
+
export interface MacosConfig {
|
|
47
80
|
bundleId: string;
|
|
48
81
|
/** Path to the app's .icns icon, relative to the project root. */
|
|
49
82
|
icon?: string;
|
|
50
|
-
/**
|
|
51
|
-
|
|
83
|
+
/** Defaults to 14.0. */
|
|
84
|
+
minimumVersion?: string;
|
|
52
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;
|
|
53
99
|
/** `sveltekit`, `astro`, `solid-start`, `nuxt`, `analog`, or any integration that sets `build`. */
|
|
54
100
|
framework: IntegrationRef;
|
|
55
101
|
integrations?: IntegrationRef[];
|
|
@@ -59,11 +105,20 @@ export interface QuickLookConfig {
|
|
|
59
105
|
maxFileSize?: number;
|
|
60
106
|
/** Exposed to the page as `config.public` from `virtual:quicklook`. */
|
|
61
107
|
public?: Record<string, unknown>;
|
|
108
|
+
/** Settings the user edits in the app, read on the page with `settings()`. */
|
|
109
|
+
settings?: Record<string, Setting>;
|
|
110
|
+
/** A file the dev server previews until one is dropped, relative to the project root. */
|
|
111
|
+
devFile?: string;
|
|
62
112
|
}
|
|
63
113
|
/** What the page sees as `config` from `virtual:quicklook`. */
|
|
64
|
-
export type PageConfig = Pick<ResolvedConfig, 'name' | '
|
|
65
|
-
|
|
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'> {
|
|
66
118
|
root: string;
|
|
119
|
+
macos?: MacosConfig & {
|
|
120
|
+
minimumVersion: string;
|
|
121
|
+
};
|
|
67
122
|
framework: Integration & {
|
|
68
123
|
build: NonNullable<Integration['build']>;
|
|
69
124
|
};
|
|
@@ -77,4 +132,4 @@ export declare const serializeCsp: (csp: Csp) => string;
|
|
|
77
132
|
/** `csp` with `sources` appended to `directive`. */
|
|
78
133
|
export declare const addSources: (csp: Csp, directive: string, ...sources: string[]) => Csp;
|
|
79
134
|
export declare function loadConfig(root?: string): Promise<ResolvedConfig>;
|
|
80
|
-
|
|
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;
|
|
@@ -13,11 +12,12 @@ declare global {
|
|
|
13
12
|
name: string;
|
|
14
13
|
extension: string;
|
|
15
14
|
base64: string;
|
|
15
|
+
/** Present when the config declares `settings`. */
|
|
16
|
+
settings?: Record<string, unknown>;
|
|
16
17
|
};
|
|
17
18
|
}
|
|
18
19
|
}
|
|
19
20
|
/** The previewed file inside Quick Look; `undefined` in the browser. */
|
|
20
21
|
export declare function nativeFile(): QuickLookFile | undefined;
|
|
21
22
|
/** The same shape from a file picked or dropped in the browser, for the dev server. */
|
|
22
|
-
export declare function browserFile(file: File): Promise<QuickLookFile>;
|
|
23
|
-
//#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,26 +1,50 @@
|
|
|
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";
|
|
6
|
+
/** The schema's defaults as nested objects, the shape the extension injects. */
|
|
7
|
+
function settingDefaults(settings) {
|
|
8
|
+
return Object.fromEntries(Object.entries(settings).map(([key, setting]) => [key, setting.type === "object" ? settingDefaults(setting.properties) : setting.default]));
|
|
9
|
+
}
|
|
4
10
|
/**
|
|
5
11
|
* Provides `virtual:quicklook`, adds the integrations' Vite plugins, and makes
|
|
6
12
|
* the dev server report what the extension's CSP would block.
|
|
7
13
|
*/
|
|
8
14
|
async function quicklook() {
|
|
9
15
|
const config = await loadConfig();
|
|
10
|
-
const { name,
|
|
16
|
+
const { name, macos } = config;
|
|
11
17
|
const pageConfig = {
|
|
12
18
|
name,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
public: config.public,
|
|
20
|
+
...macos && { macos: {
|
|
21
|
+
bundleId: macos.bundleId,
|
|
22
|
+
types: macos.types
|
|
23
|
+
} }
|
|
16
24
|
};
|
|
17
25
|
const virtualSource = `export const config = ${JSON.stringify(pageConfig)};
|
|
18
|
-
export { browserFile, nativeFile } from 'quicklook-kit/runtime'
|
|
26
|
+
export { browserFile, nativeFile } from 'quicklook-kit/runtime';
|
|
27
|
+
// The injected values in Quick Look, the defaults in the dev server and when prerendering.
|
|
28
|
+
export const settings = () => globalThis.window?.quicklook?.settings ?? ${JSON.stringify(settingDefaults(config.settings))};`;
|
|
19
29
|
const devCsp = serializeCsp(addSources(addSources(config.csp, "script-src", "'unsafe-inline'"), "connect-src", "'self'", "ws:"));
|
|
30
|
+
let serve = false;
|
|
20
31
|
return [{
|
|
21
32
|
name: "quicklook",
|
|
33
|
+
config: (_, { command }) => {
|
|
34
|
+
serve = command === "serve";
|
|
35
|
+
},
|
|
22
36
|
resolveId: (source) => source === virtualId ? `\0${virtualId}` : void 0,
|
|
23
|
-
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
|
+
},
|
|
24
48
|
configureServer(server) {
|
|
25
49
|
server.middlewares.use((_request, response, next) => {
|
|
26
50
|
response.setHeader("Content-Security-Policy-Report-Only", devCsp);
|
|
@@ -29,5 +53,4 @@ export { browserFile, nativeFile } from 'quicklook-kit/runtime';`;
|
|
|
29
53
|
}
|
|
30
54
|
}, ...config.integrations.flatMap((integration) => integration.hooks?.vite?.() ?? [])];
|
|
31
55
|
}
|
|
32
|
-
//#endregion
|
|
33
56
|
export { quicklook };
|
package/native/App/HostApp.swift
CHANGED
|
@@ -8,16 +8,237 @@ import UniformTypeIdentifiers
|
|
|
8
8
|
struct HostApp: App {
|
|
9
9
|
private let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? ""
|
|
10
10
|
|
|
11
|
+
private let specs = SettingSpec.load()
|
|
12
|
+
|
|
11
13
|
var body: some Scene {
|
|
12
14
|
Window(name, id: "main") {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
if specs.isEmpty {
|
|
16
|
+
ContentView(name: name)
|
|
17
|
+
.padding([.horizontal, .top], 32)
|
|
18
|
+
// Matches the gap above the footer.
|
|
19
|
+
.padding(.bottom, 16)
|
|
20
|
+
.fixedSize()
|
|
21
|
+
} else {
|
|
22
|
+
TabView {
|
|
23
|
+
ContentView(name: name)
|
|
24
|
+
.padding([.horizontal, .top], 32)
|
|
25
|
+
// Matches the gap above the footer.
|
|
26
|
+
.padding(.bottom, 16)
|
|
27
|
+
.tabItem { Text("General") }
|
|
28
|
+
VStack(spacing: 16) {
|
|
29
|
+
ScrollView {
|
|
30
|
+
VStack(alignment: .leading, spacing: 20) {
|
|
31
|
+
ForEach(specs, id: \.key) { SettingRow(spec: $0) }
|
|
32
|
+
}
|
|
33
|
+
.frame(width: 360, alignment: .leading)
|
|
34
|
+
}
|
|
35
|
+
// Caps the window height; longer forms scroll.
|
|
36
|
+
.frame(maxHeight: 420)
|
|
37
|
+
Footer()
|
|
38
|
+
}
|
|
39
|
+
.frame(width: 360)
|
|
40
|
+
.padding([.horizontal, .top], 32)
|
|
41
|
+
// Matches the gap above the footer.
|
|
42
|
+
.padding(.bottom, 16)
|
|
43
|
+
.tabItem { Text("Settings") }
|
|
44
|
+
}
|
|
45
|
+
.padding([.horizontal, .top], 20)
|
|
15
46
|
.fixedSize()
|
|
47
|
+
}
|
|
16
48
|
}
|
|
17
49
|
.windowResizability(.contentSize)
|
|
18
50
|
}
|
|
19
51
|
}
|
|
20
52
|
|
|
53
|
+
/// Shown at the bottom of every tab.
|
|
54
|
+
private struct Footer: View {
|
|
55
|
+
var body: some View {
|
|
56
|
+
Divider()
|
|
57
|
+
HStack(spacing: 3) {
|
|
58
|
+
Text("Made with").foregroundStyle(.tertiary)
|
|
59
|
+
Link("quicklook-kit", destination: URL(string: "https://github.com/idleberg/quicklook-kit")!)
|
|
60
|
+
.foregroundStyle(.secondary)
|
|
61
|
+
// A separate view, so only the link gets the pointer.
|
|
62
|
+
.onHover { $0 ? NSCursor.pointingHand.push() : NSCursor.pop() }
|
|
63
|
+
}
|
|
64
|
+
.font(.caption)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// One entry of the settings form, in a single column: title, description, control.
|
|
69
|
+
/// Values live in `UserDefaults.standard` under the dotted key, where the extension reads them.
|
|
70
|
+
private struct SettingRow: View {
|
|
71
|
+
let spec: SettingSpec
|
|
72
|
+
|
|
73
|
+
var body: some View {
|
|
74
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
75
|
+
if spec.type == "boolean" {
|
|
76
|
+
CheckboxSetting(spec: spec)
|
|
77
|
+
SettingDescription(spec: spec)
|
|
78
|
+
} else {
|
|
79
|
+
Text(spec.title).font(spec.type == "object" ? .headline : .body).bold()
|
|
80
|
+
SettingDescription(spec: spec)
|
|
81
|
+
if spec.type == "object" {
|
|
82
|
+
VStack(alignment: .leading, spacing: 16) {
|
|
83
|
+
ForEach(spec.children, id: \.key) { SettingRow(spec: $0) }
|
|
84
|
+
}
|
|
85
|
+
.padding(.leading, 16)
|
|
86
|
+
.padding(.top, 6)
|
|
87
|
+
} else if !spec.options.isEmpty {
|
|
88
|
+
PickerSetting(spec: spec)
|
|
89
|
+
} else {
|
|
90
|
+
FieldSetting(spec: spec)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private struct SettingDescription: View {
|
|
98
|
+
let spec: SettingSpec
|
|
99
|
+
|
|
100
|
+
var body: some View {
|
|
101
|
+
if let description = spec.description {
|
|
102
|
+
let options = AttributedString.MarkdownParsingOptions(
|
|
103
|
+
interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
|
104
|
+
Text(
|
|
105
|
+
(try? AttributedString(markdown: description, options: options))
|
|
106
|
+
?? AttributedString(description)
|
|
107
|
+
)
|
|
108
|
+
.foregroundStyle(.secondary)
|
|
109
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private struct CheckboxSetting: View {
|
|
115
|
+
let spec: SettingSpec
|
|
116
|
+
@State private var isOn: Bool
|
|
117
|
+
|
|
118
|
+
init(spec: SettingSpec) {
|
|
119
|
+
self.spec = spec
|
|
120
|
+
_isOn = State(initialValue: (storedValue(spec) ?? spec.defaultValue) as? Bool ?? false)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
var body: some View {
|
|
124
|
+
Toggle(isOn: $isOn) { Text(spec.title).bold() }
|
|
125
|
+
.toggleStyle(.checkbox)
|
|
126
|
+
.onChange(of: isOn) { save(isOn, for: spec) }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private struct PickerSetting: View {
|
|
131
|
+
let spec: SettingSpec
|
|
132
|
+
@State private var selection: Int
|
|
133
|
+
|
|
134
|
+
init(spec: SettingSpec) {
|
|
135
|
+
self.spec = spec
|
|
136
|
+
let current = storedValue(spec) ?? spec.defaultValue
|
|
137
|
+
_selection = State(
|
|
138
|
+
initialValue: spec.options.firstIndex { option in
|
|
139
|
+
current.map { same(option.value, $0) } ?? false
|
|
140
|
+
} ?? 0)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
var body: some View {
|
|
144
|
+
let picker = Picker(spec.title, selection: $selection) {
|
|
145
|
+
ForEach(spec.options.indices, id: \.self) { Text(spec.options[$0].label).tag($0) }
|
|
146
|
+
}
|
|
147
|
+
.labelsHidden()
|
|
148
|
+
.fixedSize()
|
|
149
|
+
.onChange(of: selection) { save(spec.options[selection].value, for: spec) }
|
|
150
|
+
|
|
151
|
+
if spec.radio {
|
|
152
|
+
picker.pickerStyle(.radioGroup)
|
|
153
|
+
} else {
|
|
154
|
+
picker.pickerStyle(.menu)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// Saves on Return or focus loss. Empty removes the stored value, so the default
|
|
160
|
+
/// (shown as placeholder) applies again.
|
|
161
|
+
private struct FieldSetting: View {
|
|
162
|
+
let spec: SettingSpec
|
|
163
|
+
@State private var text: String
|
|
164
|
+
@FocusState private var isFocused: Bool
|
|
165
|
+
|
|
166
|
+
init(spec: SettingSpec) {
|
|
167
|
+
self.spec = spec
|
|
168
|
+
_text = State(initialValue: storedValue(spec).map { "\($0)" } ?? "")
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
var body: some View {
|
|
172
|
+
TextField(spec.defaultValue.map { String(describing: $0) } ?? "", text: $text)
|
|
173
|
+
.focused($isFocused)
|
|
174
|
+
.onSubmit(commit)
|
|
175
|
+
.onChange(of: isFocused) { if !isFocused { commit() } }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private func commit() {
|
|
179
|
+
let input = spec.type == "string" ? text : text.trimmingCharacters(in: .whitespaces)
|
|
180
|
+
if input.isEmpty {
|
|
181
|
+
text = ""
|
|
182
|
+
save(nil, for: spec)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let clamp = { (raw: Double) in
|
|
187
|
+
min(max(raw, spec.minimum ?? -.infinity), spec.maximum ?? .infinity)
|
|
188
|
+
}
|
|
189
|
+
var value: Any?
|
|
190
|
+
switch spec.type {
|
|
191
|
+
// Int() rejects fractions, so "2.5" never saves for an integer.
|
|
192
|
+
case "integer":
|
|
193
|
+
value = Int(input).map { clamp(Double($0)) == Double($0) ? $0 : Int(clamp(Double($0))) }
|
|
194
|
+
case "number": value = Double(input).map(clamp)
|
|
195
|
+
default: value = input
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Invalid input isn't saved; the field goes back to what's stored.
|
|
199
|
+
guard let value, spec.isValid(value) else {
|
|
200
|
+
text = storedValue(spec).map { "\($0)" } ?? ""
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
text = "\(value)"
|
|
204
|
+
save(value, for: spec)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// The stored value, or nil when there is none or it no longer fits the schema.
|
|
209
|
+
private func storedValue(_ spec: SettingSpec) -> Any? {
|
|
210
|
+
guard let value = UserDefaults.standard.object(forKey: spec.key), spec.isValid(value) else {
|
|
211
|
+
return nil
|
|
212
|
+
}
|
|
213
|
+
return value
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// Compares through NSObject, so an Int option matches the NSNumber UserDefaults returns,
|
|
217
|
+
/// and 2 matches 2.0.
|
|
218
|
+
private func same(_ lhs: Any, _ rhs: Any) -> Bool {
|
|
219
|
+
(lhs as AnyObject).isEqual(rhs as AnyObject)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// Writes or removes (nil) the value, then resets Quick Look's cache so the next
|
|
223
|
+
/// preview picks it up. Open previews don't update.
|
|
224
|
+
private func save(_ value: Any?, for spec: SettingSpec) {
|
|
225
|
+
let defaults = UserDefaults.standard
|
|
226
|
+
let old = defaults.object(forKey: spec.key)
|
|
227
|
+
if let value {
|
|
228
|
+
if let old, same(old, value) { return }
|
|
229
|
+
defaults.set(value, forKey: spec.key)
|
|
230
|
+
} else {
|
|
231
|
+
if old == nil { return }
|
|
232
|
+
defaults.removeObject(forKey: spec.key)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let process = Process()
|
|
236
|
+
process.executableURL = URL(fileURLWithPath: "/usr/bin/qlmanage")
|
|
237
|
+
process.arguments = ["-r", "cache"]
|
|
238
|
+
process.standardOutput = FileHandle.nullDevice
|
|
239
|
+
try? process.run()
|
|
240
|
+
}
|
|
241
|
+
|
|
21
242
|
private struct ContentView: View {
|
|
22
243
|
let name: String
|
|
23
244
|
|
|
@@ -60,12 +281,7 @@ private struct ContentView: View {
|
|
|
60
281
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
61
282
|
}
|
|
62
283
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
Text("Made with [quicklook-kit](https://github.com/idleberg/quicklook-kit)")
|
|
66
|
-
.font(.caption)
|
|
67
|
-
.foregroundStyle(.tertiary)
|
|
68
|
-
.tint(.secondary)
|
|
284
|
+
Footer()
|
|
69
285
|
}
|
|
70
286
|
// Fixed so the window only grows in height as text comes and goes.
|
|
71
287
|
.frame(width: 360)
|
|
@@ -29,11 +29,18 @@ final class PreviewViewController: NSViewController, QLPreviewingController {
|
|
|
29
29
|
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
|
|
30
30
|
guard size <= Self.maxFileSize else { throw CocoaError(.fileReadTooLarge) }
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
var file: [String: Any] = [
|
|
33
33
|
"name": url.lastPathComponent,
|
|
34
34
|
"extension": url.pathExtension.lowercased(),
|
|
35
35
|
"base64": try Data(contentsOf: url).base64EncodedString(),
|
|
36
36
|
]
|
|
37
|
+
// The host app saves settings to its own defaults; the extension's bundle id
|
|
38
|
+
// is the app's plus `.Extension`, and its entitlements allow reading them.
|
|
39
|
+
let specs = SettingSpec.load()
|
|
40
|
+
let suite = Bundle.main.bundleIdentifier.map { String($0.dropLast(".Extension".count)) }
|
|
41
|
+
if !specs.isEmpty, let suite, let defaults = UserDefaults(suiteName: suite) {
|
|
42
|
+
file["settings"] = resolveSettings(specs, from: defaults)
|
|
43
|
+
}
|
|
37
44
|
// JSON is a valid JavaScript expression, so this needs no escaping of its own.
|
|
38
45
|
let json = String(decoding: try JSONSerialization.data(withJSONObject: file), as: UTF8.self)
|
|
39
46
|
let scripts = webView.configuration.userContentController
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// One entry of the `QLSettings` schema the build writes into Info.plist.
|
|
4
|
+
/// Compiled into both the app, which edits the values, and the extension,
|
|
5
|
+
/// which hands them to the page. See packages/quicklook-kit/src/config.ts.
|
|
6
|
+
struct SettingSpec {
|
|
7
|
+
let key: String
|
|
8
|
+
let type: String
|
|
9
|
+
let title: String
|
|
10
|
+
let description: String?
|
|
11
|
+
let defaultValue: Any?
|
|
12
|
+
let options: [(value: Any, label: String)]
|
|
13
|
+
let radio: Bool
|
|
14
|
+
let minimum: Double?
|
|
15
|
+
let maximum: Double?
|
|
16
|
+
let children: [SettingSpec]
|
|
17
|
+
|
|
18
|
+
private init(_ entry: [String: Any], prefix: String) {
|
|
19
|
+
let name = entry["key"] as? String ?? ""
|
|
20
|
+
let type = entry["type"] as? String ?? ""
|
|
21
|
+
key = prefix + name
|
|
22
|
+
self.type = type
|
|
23
|
+
title = entry["title"] as? String ?? words(name)
|
|
24
|
+
description = entry["description"] as? String
|
|
25
|
+
radio = bool(entry["radio"] as Any) ?? false
|
|
26
|
+
minimum = number(entry["minimum"] as Any)?.doubleValue
|
|
27
|
+
maximum = number(entry["maximum"] as Any)?.doubleValue
|
|
28
|
+
children = Self.sorted(entry["properties"] as? [[String: Any]] ?? [], prefix: key + ".")
|
|
29
|
+
defaultValue = entry["default"].flatMap { Self.cast($0, as: type) }
|
|
30
|
+
options = (entry["enum"] as? [Any] ?? []).compactMap { option in
|
|
31
|
+
let object = option as? [String: Any]
|
|
32
|
+
guard let value = Self.cast(object?["value"] ?? option, as: type) else { return nil }
|
|
33
|
+
return (value, object?["description"] as? String ?? "\(value)")
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Whether a stored value fits this entry: right type, in `options` if any, within bounds, integers whole.
|
|
38
|
+
func isValid(_ value: Any) -> Bool { validated(value) != nil }
|
|
39
|
+
|
|
40
|
+
/// `value` as this entry's Swift type if it fits, so a stored 3.0 comes back as integer 3.
|
|
41
|
+
func validated(_ value: Any) -> Any? {
|
|
42
|
+
guard let cast = Self.cast(value, as: type) else { return nil }
|
|
43
|
+
if !options.isEmpty, !options.contains(where: { ($0.value as AnyObject).isEqual(cast) }) {
|
|
44
|
+
return nil
|
|
45
|
+
}
|
|
46
|
+
if let num = number(cast)?.doubleValue {
|
|
47
|
+
if let minimum, num < minimum { return nil }
|
|
48
|
+
if let maximum, num > maximum { return nil }
|
|
49
|
+
}
|
|
50
|
+
return cast
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// `value` as Bool, String, Int or Double per `type`, or nil if it's the wrong type.
|
|
54
|
+
private static func cast(_ value: Any, as type: String) -> Any? {
|
|
55
|
+
switch type {
|
|
56
|
+
case "boolean": bool(value)
|
|
57
|
+
case "string": value as? String
|
|
58
|
+
case "integer": number(value).flatMap { Int(exactly: $0.doubleValue) }
|
|
59
|
+
case "number": number(value)?.doubleValue
|
|
60
|
+
default: nil
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Top-level entries from the bundle's `QLSettings`, sorted; empty if absent.
|
|
65
|
+
static func load(from bundle: Bundle = .main) -> [SettingSpec] {
|
|
66
|
+
decode(bundle.object(forInfoDictionaryKey: "QLSettings") as? String ?? "[]")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Entries from a `QLSettings` JSON string, sorted; empty if it doesn't parse.
|
|
70
|
+
static func decode(_ json: String) -> [SettingSpec] {
|
|
71
|
+
let entries = (try? JSONSerialization.jsonObject(with: Data(json.utf8))) as? [[String: Any]]
|
|
72
|
+
return sorted(entries ?? [], prefix: "")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// By `order`, unordered entries last, then in declaration order.
|
|
76
|
+
private static func sorted(_ entries: [[String: Any]], prefix: String) -> [SettingSpec] {
|
|
77
|
+
entries.enumerated()
|
|
78
|
+
.sorted {
|
|
79
|
+
let lhs = number($0.element["order"] as Any)?.doubleValue ?? .infinity
|
|
80
|
+
let rhs = number($1.element["order"] as Any)?.doubleValue ?? .infinity
|
|
81
|
+
return (lhs, $0.offset) < (rhs, $1.offset)
|
|
82
|
+
}
|
|
83
|
+
.map { SettingSpec($0.element, prefix: prefix) }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Defaults overridden by valid values from `defaults` (read by dotted key), as nested dictionaries.
|
|
88
|
+
func resolveSettings(_ specs: [SettingSpec], from defaults: UserDefaults) -> [String: Any] {
|
|
89
|
+
var values: [String: Any] = [:]
|
|
90
|
+
for spec in specs {
|
|
91
|
+
let name = spec.key.split(separator: ".").last.map(String.init) ?? spec.key
|
|
92
|
+
values[name] =
|
|
93
|
+
spec.type == "object"
|
|
94
|
+
? resolveSettings(spec.children, from: defaults)
|
|
95
|
+
: defaults.object(forKey: spec.key).flatMap(spec.validated) ?? spec.defaultValue
|
|
96
|
+
}
|
|
97
|
+
return values
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// UserDefaults and JSONSerialization hand back NSNumber for booleans and numbers
|
|
101
|
+
// alike; only the CFBoolean ones are booleans.
|
|
102
|
+
private func bool(_ value: Any) -> Bool? {
|
|
103
|
+
guard let num = value as? NSNumber, CFGetTypeID(num) == CFBooleanGetTypeID() else { return nil }
|
|
104
|
+
return num.boolValue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private func number(_ value: Any) -> NSNumber? {
|
|
108
|
+
guard let num = value as? NSNumber, CFGetTypeID(num) != CFBooleanGetTypeID() else { return nil }
|
|
109
|
+
return num
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// "fontSize" → "Font Size"; dashes and underscores become spaces too.
|
|
113
|
+
private func words(_ key: String) -> String {
|
|
114
|
+
var spaced = ""
|
|
115
|
+
var previous: Character?
|
|
116
|
+
for char in key {
|
|
117
|
+
if char == "_" || char == "-" {
|
|
118
|
+
spaced.append(" ")
|
|
119
|
+
} else {
|
|
120
|
+
if char.isUppercase, previous?.isLowercase == true || previous?.isNumber == true {
|
|
121
|
+
spaced.append(" ")
|
|
122
|
+
}
|
|
123
|
+
spaced.append(char)
|
|
124
|
+
}
|
|
125
|
+
previous = char
|
|
126
|
+
}
|
|
127
|
+
return spaced.split(separator: " ").map { $0.prefix(1).uppercased() + $0.dropFirst() }
|
|
128
|
+
.joined(separator: " ")
|
|
129
|
+
}
|