dsh-prompt-customizer 0.4.0 → 0.4.2

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.
@@ -1,226 +1,226 @@
1
- /**
2
- * preset-io.ts —— 跨环境(Tauri 2 桌面 / Web)的预设导入导出适配层。
3
- *
4
- * 浏览器端最初跑在普通网页里:导出用 Blob + <a download>,导入用隐藏的
5
- * <input type="file"> + FileReader。宿主现在可能包在 Tauri 桌面 WebView
6
- * 里,桌面端的预期交互是「原生保存 / 打开对话框 + 真实文件读写」。
7
- *
8
- * 本模块保持文件格式纯净(encode/decode 为纯函数),把真正的 I/O 路由到
9
- * 一个小型的可注入 `PresetIoEnv`:
10
- *
11
- * - Tauri v2:通过 window.__TAURI_INTERNALS__.invoke 调用原生的
12
- * `plugin:dialog|save` / `plugin:dialog|open` 与
13
- * `plugin:fs|write_text_file` / `plugin:fs|read_text_file` 命令 —— 但
14
- * 仅当宿主应用注册了 dialog / fs 插件、且其 capability ACL 放行这些
15
- * 命令时才可用。任何拒绝(插件未注册、ACL "not allowed")都归为
16
- * `unavailable`,由调用方回退到 Web 路径 —— 所以即使 Tauri 宿主尚未
17
- * 带 dialog/fs 插件,本插件也照常工作。
18
- * - Web(以及 tauri 回退):Blob + 锚点下载 / 隐藏文件输入,与旧行为
19
- * 完全一致。在 WebView2 里锚点下载会退化为 WebView 默认的下载处理,
20
- * 文件输入仍会打开系统选择器。
21
- *
22
- * 本模块在模块作用域不触碰 DOM —— 每个分支都接受注入的假实现 —— 因此
23
- * 可以在 Node 下加载,web / tauri 已保存 / tauri 已取消 / tauri 不可用
24
- * 的整个矩阵都能单测覆盖。
25
- */
26
- import type { Preset } from './types.ts'
27
- import { presetExportFilename } from './presets.ts'
28
-
29
- // ── 环境检测 ────────────────────────────────────────────────────────────────
30
-
31
- /** Tauri v2 原始 IPC 桥(window.__TAURI_INTERNALS__)。 */
32
- export interface TauriInvoke {
33
- (cmd: string, args?: Record<string, unknown>, options?: unknown): Promise<unknown>
34
- }
35
-
36
- /** 页面是否运行在 Tauri(v1 标记或 v2 桥)webview 内。 */
37
- export function isTauriEnv(win: unknown): boolean {
38
- if (win === null || win === undefined) return false
39
- const w = win as { __TAURI_INTERNALS__?: unknown; __TAURI__?: unknown }
40
- return w.__TAURI_INTERNALS__ !== undefined || w.__TAURI__ !== undefined
41
- }
42
-
43
- /** 取可用的 Tauri v2 invoke 桥;不可用时返回 null。 */
44
- function invokeOf(win: unknown): TauriInvoke | null {
45
- if (!isTauriEnv(win)) return null
46
- const internals = (win as { __TAURI_INTERNALS__?: { invoke?: unknown } }).__TAURI_INTERNALS__
47
- return internals && typeof internals.invoke === 'function' ? (internals.invoke as TauriInvoke) : null
48
- }
49
-
50
- // ── 原生(tauri)原语 ───────────────────────────────────────────────────────
51
-
52
- export type SaveOutcome = { kind: 'saved' } | { kind: 'cancelled' } | { kind: 'unavailable' }
53
- export type OpenOutcome = { kind: 'text'; text: string } | { kind: 'cancelled' } | { kind: 'unavailable' }
54
-
55
- const JSON_FILTERS = [{ name: 'JSON', extensions: ['json'] }]
56
-
57
- /**
58
- * 原生保存对话框 + 写文件。`cancelled` = 用户关闭了对话框(调用方绝不
59
- * 得再回退到下载);`unavailable` = 宿主无法提供 dialog/fs 插件(插件
60
- * 缺失或被 ACL 拦截),调用方应当回退。
61
- */
62
- export async function tauriSaveText(invoke: TauriInvoke, defaultName: string, text: string): Promise<SaveOutcome> {
63
- try {
64
- const path = await invoke('plugin:dialog|save', { defaultPath: defaultName, filters: JSON_FILTERS })
65
- if (typeof path !== 'string' || path === '') return { kind: 'cancelled' }
66
- await invoke('plugin:fs|write_text_file', { path, contents: text })
67
- return { kind: 'saved' }
68
- } catch {
69
- return { kind: 'unavailable' }
70
- }
71
- }
72
-
73
- /** 原生打开对话框 + 读文件。`cancelled` = 用户关闭了对话框。 */
74
- export async function tauriOpenText(invoke: TauriInvoke): Promise<OpenOutcome> {
75
- try {
76
- const picked = await invoke('plugin:dialog|open', { multiple: false, directory: false, filters: JSON_FILTERS })
77
- const path = Array.isArray(picked) ? picked[0] : picked
78
- if (typeof path !== 'string' || path === '') return { kind: 'cancelled' }
79
- const text = await invoke('plugin:fs|read_text_file', { path })
80
- if (typeof text !== 'string') return { kind: 'unavailable' }
81
- return { kind: 'text', text }
82
- } catch {
83
- return { kind: 'unavailable' }
84
- }
85
- }
86
-
87
- // ── Web 回退原语 ────────────────────────────────────────────────────────────
88
-
89
- /** 锚点下载的目标接口;依赖全部注入以便 Node 里伪造。 */
90
- export interface BrowserDownloadTarget {
91
- makeAnchor(): { href: string; download: string; click(): void }
92
- makeBlob(text: string): unknown
93
- objectUrl(blob: unknown): string
94
- revoke(url: string): void
95
- }
96
-
97
- /** Web 回退导出:Blob + object URL + <a download> + revoke。 */
98
- export function webDownload(target: BrowserDownloadTarget, filename: string, text: string): void {
99
- const blob = target.makeBlob(text)
100
- const url = target.objectUrl(blob)
101
- const anchor = target.makeAnchor()
102
- anchor.href = url
103
- anchor.download = filename
104
- anchor.click()
105
- target.revoke(url)
106
- }
107
-
108
- /** FileReader 形状的读取器(浏览器注入真 FileReader,测试注入假实现)。
109
- * 回调类型带可选的事件参数,让 DOM FileReader 的
110
- * `(this: FileReader, ev: ProgressEvent) => any` 处理器能干净赋值。 */
111
- export interface FileReaderLike {
112
- readAsText(file: unknown): void
113
- onload: ((ev?: unknown) => void) | null
114
- onerror: ((ev?: unknown) => void) | null
115
- result: string | ArrayBuffer | null
116
- }
117
-
118
- /** Web 回退导入:经 FileReader 形状的读取器解析所选文件的文本。 */
119
- export function readPickedFile(reader: FileReaderLike, file: unknown): Promise<string> {
120
- return new Promise((resolve, reject) => {
121
- reader.onload = () => {
122
- const r = reader.result
123
- resolve(typeof r === 'string' ? r : String(r ?? ''))
124
- }
125
- reader.onerror = () => reject(new Error('FREAD'))
126
- reader.readAsText(file)
127
- })
128
- }
129
-
130
- // ── 纯文件格式 ──────────────────────────────────────────────────────────────
131
-
132
- /** 把单个预设编码为导出文件的 JSON 格式(单对象格式)。`envBlocklist`
133
- * 是全局环境变量黑名单的随行快照:导入端按并集并入(见 PresetsTab)。 */
134
- export function encodePresetExport(preset: Preset, envBlocklist?: string[]): string {
135
- return JSON.stringify({ name: preset.name, data: preset.data, ...(Array.isArray(envBlocklist) ? { envBlocklist } : {}) }, null, 2)
136
- }
137
-
138
- /**
139
- * 解析导出的预设文件:单个 { name, data } 对象或对象数组。空内容 /
140
- * 非法 JSON 抛普通 Error,由调用方给出友好提示;条目级的过滤在
141
- * addImportedPresets 里进行。
142
- */
143
- export function decodePresetExport(text: string): unknown {
144
- const trimmed = String(text).trim()
145
- if (trimmed === '') throw new Error('EMPTY_EXPORT')
146
- return JSON.parse(trimmed)
147
- }
148
-
149
- // ── 环境门面 ────────────────────────────────────────────────────────────────
150
-
151
- /** 调用方(UI 或测试)驱动的 I/O 面。 */
152
- export interface PresetIoEnv {
153
- readonly tauri: boolean
154
- saveText(defaultName: string, text: string): Promise<SaveOutcome>
155
- openText(): Promise<OpenOutcome>
156
- download(name: string, text: string): void
157
- }
158
-
159
- function browserDownloadTarget(): BrowserDownloadTarget {
160
- const g = globalThis as { document?: Document; URL?: { createObjectURL(b: Blob): string; revokeObjectURL(u: string): void } }
161
- const doc = g.document!
162
- const url = g.URL!
163
- return {
164
- makeAnchor: () => doc.createElement('a'),
165
- makeBlob: (text) => new Blob([text], { type: 'application/json' }),
166
- objectUrl: (blob) => url.createObjectURL(blob as Blob),
167
- revoke: (u) => url.revokeObjectURL(u),
168
- }
169
- }
170
-
171
- /** 为当前 window 构建真实环境(在 Node 里调用也安全)。 */
172
- export function makeIoEnv(win?: unknown): PresetIoEnv {
173
- const w = win !== undefined ? win : typeof window !== 'undefined' ? window : undefined
174
- const invoke = invokeOf(w)
175
- const tauri = invoke !== null
176
- return {
177
- tauri,
178
- saveText: (name, text) => (tauri ? tauriSaveText(invoke!, name, text) : Promise.resolve({ kind: 'unavailable' })),
179
- openText: () => (tauri ? tauriOpenText(invoke!) : Promise.resolve({ kind: 'unavailable' })),
180
- download: (name, text) => webDownload(browserDownloadTarget(), name, text),
181
- }
182
- }
183
-
184
- export type ExportResult = { ok: boolean; via: 'tauri' | 'browser'; cancelled?: boolean; message?: string }
185
-
186
- /**
187
- * 导出一个预设:宿主能提供原生对话框时走 tauri 保存(cancelled = 用户
188
- * 拒绝,绝不回退下载);否则走 Web 下载。
189
- */
190
- export async function exportPresetFile(preset: Preset, io?: PresetIoEnv, envBlocklist?: string[]): Promise<ExportResult> {
191
- const env = io ?? makeIoEnv()
192
- try {
193
- const text = encodePresetExport(preset, envBlocklist)
194
- const filename = presetExportFilename(preset.name)
195
- if (env.tauri) {
196
- const out = await env.saveText(filename, text)
197
- if (out.kind === 'saved') return { ok: true, via: 'tauri' }
198
- if (out.kind === 'cancelled') return { ok: false, via: 'tauri', cancelled: true }
199
- // unavailable -> 继续走 Web 下载
200
- }
201
- env.download(filename, text)
202
- return { ok: true, via: 'browser' }
203
- } catch (e) {
204
- return { ok: false, via: 'browser', message: e instanceof Error ? e.message : String(e) }
205
- }
206
- }
207
-
208
- export type ImportResult = { kind: 'text'; text: string; via: 'tauri' } | { kind: 'cancelled' } | { kind: 'unavailable' }
209
-
210
- /**
211
- * 经原生 tauri 打开对话框导入一个预设;返回 `unavailable` 时由调用方
212
- * 点击自己的隐藏文件输入(Web 回退)。
213
- */
214
- export async function importPresetFile(io?: PresetIoEnv): Promise<ImportResult> {
215
- const env = io ?? makeIoEnv()
216
- try {
217
- if (env.tauri) {
218
- const out = await env.openText()
219
- if (out.kind === 'text') return { kind: 'text', text: out.text, via: 'tauri' }
220
- if (out.kind === 'cancelled') return { kind: 'cancelled' }
221
- }
222
- } catch {
223
- /* 落到下面的选择器回退 */
224
- }
225
- return { kind: 'unavailable' }
226
- }
1
+ /**
2
+ * preset-io.ts —— 跨环境(Tauri 2 桌面 / Web)的预设导入导出适配层。
3
+ *
4
+ * 浏览器端最初跑在普通网页里:导出用 Blob + <a download>,导入用隐藏的
5
+ * <input type="file"> + FileReader。宿主现在可能包在 Tauri 桌面 WebView
6
+ * 里,桌面端的预期交互是「原生保存 / 打开对话框 + 真实文件读写」。
7
+ *
8
+ * 本模块保持文件格式纯净(encode/decode 为纯函数),把真正的 I/O 路由到
9
+ * 一个小型的可注入 `PresetIoEnv`:
10
+ *
11
+ * - Tauri v2:通过 window.__TAURI_INTERNALS__.invoke 调用原生的
12
+ * `plugin:dialog|save` / `plugin:dialog|open` 与
13
+ * `plugin:fs|write_text_file` / `plugin:fs|read_text_file` 命令 —— 但
14
+ * 仅当宿主应用注册了 dialog / fs 插件、且其 capability ACL 放行这些
15
+ * 命令时才可用。任何拒绝(插件未注册、ACL "not allowed")都归为
16
+ * `unavailable`,由调用方回退到 Web 路径 —— 所以即使 Tauri 宿主尚未
17
+ * 带 dialog/fs 插件,本插件也照常工作。
18
+ * - Web(以及 tauri 回退):Blob + 锚点下载 / 隐藏文件输入,与旧行为
19
+ * 完全一致。在 WebView2 里锚点下载会退化为 WebView 默认的下载处理,
20
+ * 文件输入仍会打开系统选择器。
21
+ *
22
+ * 本模块在模块作用域不触碰 DOM —— 每个分支都接受注入的假实现 —— 因此
23
+ * 可以在 Node 下加载,web / tauri 已保存 / tauri 已取消 / tauri 不可用
24
+ * 的整个矩阵都能单测覆盖。
25
+ */
26
+ import type { Preset } from './types.ts'
27
+ import { presetExportFilename } from './presets.ts'
28
+
29
+ // ── 环境检测 ────────────────────────────────────────────────────────────────
30
+
31
+ /** Tauri v2 原始 IPC 桥(window.__TAURI_INTERNALS__)。 */
32
+ export interface TauriInvoke {
33
+ (cmd: string, args?: Record<string, unknown>, options?: unknown): Promise<unknown>
34
+ }
35
+
36
+ /** 页面是否运行在 Tauri(v1 标记或 v2 桥)webview 内。 */
37
+ export function isTauriEnv(win: unknown): boolean {
38
+ if (win === null || win === undefined) return false
39
+ const w = win as { __TAURI_INTERNALS__?: unknown; __TAURI__?: unknown }
40
+ return w.__TAURI_INTERNALS__ !== undefined || w.__TAURI__ !== undefined
41
+ }
42
+
43
+ /** 取可用的 Tauri v2 invoke 桥;不可用时返回 null。 */
44
+ function invokeOf(win: unknown): TauriInvoke | null {
45
+ if (!isTauriEnv(win)) return null
46
+ const internals = (win as { __TAURI_INTERNALS__?: { invoke?: unknown } }).__TAURI_INTERNALS__
47
+ return internals && typeof internals.invoke === 'function' ? (internals.invoke as TauriInvoke) : null
48
+ }
49
+
50
+ // ── 原生(tauri)原语 ───────────────────────────────────────────────────────
51
+
52
+ export type SaveOutcome = { kind: 'saved' } | { kind: 'cancelled' } | { kind: 'unavailable' }
53
+ export type OpenOutcome = { kind: 'text'; text: string } | { kind: 'cancelled' } | { kind: 'unavailable' }
54
+
55
+ const JSON_FILTERS = [{ name: 'JSON', extensions: ['json'] }]
56
+
57
+ /**
58
+ * 原生保存对话框 + 写文件。`cancelled` = 用户关闭了对话框(调用方绝不
59
+ * 得再回退到下载);`unavailable` = 宿主无法提供 dialog/fs 插件(插件
60
+ * 缺失或被 ACL 拦截),调用方应当回退。
61
+ */
62
+ export async function tauriSaveText(invoke: TauriInvoke, defaultName: string, text: string): Promise<SaveOutcome> {
63
+ try {
64
+ const path = await invoke('plugin:dialog|save', { defaultPath: defaultName, filters: JSON_FILTERS })
65
+ if (typeof path !== 'string' || path === '') return { kind: 'cancelled' }
66
+ await invoke('plugin:fs|write_text_file', { path, contents: text })
67
+ return { kind: 'saved' }
68
+ } catch {
69
+ return { kind: 'unavailable' }
70
+ }
71
+ }
72
+
73
+ /** 原生打开对话框 + 读文件。`cancelled` = 用户关闭了对话框。 */
74
+ export async function tauriOpenText(invoke: TauriInvoke): Promise<OpenOutcome> {
75
+ try {
76
+ const picked = await invoke('plugin:dialog|open', { multiple: false, directory: false, filters: JSON_FILTERS })
77
+ const path = Array.isArray(picked) ? picked[0] : picked
78
+ if (typeof path !== 'string' || path === '') return { kind: 'cancelled' }
79
+ const text = await invoke('plugin:fs|read_text_file', { path })
80
+ if (typeof text !== 'string') return { kind: 'unavailable' }
81
+ return { kind: 'text', text }
82
+ } catch {
83
+ return { kind: 'unavailable' }
84
+ }
85
+ }
86
+
87
+ // ── Web 回退原语 ────────────────────────────────────────────────────────────
88
+
89
+ /** 锚点下载的目标接口;依赖全部注入以便 Node 里伪造。 */
90
+ export interface BrowserDownloadTarget {
91
+ makeAnchor(): { href: string; download: string; click(): void }
92
+ makeBlob(text: string): unknown
93
+ objectUrl(blob: unknown): string
94
+ revoke(url: string): void
95
+ }
96
+
97
+ /** Web 回退导出:Blob + object URL + <a download> + revoke。 */
98
+ export function webDownload(target: BrowserDownloadTarget, filename: string, text: string): void {
99
+ const blob = target.makeBlob(text)
100
+ const url = target.objectUrl(blob)
101
+ const anchor = target.makeAnchor()
102
+ anchor.href = url
103
+ anchor.download = filename
104
+ anchor.click()
105
+ target.revoke(url)
106
+ }
107
+
108
+ /** FileReader 形状的读取器(浏览器注入真 FileReader,测试注入假实现)。
109
+ * 回调类型带可选的事件参数,让 DOM FileReader 的
110
+ * `(this: FileReader, ev: ProgressEvent) => any` 处理器能干净赋值。 */
111
+ export interface FileReaderLike {
112
+ readAsText(file: unknown): void
113
+ onload: ((ev?: unknown) => void) | null
114
+ onerror: ((ev?: unknown) => void) | null
115
+ result: string | ArrayBuffer | null
116
+ }
117
+
118
+ /** Web 回退导入:经 FileReader 形状的读取器解析所选文件的文本。 */
119
+ export function readPickedFile(reader: FileReaderLike, file: unknown): Promise<string> {
120
+ return new Promise((resolve, reject) => {
121
+ reader.onload = () => {
122
+ const r = reader.result
123
+ resolve(typeof r === 'string' ? r : String(r ?? ''))
124
+ }
125
+ reader.onerror = () => reject(new Error('FREAD'))
126
+ reader.readAsText(file)
127
+ })
128
+ }
129
+
130
+ // ── 纯文件格式 ──────────────────────────────────────────────────────────────
131
+
132
+ /** 把单个预设编码为导出文件的 JSON 格式(单对象格式)。`envBlocklist`
133
+ * 是全局环境变量黑名单的随行快照:导入端按并集并入(见 PresetsTab)。 */
134
+ export function encodePresetExport(preset: Preset, envBlocklist?: string[]): string {
135
+ return JSON.stringify({ name: preset.name, data: preset.data, ...(Array.isArray(envBlocklist) ? { envBlocklist } : {}) }, null, 2)
136
+ }
137
+
138
+ /**
139
+ * 解析导出的预设文件:单个 { name, data } 对象或对象数组。空内容 /
140
+ * 非法 JSON 抛普通 Error,由调用方给出友好提示;条目级的过滤在
141
+ * addImportedPresets 里进行。
142
+ */
143
+ export function decodePresetExport(text: string): unknown {
144
+ const trimmed = String(text).trim()
145
+ if (trimmed === '') throw new Error('EMPTY_EXPORT')
146
+ return JSON.parse(trimmed)
147
+ }
148
+
149
+ // ── 环境门面 ────────────────────────────────────────────────────────────────
150
+
151
+ /** 调用方(UI 或测试)驱动的 I/O 面。 */
152
+ export interface PresetIoEnv {
153
+ readonly tauri: boolean
154
+ saveText(defaultName: string, text: string): Promise<SaveOutcome>
155
+ openText(): Promise<OpenOutcome>
156
+ download(name: string, text: string): void
157
+ }
158
+
159
+ function browserDownloadTarget(): BrowserDownloadTarget {
160
+ const g = globalThis as { document?: Document; URL?: { createObjectURL(b: Blob): string; revokeObjectURL(u: string): void } }
161
+ const doc = g.document!
162
+ const url = g.URL!
163
+ return {
164
+ makeAnchor: () => doc.createElement('a'),
165
+ makeBlob: (text) => new Blob([text], { type: 'application/json' }),
166
+ objectUrl: (blob) => url.createObjectURL(blob as Blob),
167
+ revoke: (u) => url.revokeObjectURL(u),
168
+ }
169
+ }
170
+
171
+ /** 为当前 window 构建真实环境(在 Node 里调用也安全)。 */
172
+ export function makeIoEnv(win?: unknown): PresetIoEnv {
173
+ const w = win !== undefined ? win : typeof window !== 'undefined' ? window : undefined
174
+ const invoke = invokeOf(w)
175
+ const tauri = invoke !== null
176
+ return {
177
+ tauri,
178
+ saveText: (name, text) => (tauri ? tauriSaveText(invoke!, name, text) : Promise.resolve({ kind: 'unavailable' })),
179
+ openText: () => (tauri ? tauriOpenText(invoke!) : Promise.resolve({ kind: 'unavailable' })),
180
+ download: (name, text) => webDownload(browserDownloadTarget(), name, text),
181
+ }
182
+ }
183
+
184
+ export type ExportResult = { ok: boolean; via: 'tauri' | 'browser'; cancelled?: boolean; message?: string }
185
+
186
+ /**
187
+ * 导出一个预设:宿主能提供原生对话框时走 tauri 保存(cancelled = 用户
188
+ * 拒绝,绝不回退下载);否则走 Web 下载。
189
+ */
190
+ export async function exportPresetFile(preset: Preset, io?: PresetIoEnv, envBlocklist?: string[]): Promise<ExportResult> {
191
+ const env = io ?? makeIoEnv()
192
+ try {
193
+ const text = encodePresetExport(preset, envBlocklist)
194
+ const filename = presetExportFilename(preset.name)
195
+ if (env.tauri) {
196
+ const out = await env.saveText(filename, text)
197
+ if (out.kind === 'saved') return { ok: true, via: 'tauri' }
198
+ if (out.kind === 'cancelled') return { ok: false, via: 'tauri', cancelled: true }
199
+ // unavailable -> 继续走 Web 下载
200
+ }
201
+ env.download(filename, text)
202
+ return { ok: true, via: 'browser' }
203
+ } catch (e) {
204
+ return { ok: false, via: 'browser', message: e instanceof Error ? e.message : String(e) }
205
+ }
206
+ }
207
+
208
+ export type ImportResult = { kind: 'text'; text: string; via: 'tauri' } | { kind: 'cancelled' } | { kind: 'unavailable' }
209
+
210
+ /**
211
+ * 经原生 tauri 打开对话框导入一个预设;返回 `unavailable` 时由调用方
212
+ * 点击自己的隐藏文件输入(Web 回退)。
213
+ */
214
+ export async function importPresetFile(io?: PresetIoEnv): Promise<ImportResult> {
215
+ const env = io ?? makeIoEnv()
216
+ try {
217
+ if (env.tauri) {
218
+ const out = await env.openText()
219
+ if (out.kind === 'text') return { kind: 'text', text: out.text, via: 'tauri' }
220
+ if (out.kind === 'cancelled') return { kind: 'cancelled' }
221
+ }
222
+ } catch {
223
+ /* 落到下面的选择器回退 */
224
+ }
225
+ return { kind: 'unavailable' }
226
+ }