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/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
- export interface QuickLookConfig {
45
- /** App name, shown in Finder and System Settings. */
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
- /** Minimum macOS version. Defaults to 14.0. */
51
- macOS?: string;
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' | 'bundleId' | 'types' | 'public'>;
65
- 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'> {
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
- //#endregion
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-BQ-9qYNV.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;
@@ -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
- //#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,26 +1,50 @@
1
- import { a as serializeCsp, i as loadConfig, t as addSources } from "./config-BQ-9qYNV.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";
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, bundleId, types } = config;
16
+ const { name, macos } = config;
11
17
  const pageConfig = {
12
18
  name,
13
- bundleId,
14
- types,
15
- public: config.public
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: (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
+ },
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 };
@@ -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
- ContentView(name: name)
14
- .padding(32)
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
- Divider()
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
- let file = [
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
+ }