fpasoterm 1.6.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.

Potentially problematic release.


This version of fpasoterm might be problematic. Click here for more details.

Files changed (101) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/CONTRIBUTING.md +67 -0
  3. package/INSTALL.ja.md +204 -0
  4. package/INSTALL.md +229 -0
  5. package/LICENSE +21 -0
  6. package/README.ja.md +389 -0
  7. package/README.md +607 -0
  8. package/bin/fpasoterm +2882 -0
  9. package/completions/_fpasoterm +81 -0
  10. package/completions/fpasoterm.bash +55 -0
  11. package/completions/fpasoterm.fish +64 -0
  12. package/completions/fpasoterm.ps1 +47 -0
  13. package/docs/capability-diagnostics.en.md +77 -0
  14. package/docs/capability-diagnostics.ja.md +63 -0
  15. package/docs/completion.en.md +141 -0
  16. package/docs/completion.ja.md +113 -0
  17. package/docs/config.en.md +554 -0
  18. package/docs/config.ja.md +544 -0
  19. package/docs/debugging.en.md +72 -0
  20. package/docs/debugging.ja.md +66 -0
  21. package/docs/diagnostics.en.md +69 -0
  22. package/docs/diagnostics.ja.md +64 -0
  23. package/docs/font-diagnostics.en.md +116 -0
  24. package/docs/font-diagnostics.ja.md +105 -0
  25. package/docs/fpasoterm-plugin.d.ts +51 -0
  26. package/docs/known-issues.en.md +23 -0
  27. package/docs/known-issues.ja.md +23 -0
  28. package/docs/plugins.en.md +266 -0
  29. package/docs/plugins.ja.md +176 -0
  30. package/docs/pr-review.en.md +177 -0
  31. package/docs/pr-review.ja.md +170 -0
  32. package/docs/release-checklist.en.md +72 -0
  33. package/docs/release-checklist.ja.md +71 -0
  34. package/docs/security.en.md +36 -0
  35. package/docs/security.ja.md +31 -0
  36. package/docs/spec.en.md +106 -0
  37. package/docs/spec.ja.md +99 -0
  38. package/docs/sshfs.en.md +28 -0
  39. package/docs/sshfs.ja.md +28 -0
  40. package/docs/sync.en.md +280 -0
  41. package/docs/sync.ja.md +280 -0
  42. package/examples/apply-default-appearance.bat +8 -0
  43. package/examples/apply-default-appearance.ps1 +7 -0
  44. package/examples/apply-default-appearance.sh +7 -0
  45. package/examples/apply-runtime-appearance.bat +8 -0
  46. package/examples/apply-runtime-appearance.ps1 +7 -0
  47. package/examples/apply-runtime-appearance.sh +7 -0
  48. package/examples/config/default-appearance.toml +36 -0
  49. package/examples/config/minimal.toml +30 -0
  50. package/examples/config/profiles.toml +20 -0
  51. package/examples/config/runtime-appearance.toml +24 -0
  52. package/examples/config/sync-folder.toml +17 -0
  53. package/examples/config/tui-compatibility.toml +7 -0
  54. package/examples/config/with-plugins.toml +33 -0
  55. package/examples/plugins/hello.ts +17 -0
  56. package/examples/plugins/status-banner.ts +14 -0
  57. package/examples/plugins/theme.ts +32 -0
  58. package/examples/plugins/welcome-banner.ts +16 -0
  59. package/extra/linux/icons/hicolor/128x128/apps/fpasoterm.png +0 -0
  60. package/extra/linux/icons/hicolor/16x16/apps/fpasoterm.png +0 -0
  61. package/extra/linux/icons/hicolor/192x192/apps/fpasoterm.png +0 -0
  62. package/extra/linux/icons/hicolor/256x256/apps/fpasoterm.png +0 -0
  63. package/extra/linux/icons/hicolor/32x32/apps/fpasoterm.png +0 -0
  64. package/extra/linux/icons/hicolor/48x48/apps/fpasoterm.png +0 -0
  65. package/extra/linux/icons/hicolor/512x512/apps/fpasoterm.png +0 -0
  66. package/extra/linux/icons/hicolor/64x64/apps/fpasoterm.png +0 -0
  67. package/extra/linux/io.github.oyoguhito.fpasoterm.desktop +10 -0
  68. package/extra/logo/fpasoterm.png +0 -0
  69. package/extra/macos/fpasoterm.icns +0 -0
  70. package/extra/windows/fpasoterm.cmd +40 -0
  71. package/extra/windows/fpasoterm.ico +0 -0
  72. package/package.json +81 -0
  73. package/scripts/build-artifacts.js +183 -0
  74. package/scripts/generate-icon.js +251 -0
  75. package/scripts/init-jj-empty-main +41 -0
  76. package/scripts/install-linux-desktop.js +238 -0
  77. package/scripts/run +16 -0
  78. package/scripts/security/scan-secrets.js +89 -0
  79. package/scripts/tests/smoke.js +2476 -0
  80. package/scripts/uninstall-desktop.js +20 -0
  81. package/scripts/uninstall-linux-desktop.js +96 -0
  82. package/scripts/uninstall-windows-path.js +94 -0
  83. package/src/config.js +855 -0
  84. package/src/renderer/confirm.html +71 -0
  85. package/src/renderer/index.html +223 -0
  86. package/src/renderer/renderer.js +4166 -0
  87. package/src/renderer/styles.css +969 -0
  88. package/src/renderer/vendor/addon-fit/addon-fit.js +2 -0
  89. package/src/renderer/vendor/addon-image/LICENSE +19 -0
  90. package/src/renderer/vendor/addon-image/addon-image.js +3 -0
  91. package/src/renderer/vendor/addon-web-links/LICENSE +19 -0
  92. package/src/renderer/vendor/addon-web-links/addon-web-links.js +2 -0
  93. package/src/renderer/vendor/xterm/xterm.css +292 -0
  94. package/src/renderer/vendor/xterm/xterm.js +2 -0
  95. package/src-tauri/Cargo.lock +5253 -0
  96. package/src-tauri/Cargo.toml +29 -0
  97. package/src-tauri/build.rs +140 -0
  98. package/src-tauri/capabilities/default.json +13 -0
  99. package/src-tauri/default-config.toml +128 -0
  100. package/src-tauri/src/main.rs +9347 -0
  101. package/src-tauri/tauri.conf.json +60 -0
package/src/config.js ADDED
@@ -0,0 +1,855 @@
1
+ const fs = require('node:fs');
2
+ const os = require('node:os');
3
+ const path = require('node:path');
4
+ const { pathToFileURL } = require('node:url');
5
+ const toml = require('smol-toml');
6
+ const ts = require('typescript');
7
+
8
+ const nerdFontFallback = '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font"';
9
+ // CJK monospace candidates cover Japanese, Korean, Chinese, and half-width kana.
10
+ const cjkMonospaceFontFallback = '"DejaVu Sans Mono", "Noto Sans Mono", "Noto Sans Mono CJK JP", "Noto Sans Mono CJK KR", "Noto Sans Mono CJK SC", "NanumGothicCoding", "BIZ UDGothic"';
11
+ const broadCjkFontFallback = '"Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans CJK SC", "Noto Sans CJK TC", "Hiragino Kaku Gothic ProN", "Apple SD Gothic Neo", "Malgun Gothic", Meiryo';
12
+ // Prefer an installed monospace font for terminal cell measurement. Nerd Font
13
+ // fallbacks remain available for private-use glyphs without becoming the base font.
14
+ const defaultTerminalFontFamily = `${cjkMonospaceFontFallback}, ${nerdFontFallback}, ${broadCjkFontFallback}, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
15
+ const legacyMacosTerminalFontFamily = `"SF Mono", Menlo, ui-monospace, SFMono-Regular, ${nerdFontFallback}, ${broadCjkFontFallback}, monospace`;
16
+ // Menlo's box and block glyph metrics match the macOS Terminal renderer more closely.
17
+ const macosTerminalFontFamily = `Menlo, "SF Mono", ui-monospace, SFMono-Regular, ${nerdFontFallback}, ${broadCjkFontFallback}, monospace`;
18
+ // Keep descenders such as g, q, and y visibly separate from the next row.
19
+ const defaultTerminalLineHeight = 1;
20
+ // Prioritize readable descenders over compact TUI logo rows on macOS.
21
+ const macosTerminalLineHeight = 1;
22
+ const legacyTerminalFontFamily = `${nerdFontFallback}, "Noto Sans Mono CJK JP", "Noto Sans Mono CJK KR", "Noto Sans Mono CJK SC", "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans CJK SC", "Noto Sans CJK TC", "NanumGothicCoding", "BIZ UDGothic", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Apple SD Gothic Neo", "Malgun Gothic", Meiryo, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
23
+
24
+ // The complete set of supported user settings. This object is also used to
25
+ // fill missing keys when a user provides a partial config.toml.
26
+ const defaultConfig = Object.freeze({
27
+ window: {
28
+ title: 'fpasoterm',
29
+ width: 1000,
30
+ height: 680,
31
+ minWidth: 420,
32
+ minHeight: 260,
33
+ backgroundColor: 'rgba(0, 0, 0, 0)',
34
+ titlebarColor: '#1565c0',
35
+ titleLocked: true,
36
+ themeSource: 'system',
37
+ frame: false,
38
+ rememberBounds: true,
39
+ },
40
+ terminal: {
41
+ allowTransparency: true,
42
+ cursorBlink: true,
43
+ cursorStyle: 'block',
44
+ fontFamily: defaultTerminalFontFamily,
45
+ fontSize: 14,
46
+ lineHeight: defaultTerminalLineHeight,
47
+ minimumContrastRatio: 1,
48
+ rescaleOverlappingGlyphs: false,
49
+ backgroundOpacity: 0.65,
50
+ scrollback: 1000,
51
+ termName: 'xterm-256color',
52
+ // Decode PTY output as UTF-8 unless a legacy application explicitly
53
+ // emits a different Japanese encoding.
54
+ encoding: 'utf-8',
55
+ shell: '',
56
+ // Keep enhanced Kitty keyboard negotiation opt-in until IME behavior is
57
+ // verified across the supported WebView implementations.
58
+ kittyKeyboard: false,
59
+ images: {
60
+ enabled: false,
61
+ kittySupport: false,
62
+ kittySizeLimit: 33554432,
63
+ storageLimit: 64,
64
+ sixelSupport: false,
65
+ iipSupport: false,
66
+ },
67
+ theme: {
68
+ background: 'rgba(16, 19, 23, 0.65)',
69
+ foreground: '#e8edf2',
70
+ cursor: '#f5d76e',
71
+ selectionBackground: '#35506b',
72
+ black: '#11151a',
73
+ red: '#ff6b6b',
74
+ green: '#8bd17c',
75
+ yellow: '#f5d76e',
76
+ blue: '#7bb7ff',
77
+ magenta: '#d7a8ff',
78
+ cyan: '#63d4d5',
79
+ white: '#e8edf2',
80
+ brightBlack: '#5d6978',
81
+ brightRed: '#ff8f8f',
82
+ brightGreen: '#ade89f',
83
+ brightYellow: '#ffe08a',
84
+ brightBlue: '#a4ceff',
85
+ brightMagenta: '#e3c3ff',
86
+ brightCyan: '#9de9ea',
87
+ brightWhite: '#ffffff',
88
+ },
89
+ },
90
+ keybindings: {
91
+ prefix: 'Mod+Shift',
92
+ logMenu: 'L',
93
+ logToggle: 'S',
94
+ logShow: 'P',
95
+ copy: 'C',
96
+ paste: 'V',
97
+ menu: 'M',
98
+ help: 'H',
99
+ newWindow: 'N',
100
+ openCwd: 'o',
101
+ broadcast: 'B',
102
+ kill: 'K',
103
+ tile: 'T',
104
+ closeAll: 'X',
105
+ },
106
+ plugins: {
107
+ enabled: [],
108
+ },
109
+ sync: {
110
+ enabled: false,
111
+ provider: 'folder',
112
+ path: '',
113
+ channel: 'default',
114
+ diagnostics: true,
115
+ maxBytes: 1048576,
116
+ commands: false,
117
+ // Shared-folder command delivery requires this secret on every participant.
118
+ commandSecret: '',
119
+ commandTtlSeconds: 60,
120
+ },
121
+ logging: {
122
+ enabled: true,
123
+ directory: '',
124
+ autoStart: false,
125
+ maxBytes: 10485760,
126
+ },
127
+ security: {
128
+ // OSC 52 is emitted by trusted terminal tools such as tmux and herdr.
129
+ // Set disabled when terminal output must never alter the OS clipboard.
130
+ osc52: 'trusted',
131
+ osc52MaxBytes: 65536,
132
+ // OSC 7 and OSC 133 only record local terminal metadata for diagnostics.
133
+ osc7: true,
134
+ osc133: true,
135
+ // Opening terminal-provided URLs and desktop notifications require opt-in.
136
+ osc8Open: false,
137
+ oscNotifications: false,
138
+ oscNotificationMinIntervalMs: 5000,
139
+ },
140
+ });
141
+
142
+ // Uses macOS-native monospace fonts before CJK fallbacks. Hiragino Sans is
143
+ // proportional, so using it as the first available font makes xterm cells look spaced out.
144
+ function platformDefaultConfig(platform = process.platform, architecture = process.arch) {
145
+ if (platform === 'darwin') {
146
+ return mergeConfig(defaultConfig, {
147
+ terminal: {
148
+ fontFamily: macosTerminalFontFamily,
149
+ fontSize: architecture === 'x64' ? 12 : 14,
150
+ lineHeight: macosTerminalLineHeight,
151
+ },
152
+ });
153
+ }
154
+ return defaultConfig;
155
+ }
156
+
157
+ // Safely migrates only previously shipped defaults, preserving custom font choices.
158
+ function migrateLegacyMacosFontFamily(config, platform = process.platform) {
159
+ const fontFamily = config?.terminal?.fontFamily;
160
+ if (platform === 'darwin' && (fontFamily === legacyTerminalFontFamily || fontFamily === legacyMacosTerminalFontFamily || fontFamily === defaultTerminalFontFamily)) {
161
+ return mergeConfig(config, { terminal: { fontFamily: macosTerminalFontFamily } });
162
+ }
163
+ if (platform !== 'darwin' && fontFamily === legacyTerminalFontFamily) {
164
+ return mergeConfig(config, { terminal: { fontFamily: defaultTerminalFontFamily } });
165
+ }
166
+ if (platform !== 'darwin' || fontFamily !== defaultTerminalFontFamily) {
167
+ return config;
168
+ }
169
+ return mergeConfig(config, { terminal: { fontFamily: macosTerminalFontFamily } });
170
+ }
171
+
172
+ // Migrates previously shipped compact defaults while preserving custom values.
173
+ function migrateLegacyTerminalLineHeight(config, platform = process.platform) {
174
+ const formerDefaults = platform === 'darwin' ? [0.8, 0.81, 0.82, 0.85, 0.9, 0.92, 1.12] : [0.92, 1.12];
175
+ if (formerDefaults.includes(config?.terminal?.lineHeight)) {
176
+ return mergeConfig(config, {
177
+ terminal: {
178
+ lineHeight: platform === 'darwin' ? macosTerminalLineHeight : defaultTerminalLineHeight,
179
+ },
180
+ });
181
+ }
182
+ return config;
183
+ }
184
+
185
+ // Returns the settings persisted in user config files. Image protocol options
186
+ // remain internal until their renderer support is stable.
187
+ function writableConfigDefaults(platform = process.platform, architecture = process.arch) {
188
+ const defaults = mergeConfig({}, platformDefaultConfig(platform, architecture));
189
+ delete defaults.terminal.images;
190
+ return defaults;
191
+ }
192
+
193
+ // Writes the default TOML with comments so users can copy it to config.toml
194
+ // and understand what each section controls.
195
+ function defaultConfigExample(platform = process.platform, architecture = process.arch) {
196
+ const terminalDefaults = platformDefaultConfig(platform, architecture).terminal;
197
+ return `# fpasoterm user configuration.
198
+ # Copy this file to config.toml and edit the values you want to change.
199
+
200
+ # Window options are applied when the application window is created.
201
+ [window]
202
+ title = "fpasoterm"
203
+ width = 1000
204
+ height = 680
205
+ minWidth = 420
206
+ minHeight = 260
207
+ backgroundColor = "rgba(0, 0, 0, 0)"
208
+ # titlebarColor controls the custom titlebar background when frame is false.
209
+ titlebarColor = "#1565c0"
210
+ # titleLocked prevents shell-emitted title sequences from replacing title.
211
+ titleLocked = true
212
+ # themeSource can be "system", "light", or "dark".
213
+ themeSource = "system"
214
+ # frame controls whether the native window frame/titlebar is shown.
215
+ frame = false
216
+ # rememberBounds controls whether size is saved to User/window-state.json.
217
+ rememberBounds = true
218
+
219
+ # Terminal options are passed to xterm.js when the terminal is created.
220
+ [terminal]
221
+ allowTransparency = true
222
+ cursorBlink = true
223
+ cursorStyle = "block"
224
+ fontFamily = ${JSON.stringify(terminalDefaults.fontFamily)}
225
+ fontSize = ${terminalDefaults.fontSize}
226
+ # A compact value keeps adjacent rows connected for terminal art and TUI logos.
227
+ lineHeight = ${terminalDefaults.lineHeight}
228
+ # minimumContrastRatio = 1 preserves application-selected ANSI and RGB colors.
229
+ minimumContrastRatio = 1
230
+ # Keep this false for glyph fidelity in terminal applications. Enable only if needed for CJK overlap.
231
+ rescaleOverlappingGlyphs = false
232
+ # backgroundOpacity changes only the terminal background alpha, not text opacity.
233
+ backgroundOpacity = 0.65
234
+ scrollback = 1000
235
+ # termName is the terminal type used by xterm.js. The backend PTY exports
236
+ # TERM=xterm-256color so terminal multiplexers such as tmux can use terminfo.
237
+ termName = "xterm-256color"
238
+ # encoding controls decoding of PTY output. Keep UTF-8 unless a legacy program
239
+ # explicitly emits Shift_JIS or EUC-JP bytes.
240
+ encoding = "utf-8"
241
+ # shell overrides the platform default when non-empty.
242
+ # Windows examples: "powershell.exe", "pwsh.exe", or "cmd.exe".
243
+ shell = ""
244
+ # kittyKeyboard enables enhanced key negotiation for compatible TUIs. Keep it
245
+ # false unless that TUI requires it; it is unrelated to the disabled graphics addon.
246
+ kittyKeyboard = false
247
+
248
+ # [terminal.images] is reserved for a future stable renderer. Current builds
249
+ # ignore it, so do not add this section to config.toml.
250
+
251
+ # Terminal color palette.
252
+ [terminal.theme]
253
+ background = "rgba(16, 19, 23, 0.65)"
254
+ foreground = "#e8edf2"
255
+ cursor = "#f5d76e"
256
+ selectionBackground = "#35506b"
257
+ black = "#11151a"
258
+ red = "#ff6b6b"
259
+ green = "#8bd17c"
260
+ yellow = "#f5d76e"
261
+ blue = "#7bb7ff"
262
+ magenta = "#d7a8ff"
263
+ cyan = "#63d4d5"
264
+ white = "#e8edf2"
265
+ brightBlack = "#5d6978"
266
+ brightRed = "#ff8f8f"
267
+ brightGreen = "#ade89f"
268
+ brightYellow = "#ffe08a"
269
+ brightBlue = "#a4ceff"
270
+ brightMagenta = "#e3c3ff"
271
+ brightCyan = "#9de9ea"
272
+ brightWhite = "#ffffff"
273
+
274
+ # Keybindings use Mod for Ctrl on Windows/Linux and Cmd on macOS.
275
+ # Set prefix = "Ctrl+Alt" on Windows when Ctrl+Shift is unavailable.
276
+ # Individual values may be a key such as "N" or a full shortcut such as
277
+ # "Ctrl+Alt+KeyN". Use KeyN-style values for physical-key bindings.
278
+ [keybindings]
279
+ prefix = "Mod+Shift"
280
+ logMenu = "L"
281
+ logToggle = "S"
282
+ logShow = "P"
283
+ copy = "C"
284
+ paste = "V"
285
+ menu = "M"
286
+ help = "H"
287
+ newWindow = "N"
288
+ openCwd = "o"
289
+ broadcast = "B"
290
+ kill = "K"
291
+ tile = "T"
292
+ closeAll = "X"
293
+
294
+ # Plugins are relative to ~/.config/fpasoterm/User/.
295
+ # Example: enabled = ["plugins/hello.ts", "plugins/theme.ts"]
296
+ [plugins]
297
+ enabled = []
298
+
299
+ # Sync folder options use an already-synced local folder, such as Google Drive.
300
+ # fpasoterm does not call Google Drive APIs or perform OAuth.
301
+ [sync]
302
+ enabled = false
303
+ provider = "folder"
304
+ # Example: path = "~/Google Drive/fpasoterm-sync"
305
+ path = ""
306
+ channel = "default"
307
+ diagnostics = true
308
+ maxBytes = 1048576
309
+ # commands enables explicitly requested broadcast input through this folder.
310
+ commands = false
311
+ # Leave empty until --setup-sync generates a secret shared only with trusted devices.
312
+ commandSecret = ""
313
+ # Command files expire quickly so they are not executed after a delayed sync.
314
+ commandTtlSeconds = 60
315
+
316
+ # Terminal output logging records readable PTY output with control sequences
317
+ # removed when started from the titlebar or an OSC 777 command.
318
+ # The default directory is User/logs.
319
+ [logging]
320
+ enabled = true
321
+ # Example: directory = "~/Google Drive/fpasoterm-sync/logs"
322
+ directory = ""
323
+ autoStart = false
324
+ maxBytes = 10485760
325
+
326
+ # OSC safety controls. OSC 52 can change the OS clipboard, so disable it when
327
+ # terminal output is untrusted. OSC 7/133 only update local diagnostics.
328
+ [security]
329
+ osc52 = "trusted"
330
+ osc52MaxBytes = 65536
331
+ osc7 = true
332
+ osc133 = true
333
+ # OSC 8 URLs always require a confirmation dialog. Enable external browser
334
+ # opening only for terminal output you trust. OSC 9/99 notifications are off
335
+ # by default and rate-limited when enabled.
336
+ osc8Open = false
337
+ oscNotifications = false
338
+ oscNotificationMinIntervalMs = 5000
339
+
340
+ # Profiles are optional named overlays selected with --profile <name>.
341
+ # They may contain [window], [terminal], [ime], [keybindings], [sync],
342
+ # [logging], or [security] settings. The selected profile overrides them.
343
+ #
344
+ # [profiles.large-font.terminal]
345
+ # fontSize = 18
346
+ #
347
+ # [profiles.transparent.terminal]
348
+ # backgroundOpacity = 0.65
349
+ `;
350
+ }
351
+
352
+ // Returns the user-editable config directory under the runtime profile.
353
+ function configDir() {
354
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
355
+ return path.join(configHome, 'fpasoterm', 'User');
356
+ }
357
+
358
+ // Returns the active TOML config file path.
359
+ function configPath() {
360
+ if (process.env.FPASOTERM_CONFIG_PATH) {
361
+ return path.resolve(process.env.FPASOTERM_CONFIG_PATH);
362
+ }
363
+ return path.join(configDir(), 'config.toml');
364
+ }
365
+
366
+ // Returns the runtime profile directory used by the desktop runtime.
367
+ function profileDir() {
368
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
369
+ return path.join(configHome, 'fpasoterm');
370
+ }
371
+
372
+ // Returns the local window state file used to remember bounds.
373
+ function windowStatePath() {
374
+ return path.join(configDir(), 'window-state.json');
375
+ }
376
+
377
+ // Returns the old pre-User window state path for read-only migration.
378
+ function legacyWindowStatePath() {
379
+ return path.join(profileDir(), 'window-state.json');
380
+ }
381
+
382
+ // Checks for plain objects so arrays and scalar TOML values are not merged recursively.
383
+ function isObject(value) {
384
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
385
+ }
386
+
387
+ // Deep-merges user settings over defaults while preserving missing default keys.
388
+ function mergeConfig(base, override) {
389
+ if (!isObject(override)) {
390
+ return base;
391
+ }
392
+
393
+ const merged = { ...base };
394
+ for (const [key, value] of Object.entries(override)) {
395
+ if (isObject(value) && isObject(base[key])) {
396
+ merged[key] = mergeConfig(base[key], value);
397
+ } else {
398
+ merged[key] = value;
399
+ }
400
+ }
401
+ return merged;
402
+ }
403
+
404
+ // Lists default leaf settings that are absent from a user configuration.
405
+ function missingConfigKeys(defaults, config, prefix = '') {
406
+ const missing = [];
407
+ for (const [key, defaultValue] of Object.entries(defaults)) {
408
+ const path = prefix ? `${prefix}.${key}` : key;
409
+ const hasValue = isObject(config) && Object.hasOwn(config, key);
410
+ const configuredValue = hasValue ? config[key] : undefined;
411
+ if (isObject(defaultValue)) {
412
+ missing.push(...missingConfigKeys(defaultValue, configuredValue, path));
413
+ } else if (!hasValue) {
414
+ missing.push(path);
415
+ }
416
+ }
417
+ return missing;
418
+ }
419
+
420
+ // Removes settings that are not part of the current supported configuration.
421
+ function pruneUnsupportedConfig(defaults, config, prefix = '') {
422
+ if (!isObject(config)) {
423
+ return { config, removed: [] };
424
+ }
425
+
426
+ const pruned = {};
427
+ const removed = [];
428
+ for (const [key, value] of Object.entries(config)) {
429
+ const path = prefix ? `${prefix}.${key}` : key;
430
+ // Profiles are optional named overlays. Their keys are validated when the
431
+ // profile is selected, so do not erase a whole user-owned profile here.
432
+ if (prefix === '' && key === 'profiles' && isObject(value)) {
433
+ pruned[key] = value;
434
+ continue;
435
+ }
436
+ if (!Object.hasOwn(defaults, key)) {
437
+ removed.push(path);
438
+ continue;
439
+ }
440
+ if (isObject(defaults[key]) && isObject(value)) {
441
+ const nested = pruneUnsupportedConfig(defaults[key], value, path);
442
+ pruned[key] = nested.config;
443
+ removed.push(...nested.removed);
444
+ } else {
445
+ pruned[key] = value;
446
+ }
447
+ }
448
+ return { config: pruned, removed };
449
+ }
450
+
451
+ // Returns deterministic names for the optional named configuration overlays.
452
+ function profileNames(config) {
453
+ return isObject(config?.profiles) ? Object.keys(config.profiles).sort() : [];
454
+ }
455
+
456
+ // Separates normal settings from one selected profile before runtime merging.
457
+ function selectProfileConfig(userConfig, profileName = process.env.FPASOTERM_PROFILE || '') {
458
+ const config = isObject(userConfig) ? userConfig : {};
459
+ const profiles = isObject(config.profiles) ? config.profiles : {};
460
+ const baseConfig = { ...config };
461
+ delete baseConfig.profiles;
462
+ const activeProfile = String(profileName || '').trim();
463
+ if (!activeProfile) {
464
+ return { baseConfig, profileConfig: {}, activeProfile: '' };
465
+ }
466
+ if (!Object.hasOwn(profiles, activeProfile)) {
467
+ throw new Error(`profile '${activeProfile}' does not exist; available profiles: ${profileNames(config).join(', ') || '(none)'}`);
468
+ }
469
+ if (!isObject(profiles[activeProfile])) {
470
+ throw new Error(`profile '${activeProfile}' must be a TOML table`);
471
+ }
472
+ return { baseConfig, profileConfig: profiles[activeProfile], activeProfile };
473
+ }
474
+
475
+ // Drops config sections that were removed from the supported schema.
476
+ function removeUnsupportedConfigSections(config) {
477
+ delete config[['web', 'Console'].join('')];
478
+ return config;
479
+ }
480
+
481
+ // Keeps config.toml.example in sync without overwriting the user's config.toml.
482
+ function writeDefaultConfigExample(targetPath) {
483
+ const examplePath = `${targetPath}.example`;
484
+ const example = defaultConfigExample();
485
+ const userDir = path.dirname(targetPath);
486
+ fs.mkdirSync(userDir, { recursive: true });
487
+ // Keep the documented plugin location ready without creating or replacing
488
+ // plugin source files on the user's behalf.
489
+ fs.mkdirSync(path.join(userDir, 'plugins'), { recursive: true });
490
+ if (!fs.existsSync(examplePath) || fs.readFileSync(examplePath, 'utf8') !== example) {
491
+ fs.writeFileSync(examplePath, example);
492
+ }
493
+ }
494
+
495
+ // Parses the user's TOML config. Missing files are treated as an empty override.
496
+ function readUserConfig(targetPath = configPath()) {
497
+ if (!fs.existsSync(targetPath)) {
498
+ return {};
499
+ }
500
+
501
+ return toml.parse(fs.readFileSync(targetPath, 'utf8'));
502
+ }
503
+
504
+ // Validates the user-owned TOML without changing it. The launcher can use this
505
+ // result to explain recoverable configuration mistakes before opening a window.
506
+ function validateUserConfig(targetPath = configPath(), profileName = process.env.FPASOTERM_PROFILE || '') {
507
+ const result = {
508
+ configPath: targetPath,
509
+ exists: fs.existsSync(targetPath),
510
+ warnings: [],
511
+ error: '',
512
+ };
513
+
514
+ let userConfig;
515
+ try {
516
+ userConfig = readUserConfig(targetPath);
517
+ } catch (error) {
518
+ result.error = `cannot parse TOML: ${error.message}`;
519
+ return result;
520
+ }
521
+
522
+ if (userConfig.profiles !== undefined && !isObject(userConfig.profiles)) {
523
+ result.warnings.push('profiles should be a table of named profile tables');
524
+ }
525
+ for (const name of profileNames(userConfig)) {
526
+ if (!isObject(userConfig.profiles[name])) {
527
+ result.warnings.push(`profiles.${name} should be a TOML table`);
528
+ }
529
+ }
530
+ if (profileName && !profileNames(userConfig).includes(profileName)) {
531
+ result.warnings.push(`selected profile '${profileName}' does not exist`);
532
+ }
533
+
534
+ const fontSize = userConfig.terminal?.fontSize;
535
+ if (fontSize !== undefined && (typeof fontSize !== 'number' || !Number.isFinite(fontSize) || fontSize <= 0)) {
536
+ result.warnings.push('terminal.fontSize should be a positive number');
537
+ }
538
+
539
+ if (userConfig.terminal?.kittyKeyboard !== undefined && typeof userConfig.terminal.kittyKeyboard !== 'boolean') {
540
+ result.warnings.push('terminal.kittyKeyboard should be true or false');
541
+ }
542
+
543
+ for (const [key, value] of [
544
+ ['window.width', userConfig.window?.width],
545
+ ['window.height', userConfig.window?.height],
546
+ ['window.minWidth', userConfig.window?.minWidth],
547
+ ['window.minHeight', userConfig.window?.minHeight],
548
+ ['terminal.lineHeight', userConfig.terminal?.lineHeight],
549
+ ['terminal.scrollback', userConfig.terminal?.scrollback],
550
+ ['logging.maxBytes', userConfig.logging?.maxBytes],
551
+ ]) {
552
+ if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)) {
553
+ result.warnings.push(`${key} should be a positive number`);
554
+ }
555
+ }
556
+
557
+ const enabled = userConfig.plugins?.enabled;
558
+ if (enabled !== undefined && !Array.isArray(enabled)) {
559
+ result.warnings.push('plugins.enabled should be an array of .js/.ts file names');
560
+ } else if (Array.isArray(enabled)) {
561
+ const rootDir = path.dirname(targetPath);
562
+ for (const plugin of enabled) {
563
+ const pluginPath = typeof plugin === 'string' ? path.resolve(rootDir, plugin) : '';
564
+ if (typeof plugin !== 'string' || !pluginPath.startsWith(`${path.resolve(rootDir, 'plugins')}${path.sep}`)
565
+ || !['.js', '.ts'].includes(path.extname(pluginPath))) {
566
+ result.warnings.push(`plugins.enabled includes invalid entry ${JSON.stringify(plugin)}`);
567
+ } else if (!fs.existsSync(pluginPath)) {
568
+ result.warnings.push(`plugins.enabled includes ${plugin} but file does not exist`);
569
+ }
570
+ }
571
+ }
572
+
573
+ const sync = userConfig.sync;
574
+ if (sync?.commands === true && (typeof sync.commandSecret !== 'string' || sync.commandSecret.length < 32)) {
575
+ result.warnings.push('sync.commands requires sync.commandSecret with at least 32 characters; run fpasoterm --setup-sync');
576
+ }
577
+
578
+ const security = userConfig.security;
579
+ if (security?.osc52 !== undefined && !['trusted', 'disabled'].includes(security.osc52)) {
580
+ result.warnings.push('security.osc52 must be "trusted" or "disabled"');
581
+ }
582
+ if (security?.osc52MaxBytes !== undefined
583
+ && (typeof security.osc52MaxBytes !== 'number' || !Number.isFinite(security.osc52MaxBytes)
584
+ || security.osc52MaxBytes < 0 || security.osc52MaxBytes > 1048576)) {
585
+ result.warnings.push('security.osc52MaxBytes must be a number from 0 to 1048576');
586
+ }
587
+ for (const key of ['osc7', 'osc133', 'osc8Open', 'oscNotifications']) {
588
+ if (security?.[key] !== undefined && typeof security[key] !== 'boolean') {
589
+ result.warnings.push(`security.${key} should be true or false`);
590
+ }
591
+ }
592
+ if (security?.oscNotificationMinIntervalMs !== undefined
593
+ && (typeof security.oscNotificationMinIntervalMs !== 'number'
594
+ || !Number.isFinite(security.oscNotificationMinIntervalMs)
595
+ || security.oscNotificationMinIntervalMs < 1000
596
+ || security.oscNotificationMinIntervalMs > 60000)) {
597
+ result.warnings.push('security.oscNotificationMinIntervalMs must be a number from 1000 to 60000');
598
+ }
599
+
600
+ const unsupported = pruneUnsupportedConfig(writableConfigDefaults(), userConfig).removed;
601
+ for (const key of unsupported) {
602
+ result.warnings.push(`${key} is not a supported configuration key`);
603
+ }
604
+ return result;
605
+ }
606
+
607
+ // Writes a user config file, used by CLI commands that edit plugin settings.
608
+ function writeUserConfig(config, targetPath = configPath()) {
609
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
610
+ fs.writeFileSync(targetPath, `# fpasoterm user configuration.\n${toml.stringify(config)}`);
611
+ }
612
+
613
+ // Reads saved window bounds from the local profile directory.
614
+ function readableWindowStatePath(targetPath = windowStatePath()) {
615
+ let statePath = targetPath;
616
+ if (!fs.existsSync(statePath)) {
617
+ const legacyPath = legacyWindowStatePath();
618
+ statePath = targetPath === windowStatePath() && fs.existsSync(legacyPath) ? legacyPath : targetPath;
619
+ }
620
+ return fs.existsSync(statePath) ? statePath : undefined;
621
+ }
622
+
623
+ // Reads saved window bounds from the local profile directory.
624
+ function readWindowState(targetPath = windowStatePath()) {
625
+ const statePath = readableWindowStatePath(targetPath);
626
+
627
+ if (!statePath) {
628
+ return {};
629
+ }
630
+
631
+ try {
632
+ return JSON.parse(fs.readFileSync(statePath, 'utf8'));
633
+ } catch {
634
+ return {};
635
+ }
636
+ }
637
+
638
+ // Writes the current window bounds so the next launch can restore them.
639
+ function writeWindowState(state, targetPath = windowStatePath()) {
640
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
641
+ fs.writeFileSync(targetPath, `${JSON.stringify(state, null, 2)}\n`);
642
+ }
643
+
644
+ // Keeps only supported window state keys so older x/y entries are ignored.
645
+ function sanitizeWindowState(state) {
646
+ const window = state.window && typeof state.window === 'object' ? state.window : {};
647
+ const sanitized = {};
648
+ if (Number.isInteger(window.width) && window.width > 0) {
649
+ sanitized.width = window.width;
650
+ }
651
+ if (Number.isInteger(window.height) && window.height > 0) {
652
+ sanitized.height = window.height;
653
+ }
654
+ return { window: sanitized };
655
+ }
656
+
657
+ // Deletes saved window bounds so configured/default bounds are used again.
658
+ function deleteWindowState(targetPath = windowStatePath()) {
659
+ if (fs.existsSync(targetPath)) {
660
+ fs.rmSync(targetPath);
661
+ }
662
+ if (targetPath === windowStatePath()) {
663
+ const legacyPath = legacyWindowStatePath();
664
+ if (fs.existsSync(legacyPath)) {
665
+ fs.rmSync(legacyPath);
666
+ }
667
+ }
668
+ }
669
+
670
+ // Lists JavaScript and TypeScript plugin files below User/plugins.
671
+ function discoverPluginFiles(targetPath = configPath()) {
672
+ const pluginsDir = path.join(path.dirname(targetPath), 'plugins');
673
+ if (!fs.existsSync(pluginsDir)) {
674
+ return [];
675
+ }
676
+
677
+ const discovered = [];
678
+ const visit = (directory) => {
679
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
680
+ const entryPath = path.join(directory, entry.name);
681
+ if (entry.isDirectory()) {
682
+ visit(entryPath);
683
+ } else if (entry.isFile() && ['.js', '.ts'].includes(path.extname(entry.name))) {
684
+ discovered.push(path.relative(path.dirname(targetPath), entryPath).replaceAll('\\', '/'));
685
+ }
686
+ }
687
+ };
688
+ visit(pluginsDir);
689
+ return discovered.sort();
690
+ }
691
+
692
+ // Resolves a filename or plugins-relative path, with an optional .js/.ts suffix, to one plugin entry.
693
+ function resolvePluginSelector(selector, candidates, action) {
694
+ const normalized = selector.replaceAll('\\', '/').replace(/^\.\//, '').replace(/^plugins\//, '');
695
+ if (!normalized || normalized.startsWith('/') || normalized.startsWith('../') || normalized.includes('/../')) {
696
+ throw new Error(`invalid plugin name: ${selector}`);
697
+ }
698
+
699
+ const extensionless = !['.js', '.ts'].includes(path.posix.extname(normalized));
700
+ const matches = candidates.filter((candidate) => {
701
+ const relative = candidate.replace(/^plugins\//, '');
702
+ const candidateSelector = normalized.includes('/') ? relative : path.posix.basename(relative);
703
+ if (candidateSelector === normalized) return true;
704
+ return extensionless && candidateSelector.replace(/\.(?:js|ts)$/, '') === normalized;
705
+ });
706
+
707
+ if (matches.length === 0) {
708
+ throw new Error(`cannot ${action} plugin '${selector}': no matching plugin file`);
709
+ }
710
+ if (matches.length > 1) {
711
+ throw new Error(`plugin name '${selector}' is ambiguous; use one of: ${matches.join(', ')}`);
712
+ }
713
+ return matches[0];
714
+ }
715
+
716
+ // Reads optional single-file plugin metadata without requiring a manifest or
717
+ // changing the established User/plugins .js/.ts layout.
718
+ function pluginMetadata(source) {
719
+ const metadata = {};
720
+ let fallbackDescription = '';
721
+ for (const line of String(source || '').split(/\r?\n/)) {
722
+ const trimmed = line.trim();
723
+ const header = trimmed.match(/^\/\/\s*@fpasoterm-plugin\s+(version|description)\s*:\s*(.+?)\s*$/i);
724
+ if (header) {
725
+ metadata[header[1].toLowerCase()] = header[2];
726
+ continue;
727
+ }
728
+ if (!fallbackDescription && trimmed.startsWith('//') && !trimmed.startsWith('///')) {
729
+ fallbackDescription = trimmed.replace(/^\/\/\s*/, '');
730
+ }
731
+ }
732
+ return {
733
+ version: metadata.version || '(not declared)',
734
+ description: metadata.description || fallbackDescription || '(no leading plugin comment)',
735
+ };
736
+ }
737
+
738
+ // Loads metadata from one trusted plugin source for CLI reporting.
739
+ function readPluginMetadata(pluginPath) {
740
+ return pluginMetadata(fs.readFileSync(pluginPath, 'utf8'));
741
+ }
742
+
743
+ // Resolves enabled plugin files and transpiles TypeScript plugins into User/cache.
744
+ function resolvePluginUrls(config, rootDir) {
745
+ const enabled = Array.isArray(config.plugins?.enabled) ? config.plugins.enabled : [];
746
+ const pluginsDir = path.join(rootDir, 'plugins');
747
+ const cacheDir = path.join(rootDir, 'cache', 'plugins');
748
+ const pluginRoot = `${path.resolve(pluginsDir)}${path.sep}`;
749
+
750
+ return enabled.flatMap((entry) => {
751
+ if (typeof entry !== 'string' || entry.trim() === '') {
752
+ return [];
753
+ }
754
+
755
+ const pluginPath = path.resolve(rootDir, entry);
756
+ const extension = path.extname(pluginPath);
757
+ if (!pluginPath.startsWith(pluginRoot) || !['.js', '.ts'].includes(extension)) {
758
+ return [];
759
+ }
760
+
761
+ const relativeName = path.relative(pluginsDir, pluginPath);
762
+ const compiledPath = path.join(cacheDir, relativeName.replace(/\.ts$/, '.js'));
763
+ const source = fs.readFileSync(pluginPath, 'utf8');
764
+ let output = source;
765
+
766
+ // TypeScript plugins are transpiled to JavaScript because the renderer can
767
+ // only load script files directly.
768
+ if (extension === '.ts') {
769
+ const compiled = ts.transpileModule(source, {
770
+ compilerOptions: {
771
+ module: ts.ModuleKind.None,
772
+ target: ts.ScriptTarget.ES2020,
773
+ removeComments: false,
774
+ sourceMap: false,
775
+ },
776
+ fileName: pluginPath,
777
+ });
778
+ output = compiled.outputText;
779
+ }
780
+
781
+ // Each plugin is a classic script. Isolate its top-level declarations so
782
+ // two trusted plugins can independently use names such as `const api`.
783
+ fs.mkdirSync(path.dirname(compiledPath), { recursive: true });
784
+ fs.writeFileSync(
785
+ compiledPath,
786
+ `(() => {\n${output}\n})();\n//# sourceURL=${pathToFileURL(pluginPath).toString()}\n`,
787
+ );
788
+
789
+ return [{
790
+ name: path.relative(rootDir, pluginPath),
791
+ url: pathToFileURL(compiledPath).toString(),
792
+ }];
793
+ });
794
+ }
795
+
796
+ // Loads the full runtime config and the renderer-loadable plugin script URLs.
797
+ function loadConfig() {
798
+ const file = configPath();
799
+ const dir = path.dirname(file);
800
+ writeDefaultConfigExample(file);
801
+
802
+ const userConfig = readUserConfig(file);
803
+ const selected = selectProfileConfig(userConfig);
804
+ let config = removeUnsupportedConfigSections(mergeConfig(
805
+ mergeConfig(platformDefaultConfig(), selected.baseConfig),
806
+ selected.profileConfig,
807
+ ));
808
+ config = migrateLegacyMacosFontFamily(config);
809
+ config = migrateLegacyTerminalLineHeight(config);
810
+ if (config.window?.rememberBounds !== false) {
811
+ const statePath = readableWindowStatePath();
812
+ if (statePath) {
813
+ config.window = mergeConfig(config.window, sanitizeWindowState(readWindowState(statePath)).window);
814
+ }
815
+ }
816
+ const pluginUrls = resolvePluginUrls(config, dir);
817
+
818
+ return {
819
+ config,
820
+ configDir: dir,
821
+ configPath: file,
822
+ activeProfile: selected.activeProfile,
823
+ pluginUrls,
824
+ windowStatePath: windowStatePath(),
825
+ };
826
+ }
827
+
828
+ module.exports = {
829
+ defaultConfig,
830
+ defaultConfigExample,
831
+ configDir,
832
+ configPath,
833
+ deleteWindowState,
834
+ discoverPluginFiles,
835
+ profileDir,
836
+ pluginMetadata,
837
+ readPluginMetadata,
838
+ readUserConfig,
839
+ validateUserConfig,
840
+ resolvePluginSelector,
841
+ writeUserConfig,
842
+ readWindowState,
843
+ writeWindowState,
844
+ loadConfig,
845
+ migrateLegacyMacosFontFamily,
846
+ migrateLegacyTerminalLineHeight,
847
+ mergeConfig,
848
+ missingConfigKeys,
849
+ pruneUnsupportedConfig,
850
+ profileNames,
851
+ platformDefaultConfig,
852
+ selectProfileConfig,
853
+ writableConfigDefaults,
854
+ windowStatePath,
855
+ };