termcast 1.3.50 → 1.3.52

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.
Files changed (178) hide show
  1. package/dist/apis/environment.d.ts +1 -0
  2. package/dist/apis/environment.d.ts.map +1 -1
  3. package/dist/apis/environment.js +5 -0
  4. package/dist/apis/environment.js.map +1 -1
  5. package/dist/app.d.ts +33 -0
  6. package/dist/app.d.ts.map +1 -0
  7. package/dist/app.js +1130 -0
  8. package/dist/app.js.map +1 -0
  9. package/dist/cli.js +80 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/compile.d.ts.map +1 -1
  12. package/dist/compile.js +5 -2
  13. package/dist/compile.js.map +1 -1
  14. package/dist/components/actions.d.ts +4 -1
  15. package/dist/components/actions.d.ts.map +1 -1
  16. package/dist/components/actions.js +8 -5
  17. package/dist/components/actions.js.map +1 -1
  18. package/dist/components/detail.d.ts.map +1 -1
  19. package/dist/components/detail.js +21 -18
  20. package/dist/components/detail.js.map +1 -1
  21. package/dist/components/dropdown.d.ts.map +1 -1
  22. package/dist/components/dropdown.js +3 -2
  23. package/dist/components/dropdown.js.map +1 -1
  24. package/dist/components/footer.d.ts +6 -0
  25. package/dist/components/footer.d.ts.map +1 -1
  26. package/dist/components/footer.js +15 -6
  27. package/dist/components/footer.js.map +1 -1
  28. package/dist/components/form/checkbox.d.ts.map +1 -1
  29. package/dist/components/form/checkbox.js +1 -13
  30. package/dist/components/form/checkbox.js.map +1 -1
  31. package/dist/components/form/date-picker.js +2 -2
  32. package/dist/components/form/date-picker.js.map +1 -1
  33. package/dist/components/form/description.js +1 -1
  34. package/dist/components/form/description.js.map +1 -1
  35. package/dist/components/form/dropdown.d.ts.map +1 -1
  36. package/dist/components/form/dropdown.js +19 -3
  37. package/dist/components/form/dropdown.js.map +1 -1
  38. package/dist/components/form/file-picker.d.ts.map +1 -1
  39. package/dist/components/form/file-picker.js +22 -4
  40. package/dist/components/form/file-picker.js.map +1 -1
  41. package/dist/components/form/index.d.ts +3 -1
  42. package/dist/components/form/index.d.ts.map +1 -1
  43. package/dist/components/form/index.js +7 -5
  44. package/dist/components/form/index.js.map +1 -1
  45. package/dist/components/form/password-field.js +3 -3
  46. package/dist/components/form/password-field.js.map +1 -1
  47. package/dist/components/form/text-area.d.ts.map +1 -1
  48. package/dist/components/form/text-area.js +29 -6
  49. package/dist/components/form/text-area.js.map +1 -1
  50. package/dist/components/form/text-field.js +3 -3
  51. package/dist/components/form/text-field.js.map +1 -1
  52. package/dist/components/graph.d.ts.map +1 -1
  53. package/dist/components/graph.js +21 -25
  54. package/dist/components/graph.js.map +1 -1
  55. package/dist/components/heatmap.d.ts +80 -0
  56. package/dist/components/heatmap.d.ts.map +1 -0
  57. package/dist/components/heatmap.js +424 -0
  58. package/dist/components/heatmap.js.map +1 -0
  59. package/dist/components/list.d.ts +2 -0
  60. package/dist/components/list.d.ts.map +1 -1
  61. package/dist/components/list.js +91 -58
  62. package/dist/components/list.js.map +1 -1
  63. package/dist/components/markdown.d.ts +7 -0
  64. package/dist/components/markdown.d.ts.map +1 -0
  65. package/dist/components/markdown.js +19 -0
  66. package/dist/components/markdown.js.map +1 -0
  67. package/dist/components/metadata.d.ts.map +1 -1
  68. package/dist/components/metadata.js +4 -1
  69. package/dist/components/metadata.js.map +1 -1
  70. package/dist/components/progress-bar.d.ts +37 -0
  71. package/dist/components/progress-bar.d.ts.map +1 -0
  72. package/dist/components/progress-bar.js +34 -0
  73. package/dist/components/progress-bar.js.map +1 -0
  74. package/dist/components/table.d.ts +3 -2
  75. package/dist/components/table.d.ts.map +1 -1
  76. package/dist/components/table.js +78 -63
  77. package/dist/components/table.js.map +1 -1
  78. package/dist/diagram-parser.d.ts +17 -3
  79. package/dist/diagram-parser.d.ts.map +1 -1
  80. package/dist/diagram-parser.js +17 -3
  81. package/dist/diagram-parser.js.map +1 -1
  82. package/dist/examples/list-slot.d.ts +2 -0
  83. package/dist/examples/list-slot.d.ts.map +1 -0
  84. package/dist/examples/list-slot.js +14 -0
  85. package/dist/examples/list-slot.js.map +1 -0
  86. package/dist/examples/list-with-dropdown.js +2 -4
  87. package/dist/examples/list-with-dropdown.js.map +1 -1
  88. package/dist/examples/simple-heatmap.d.ts +2 -0
  89. package/dist/examples/simple-heatmap.d.ts.map +1 -0
  90. package/dist/examples/simple-heatmap.js +37 -0
  91. package/dist/examples/simple-heatmap.js.map +1 -0
  92. package/dist/examples/simple-progress-bar.d.ts +2 -0
  93. package/dist/examples/simple-progress-bar.d.ts.map +1 -0
  94. package/dist/examples/simple-progress-bar.js +36 -0
  95. package/dist/examples/simple-progress-bar.js.map +1 -0
  96. package/dist/index.d.ts +6 -0
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +6 -0
  99. package/dist/index.js.map +1 -1
  100. package/dist/internal/date-picker-widget.d.ts.map +1 -1
  101. package/dist/internal/date-picker-widget.js +5 -4
  102. package/dist/internal/date-picker-widget.js.map +1 -1
  103. package/dist/internal/navigation.d.ts.map +1 -1
  104. package/dist/internal/navigation.js +7 -2
  105. package/dist/internal/navigation.js.map +1 -1
  106. package/dist/internal/providers.d.ts.map +1 -1
  107. package/dist/internal/providers.js +42 -4
  108. package/dist/internal/providers.js.map +1 -1
  109. package/dist/logger.js +6 -1
  110. package/dist/logger.js.map +1 -1
  111. package/dist/state.d.ts +2 -0
  112. package/dist/state.d.ts.map +1 -1
  113. package/dist/state.js +31 -2
  114. package/dist/state.js.map +1 -1
  115. package/dist/theme.d.ts +1 -0
  116. package/dist/theme.d.ts.map +1 -1
  117. package/dist/theme.js +23 -1
  118. package/dist/theme.js.map +1 -1
  119. package/dist/utils.d.ts.map +1 -1
  120. package/dist/utils.js +6 -1
  121. package/dist/utils.js.map +1 -1
  122. package/package.json +3 -3
  123. package/src/apis/environment.tsx +6 -0
  124. package/src/app.tsx +1492 -0
  125. package/src/assets/default-app-icon.png +0 -0
  126. package/src/cli.tsx +105 -0
  127. package/src/compile.tsx +5 -2
  128. package/src/components/actions.tsx +9 -6
  129. package/src/components/detail.tsx +33 -23
  130. package/src/components/dropdown.tsx +3 -2
  131. package/src/components/footer.tsx +40 -7
  132. package/src/components/form/checkbox.tsx +2 -17
  133. package/src/components/form/date-picker.tsx +2 -2
  134. package/src/components/form/description.tsx +1 -1
  135. package/src/components/form/dropdown.tsx +22 -3
  136. package/src/components/form/file-picker.tsx +33 -10
  137. package/src/components/form/index.tsx +11 -7
  138. package/src/components/form/password-field.tsx +3 -3
  139. package/src/components/form/text-area.tsx +31 -6
  140. package/src/components/form/text-field.tsx +3 -3
  141. package/src/components/graph.tsx +21 -24
  142. package/src/components/heatmap.tsx +602 -0
  143. package/src/components/list.tsx +147 -78
  144. package/src/components/markdown.tsx +30 -0
  145. package/src/components/metadata.tsx +9 -2
  146. package/src/components/progress-bar.tsx +112 -0
  147. package/src/components/table.tsx +88 -71
  148. package/src/diagram-parser.tsx +17 -3
  149. package/src/examples/bar-graph-weekly.vitest.tsx +4 -4
  150. package/src/examples/detail-metadata-showcase.vitest.tsx +12 -12
  151. package/src/examples/form-basic.vitest.tsx +117 -16
  152. package/src/examples/graph-bar-chart.vitest.tsx +7 -7
  153. package/src/examples/graph-row.vitest.tsx +45 -45
  154. package/src/examples/graph-styles.vitest.tsx +19 -19
  155. package/src/examples/internal/descendants-rerender.vitest.tsx +94 -46
  156. package/src/examples/internal/simple-scrollbox.vitest.tsx +38 -14
  157. package/src/examples/list-dropdown-default.vitest.tsx +78 -58
  158. package/src/examples/list-slot.tsx +38 -0
  159. package/src/examples/list-with-detail.vitest.tsx +8 -8
  160. package/src/examples/list-with-dropdown.tsx +2 -2
  161. package/src/examples/list-with-dropdown.vitest.tsx +16 -16
  162. package/src/examples/list-with-sections.vitest.tsx +45 -32
  163. package/src/examples/simple-detail-table.vitest.tsx +2 -2
  164. package/src/examples/simple-file-picker.vitest.tsx +1 -1
  165. package/src/examples/simple-grid.vitest.tsx +27 -53
  166. package/src/examples/simple-heatmap.tsx +63 -0
  167. package/src/examples/simple-heatmap.vitest.tsx +88 -0
  168. package/src/examples/simple-progress-bar.tsx +82 -0
  169. package/src/examples/simple-progress-bar.vitest.tsx +72 -0
  170. package/src/examples/table-edge-cases.vitest.tsx +1 -1
  171. package/src/index.tsx +19 -0
  172. package/src/internal/date-picker-widget.tsx +23 -12
  173. package/src/internal/navigation.tsx +7 -2
  174. package/src/internal/providers.tsx +48 -3
  175. package/src/logger.tsx +6 -1
  176. package/src/state.tsx +38 -2
  177. package/src/theme.tsx +26 -2
  178. package/src/utils.tsx +6 -1
package/dist/app.js ADDED
@@ -0,0 +1,1130 @@
1
+ // Build standalone desktop app bundles (macOS .app, Windows folder) that wrap WezTerm
2
+ // + a compiled termcast extension. Each bundle contains: wezterm-gui binary, baked
3
+ // wezterm.lua config, compiled extension, a platform launcher, and a custom icon.
4
+ // Multiple apps run fully isolated because --config-file triggers WezTerm's
5
+ // NoConnectNoPublish mode (separate PID sockets per process).
6
+ // See termcast/docs/wezterm-fork.md for the full architecture.
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { execFile } from 'node:child_process';
11
+ import { promisify } from 'node:util';
12
+ import { compileExtension } from './compile';
13
+ import { getResolvedTheme, defaultThemeName } from './themes';
14
+ const execFileAsync = promisify(execFile);
15
+ // Pin to a known-good WezTerm release. Update manually when needed.
16
+ const WEZTERM_TAG = '20240203-110809-5046fc22';
17
+ const WEZTERM_MACOS_ZIP_URL = `https://github.com/wez/wezterm/releases/download/${WEZTERM_TAG}/WezTerm-macos-${WEZTERM_TAG}.zip`;
18
+ const WEZTERM_WINDOWS_ZIP_URL = `https://github.com/wez/wezterm/releases/download/${WEZTERM_TAG}/WezTerm-windows-${WEZTERM_TAG}.zip`;
19
+ // Files to extract from the Windows WezTerm zip. wezterm-gui.exe is the main binary,
20
+ // conpty.dll + OpenConsole.exe are required for PTY support, and the ANGLE DLLs
21
+ // (libEGL/libGLESv2) provide WebGpu/OpenGL on machines with older GPU drivers.
22
+ const WEZTERM_WINDOWS_FILES = [
23
+ 'wezterm-gui.exe',
24
+ 'conpty.dll',
25
+ 'OpenConsole.exe',
26
+ 'libEGL.dll',
27
+ 'libGLESv2.dll',
28
+ ];
29
+ // Bundled default icon shipped with termcast source.
30
+ // __dirname is termcast/src/ in dev or termcast/dist/ when published.
31
+ // The asset lives in src/assets/, so resolve from the package root.
32
+ const termcastRoot = path.resolve(__dirname, '..');
33
+ const DEFAULT_ICON_PATH = path.join(termcastRoot, 'src', 'assets', 'default-app-icon.png');
34
+ function getCacheDir() {
35
+ return path.join(os.homedir(), '.termcast', 'cache');
36
+ }
37
+ // Download and cache the wezterm-gui universal binary from official WezTerm release.
38
+ // Returns the path to the cached universal binary.
39
+ async function downloadWeztermUniversal() {
40
+ const cacheDir = path.join(getCacheDir(), 'wezterm', WEZTERM_TAG);
41
+ const cachedBinary = path.join(cacheDir, 'wezterm-gui-universal');
42
+ if (fs.existsSync(cachedBinary)) {
43
+ return cachedBinary;
44
+ }
45
+ console.log(`Downloading WezTerm ${WEZTERM_TAG}...`);
46
+ fs.mkdirSync(cacheDir, { recursive: true });
47
+ const response = await fetch(WEZTERM_MACOS_ZIP_URL);
48
+ if (!response.ok) {
49
+ throw new Error(`Failed to download WezTerm: ${response.status} ${response.statusText}`);
50
+ }
51
+ const buffer = await response.arrayBuffer();
52
+ console.log(`Downloaded ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB`);
53
+ // Extract wezterm-gui binary from the zip using JSZip (cross-platform, no shell deps)
54
+ console.log('Extracting wezterm-gui...');
55
+ const JSZip = (await import('jszip')).default;
56
+ const zip = await JSZip.loadAsync(buffer);
57
+ const weztermGuiEntry = Object.keys(zip.files).find((name) => {
58
+ return name.endsWith('/wezterm-gui') && name.includes('MacOS');
59
+ });
60
+ if (!weztermGuiEntry) {
61
+ const entries = Object.keys(zip.files).slice(0, 20).join('\n ');
62
+ throw new Error(`Could not find wezterm-gui in archive. First entries:\n ${entries}`);
63
+ }
64
+ const weztermGuiData = await zip.files[weztermGuiEntry].async('nodebuffer');
65
+ // Write to temp file then rename for atomic cache write (concurrency safe)
66
+ const tmpBinary = cachedBinary + `.tmp-${process.pid}`;
67
+ fs.writeFileSync(tmpBinary, weztermGuiData);
68
+ fs.chmodSync(tmpBinary, 0o755);
69
+ fs.renameSync(tmpBinary, cachedBinary);
70
+ console.log(`Cached wezterm-gui at ${cacheDir}`);
71
+ return cachedBinary;
72
+ }
73
+ // Get a single-arch wezterm-gui binary, thinning the universal binary with lipo.
74
+ // On macOS: uses lipo to extract the requested arch (~65MB instead of ~130MB).
75
+ // On Linux: can't run lipo, so returns the full universal binary as-is.
76
+ async function getWeztermBinary({ arch }) {
77
+ const universalBinary = await downloadWeztermUniversal();
78
+ const cacheDir = path.dirname(universalBinary);
79
+ const archName = arch === 'x64' ? 'x86_64' : 'arm64';
80
+ const thinnedBinary = path.join(cacheDir, `wezterm-gui-${archName}`);
81
+ if (fs.existsSync(thinnedBinary)) {
82
+ return thinnedBinary;
83
+ }
84
+ // lipo is macOS-only — on other platforms return the universal binary
85
+ if (process.platform !== 'darwin') {
86
+ console.log(`Not on macOS, using universal binary (130MB). Thin with lipo on macOS for ~65MB.`);
87
+ return universalBinary;
88
+ }
89
+ console.log(`Thinning wezterm-gui to ${archName}...`);
90
+ const tmpThinned = thinnedBinary + `.tmp-${process.pid}`;
91
+ await execFileAsync('lipo', ['-thin', archName, universalBinary, '-output', tmpThinned]);
92
+ fs.chmodSync(tmpThinned, 0o755);
93
+ fs.renameSync(tmpThinned, thinnedBinary);
94
+ const stat = fs.statSync(thinnedBinary);
95
+ console.log(`Thinned to ${(stat.size / 1024 / 1024).toFixed(1)}MB (${archName})`);
96
+ return thinnedBinary;
97
+ }
98
+ // Download and cache WezTerm Windows files from official release.
99
+ // Returns a map of filename -> cached file path for the needed DLLs and executables.
100
+ async function downloadWeztermWindows() {
101
+ const cacheDir = path.join(getCacheDir(), 'wezterm', WEZTERM_TAG, 'windows');
102
+ const sentinel = path.join(cacheDir, '.complete');
103
+ // Check if all files are already cached. Verify each file exists
104
+ // in case of partial cleanup or corruption.
105
+ if (fs.existsSync(sentinel)) {
106
+ const allExist = WEZTERM_WINDOWS_FILES.every((name) => {
107
+ return fs.existsSync(path.join(cacheDir, name));
108
+ });
109
+ if (allExist) {
110
+ const result = new Map();
111
+ for (const name of WEZTERM_WINDOWS_FILES) {
112
+ result.set(name, path.join(cacheDir, name));
113
+ }
114
+ return result;
115
+ }
116
+ // Sentinel exists but files are missing — remove sentinel and re-download
117
+ fs.unlinkSync(sentinel);
118
+ }
119
+ console.log(`Downloading WezTerm Windows ${WEZTERM_TAG}...`);
120
+ fs.mkdirSync(cacheDir, { recursive: true });
121
+ const response = await fetch(WEZTERM_WINDOWS_ZIP_URL);
122
+ if (!response.ok) {
123
+ throw new Error(`Failed to download WezTerm Windows: ${response.status} ${response.statusText}`);
124
+ }
125
+ const buffer = await response.arrayBuffer();
126
+ console.log(`Downloaded ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB`);
127
+ console.log('Extracting WezTerm Windows files...');
128
+ const JSZip = (await import('jszip')).default;
129
+ const zip = await JSZip.loadAsync(buffer);
130
+ const result = new Map();
131
+ for (const name of WEZTERM_WINDOWS_FILES) {
132
+ const zipEntry = Object.keys(zip.files).find((entry) => {
133
+ return entry.endsWith('/' + name) || entry === name;
134
+ });
135
+ if (!zipEntry) {
136
+ throw new Error(`Could not find ${name} in WezTerm Windows archive`);
137
+ }
138
+ const data = await zip.files[zipEntry].async('nodebuffer');
139
+ const outPath = path.join(cacheDir, name);
140
+ const tmpPath = outPath + `.tmp-${process.pid}`;
141
+ fs.writeFileSync(tmpPath, data);
142
+ fs.renameSync(tmpPath, outPath);
143
+ result.set(name, outPath);
144
+ }
145
+ // Write sentinel after all files are extracted successfully
146
+ fs.writeFileSync(sentinel, '');
147
+ console.log(`Cached WezTerm Windows files at ${cacheDir}`);
148
+ return result;
149
+ }
150
+ // Resolve the icon to use. Returns path to a PNG file.
151
+ // Priority: --icon flag > package.json icon field > bundled default
152
+ function resolveIcon({ extensionPath, iconOverride, packageJson, }) {
153
+ if (iconOverride) {
154
+ const resolved = path.resolve(iconOverride);
155
+ if (!fs.existsSync(resolved)) {
156
+ throw new Error(`Icon file not found: ${resolved}`);
157
+ }
158
+ return resolved;
159
+ }
160
+ if (packageJson.icon) {
161
+ const directPath = path.join(extensionPath, packageJson.icon);
162
+ if (fs.existsSync(directPath)) {
163
+ return directPath;
164
+ }
165
+ const assetsPath = path.join(extensionPath, 'assets', packageJson.icon);
166
+ if (fs.existsSync(assetsPath)) {
167
+ return assetsPath;
168
+ }
169
+ }
170
+ if (fs.existsSync(DEFAULT_ICON_PATH)) {
171
+ return DEFAULT_ICON_PATH;
172
+ }
173
+ throw new Error(`No icon found. Provide --icon flag or add an "icon" field to package.json`);
174
+ }
175
+ // Try to load sharp (optional dependency). Returns null if not installed.
176
+ // sharp provides high-quality Lanczos3 resizing for icon generation.
177
+ // Without it, the original PNG is embedded at all sizes and the OS handles scaling.
178
+ let _sharpModule;
179
+ async function getSharp() {
180
+ if (_sharpModule !== undefined) {
181
+ return _sharpModule;
182
+ }
183
+ try {
184
+ _sharpModule = (await import('sharp')).default;
185
+ }
186
+ catch {
187
+ _sharpModule = null;
188
+ console.log('Note: sharp not installed, icons will not be resized. Install sharp for higher quality icons.');
189
+ }
190
+ return _sharpModule;
191
+ }
192
+ // Resize a PNG buffer to a square target size using sharp (Lanczos3 for downscale).
193
+ // Returns a new PNG buffer at the target dimensions.
194
+ // If sharp is not available or the source is already the target size, returns unchanged.
195
+ async function resizePng({ pngData, size }) {
196
+ const sharpFn = await getSharp();
197
+ if (!sharpFn) {
198
+ return pngData;
199
+ }
200
+ const metadata = await sharpFn(pngData).metadata();
201
+ if (metadata.width === size && metadata.height === size) {
202
+ return pngData;
203
+ }
204
+ return sharpFn(pngData)
205
+ .resize(size, size, {
206
+ fit: 'contain',
207
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
208
+ kernel: 'lanczos3',
209
+ })
210
+ .png()
211
+ .toBuffer();
212
+ }
213
+ // Build a .icns file from properly sized PNG buffers. Each entry gets its own
214
+ // correctly sized PNG for sharp rendering at that resolution.
215
+ // The format is: 'icns' magic (4B) + total file size (4B BE) + entries.
216
+ // Each entry: type code (4B) + entry size including header (4B BE) + PNG data.
217
+ // Type codes: ic10=1024 (retina 512@2x), ic09=512, ic08=256, ic07=128.
218
+ function buildIcnsFromPngs(sizedPngs) {
219
+ const entries = sizedPngs.map(({ type, data }) => {
220
+ const header = Buffer.alloc(8);
221
+ header.write(type, 0, 4, 'ascii');
222
+ header.writeUInt32BE(8 + data.length, 4);
223
+ return Buffer.concat([header, data]);
224
+ });
225
+ const totalEntrySize = entries.reduce((sum, e) => sum + e.length, 0);
226
+ const fileHeader = Buffer.alloc(8);
227
+ fileHeader.write('icns', 0, 4, 'ascii');
228
+ fileHeader.writeUInt32BE(8 + totalEntrySize, 4);
229
+ return Buffer.concat([fileHeader, ...entries]);
230
+ }
231
+ // icns type codes and their required pixel sizes
232
+ const ICNS_SIZES = [
233
+ { type: 'ic10', size: 1024 },
234
+ { type: 'ic09', size: 512 },
235
+ { type: 'ic08', size: 256 },
236
+ { type: 'ic07', size: 128 },
237
+ ];
238
+ async function convertToIcns({ pngPath, outputPath, }) {
239
+ const pngData = fs.readFileSync(pngPath);
240
+ if (pngData[0] !== 0x89 || pngData[1] !== 0x50 || pngData[2] !== 0x4e || pngData[3] !== 0x47) {
241
+ throw new Error(`File is not a valid PNG: ${pngPath}`);
242
+ }
243
+ // Resize source PNG to each required size in parallel
244
+ const sizedPngs = await Promise.all(ICNS_SIZES.map(async ({ type, size }) => {
245
+ const data = await resizePng({ pngData, size });
246
+ return { type, data };
247
+ }));
248
+ const icns = buildIcnsFromPngs(sizedPngs);
249
+ fs.writeFileSync(outputPath, icns);
250
+ }
251
+ // Standard ICO sizes: 256 for high-DPI/Explorer, smaller ones for taskbar/title bar
252
+ const ICO_SIZES = [256, 128, 64, 48, 32, 16];
253
+ // Build a .ico file from properly sized PNG buffers. Each directory entry
254
+ // points to its own correctly sized PNG data for crisp rendering at that size.
255
+ // Modern Windows ICO files accept embedded PNG data (since Vista).
256
+ // Format: ICO header (6B) + directory entries (16B each) + PNG data blocks.
257
+ function buildIcoFromPngs(sizedPngs) {
258
+ // ICO header: reserved(2) + type(2, 1=ICO) + count(2)
259
+ const header = Buffer.alloc(6);
260
+ header.writeUInt16LE(0, 0);
261
+ header.writeUInt16LE(1, 2);
262
+ header.writeUInt16LE(sizedPngs.length, 4);
263
+ // Directory entries come right after header, then PNG data blocks
264
+ const dirEntrySize = 16;
265
+ const dataOffset = 6 + dirEntrySize * sizedPngs.length;
266
+ const dirEntries = [];
267
+ let currentOffset = dataOffset;
268
+ for (const { size, data } of sizedPngs) {
269
+ const entry = Buffer.alloc(16);
270
+ // width/height: 0 means 256 in ICO format
271
+ entry.writeUInt8(size >= 256 ? 0 : size, 0);
272
+ entry.writeUInt8(size >= 256 ? 0 : size, 1);
273
+ entry.writeUInt8(0, 2); // color palette count
274
+ entry.writeUInt8(0, 3); // reserved
275
+ entry.writeUInt16LE(1, 4); // color planes
276
+ entry.writeUInt16LE(32, 6); // bits per pixel
277
+ entry.writeUInt32LE(data.length, 8);
278
+ entry.writeUInt32LE(currentOffset, 12);
279
+ dirEntries.push(entry);
280
+ currentOffset += data.length;
281
+ }
282
+ const pngBlocks = sizedPngs.map(({ data }) => data);
283
+ return Buffer.concat([header, ...dirEntries, ...pngBlocks]);
284
+ }
285
+ async function convertToIco({ pngPath, outputPath, }) {
286
+ const pngData = fs.readFileSync(pngPath);
287
+ if (pngData[0] !== 0x89 || pngData[1] !== 0x50 || pngData[2] !== 0x4e || pngData[3] !== 0x47) {
288
+ throw new Error(`File is not a valid PNG: ${pngPath}`);
289
+ }
290
+ // Resize source PNG to each required size in parallel
291
+ const sizedPngs = await Promise.all(ICO_SIZES.map(async (size) => {
292
+ const data = await resizePng({ pngData, size });
293
+ return { size, data };
294
+ }));
295
+ const ico = buildIcoFromPngs(sizedPngs);
296
+ fs.writeFileSync(outputPath, ico);
297
+ }
298
+ // Generate the C source for the Windows launcher. This tiny program hides the console
299
+ // window (via WinMain + windows subsystem) and launches wezterm-gui.exe with the
300
+ // baked config file. All WezTerm/extension files live in a runtime/ subdirectory so
301
+ // the user only sees the launcher .exe at the top level. Cross-compiled with `zig cc`.
302
+ function generateLauncherC({ themeName }) {
303
+ return `\
304
+ #define WIN32_LEAN_AND_MEAN
305
+ #include <windows.h>
306
+ #include <wchar.h>
307
+
308
+ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) {
309
+ (void)hInstance; (void)hPrevInstance; (void)lpCmdLine; (void)nCmdShow;
310
+
311
+ WCHAR dir[MAX_PATH];
312
+ GetModuleFileNameW(NULL, dir, MAX_PATH);
313
+ /* Strip executable name to get the directory */
314
+ for (int i = (int)wcslen(dir) - 1; i >= 0; i--) {
315
+ if (dir[i] == L'\\\\') { dir[i + 1] = L'\\0'; break; }
316
+ }
317
+
318
+ /* Set TERMCAST_WEZTERM_CONFIG env var so the TUI can rewrite the config on theme change */
319
+ WCHAR configPath[MAX_PATH * 2];
320
+ wsprintfW(configPath, L"%sruntime\\\\config\\\\wezterm.lua", dir);
321
+ SetEnvironmentVariableW(L"TERMCAST_WEZTERM_CONFIG", configPath);
322
+
323
+ /* Set default theme name baked at build time */
324
+ SetEnvironmentVariableW(L"TERMCAST_DEFAULT_THEME", L"${themeName}");
325
+
326
+ /* Mark as standalone app mode (disables ESC-to-exit, etc.) */
327
+ SetEnvironmentVariableW(L"TERMCAST_APP_MODE", L"1");
328
+
329
+ WCHAR cmdline[MAX_PATH * 3];
330
+ wsprintfW(cmdline,
331
+ L"\\"%sruntime\\\\wezterm-gui.exe\\" --config-file \\"%sruntime\\\\config\\\\wezterm.lua\\"",
332
+ dir, dir);
333
+
334
+ STARTUPINFOW si;
335
+ ZeroMemory(&si, sizeof(si));
336
+ si.cb = sizeof(si);
337
+ PROCESS_INFORMATION pi;
338
+ ZeroMemory(&pi, sizeof(pi));
339
+
340
+ if (!CreateProcessW(NULL, cmdline, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi)) {
341
+ MessageBoxW(NULL, L"Failed to launch wezterm-gui.exe", L"Launch Error", 0x10);
342
+ return 1;
343
+ }
344
+ CloseHandle(pi.hProcess);
345
+ CloseHandle(pi.hThread);
346
+ return 0;
347
+ }
348
+ `;
349
+ }
350
+ // Generate the .rc resource file that embeds the icon into the launcher .exe.
351
+ // The icon ID 1 is used by Windows Explorer to display the app icon.
352
+ function generateLauncherRc({ icoPath }) {
353
+ // Use forward slashes in the .rc file — the Windows resource compiler accepts them
354
+ const normalizedPath = icoPath.replace(/\\/g, '/');
355
+ return `1 ICON "${normalizedPath}"\n`;
356
+ }
357
+ // Generate the font/typography portion of wezterm.lua, shared by macOS and Windows configs.
358
+ function generateFontConfig(opts) {
359
+ const fontSize = opts.fontSize ?? 14;
360
+ const lineHeight = opts.lineHeight ?? 1.3;
361
+ const cellWidth = opts.cellWidth ?? 1.05;
362
+ const lines = [];
363
+ lines.push(`-- Typography`);
364
+ lines.push(`config.font_size = ${fontSize}`);
365
+ lines.push(`config.line_height = ${lineHeight}`);
366
+ if (cellWidth !== 1.0) {
367
+ lines.push(`config.cell_width = ${cellWidth}`);
368
+ }
369
+ if (opts.fontFamily) {
370
+ // Escape single quotes for Lua string literal
371
+ const escapedFamily = opts.fontFamily.replace(/'/g, "\\'");
372
+ lines.push(`config.font = wezterm.font '${escapedFamily}'`);
373
+ }
374
+ if (opts.hasBundledFonts) {
375
+ lines.push(``);
376
+ lines.push(`-- Load bundled fonts from the fonts/ directory next to this config`);
377
+ lines.push(`config.font_dirs = { config_dir .. '/fonts' }`);
378
+ }
379
+ return lines.join('\n');
380
+ }
381
+ // Single config generator for both macOS and Windows. Only 4 things differ:
382
+ // - default_prog path separator
383
+ // - key bindings (SUPER on mac, none on Windows since Cmd doesn't exist)
384
+ // - quote_dropped_files (Posix vs Windows)
385
+ // - rendering comment
386
+ function generateWeztermConfig({ binaryName, font, platform, backgroundColor, }) {
387
+ const defaultProg = platform === 'win32'
388
+ ? `config_dir .. '\\\\..\\\\${binaryName}'`
389
+ : `config_dir .. '/${binaryName}'`;
390
+ // On macOS, forward Cmd+C, Cmd+K, and Cmd+Arrows to the TUI instead of WezTerm handling them.
391
+ // Uses SendString with raw kitty CSI sequences because SendKey drops the SUPER modifier
392
+ // — WezTerm treats SUPER as a window-manager modifier and doesn't encode it into
393
+ // kitty protocol sequences. Raw CSI sequences bypass this limitation.
394
+ // Kitty modifier encoding: SUPER = bit 3 (value 8), encoded field = bitmask + 1 = 9.
395
+ // On Windows there is no Cmd key, so no key overrides needed.
396
+ const keysBlock = platform === 'darwin'
397
+ ? `
398
+ -- Forward Cmd keys to the TUI using raw kitty protocol CSI sequences.
399
+ -- SendKey { mods = 'SUPER' } doesn't encode SUPER in kitty protocol, so we
400
+ -- use SendString with the exact CSI bytes that opentui's kitty parser expects.
401
+ -- Kitty modifier 9 = SUPER(8) + 1 (base offset per kitty spec).
402
+ config.keys = {
403
+ { key = 'c', mods = 'SUPER', action = wezterm.action.SendString('\\x1b[99;9u') },
404
+ { key = 'k', mods = 'SUPER', action = wezterm.action.SendString('\\x1b[107;9u') },
405
+ { key = 'LeftArrow', mods = 'SUPER', action = wezterm.action.SendString('\\x1b[1;9D') },
406
+ { key = 'RightArrow', mods = 'SUPER', action = wezterm.action.SendString('\\x1b[1;9C') },
407
+ { key = 'UpArrow', mods = 'SUPER', action = wezterm.action.SendString('\\x1b[1;9A') },
408
+ { key = 'DownArrow', mods = 'SUPER', action = wezterm.action.SendString('\\x1b[1;9B') },
409
+ }
410
+ `
411
+ : '';
412
+ const quoteDroppedFiles = platform === 'win32' ? 'Windows' : 'Posix';
413
+ return `\
414
+ local wezterm = require 'wezterm'
415
+ local config = wezterm.config_builder()
416
+
417
+ local config_dir = wezterm.config_dir
418
+ config.default_prog = { ${defaultProg} }
419
+
420
+ -- Window chrome
421
+ config.enable_tab_bar = false
422
+ config.window_decorations = '${platform === 'darwin' ? 'TITLE|RESIZE' : 'RESIZE'}'
423
+ config.window_padding = { left = 0, right = 0, top = 0, bottom = 0 }
424
+ config.window_close_confirmation = 'NeverPrompt'
425
+
426
+ -- Background color matching the configured termcast theme.
427
+ -- The TUI rewrites this file on theme change so WezTerm auto-reloads it,
428
+ -- keeping the window edges/padding in sync with the active theme.
429
+ config.colors = { background = '${backgroundColor}' }
430
+
431
+ -- Default window size: 120x36 is comfortable for TUI apps (WezTerm default is 80x24)
432
+ config.initial_cols = 120
433
+ config.initial_rows = 36
434
+
435
+ -- Snap resize to cell grid
436
+ config.use_resize_increments = true
437
+
438
+ -- Kitty protocols
439
+ config.enable_kitty_graphics = true
440
+ config.enable_kitty_keyboard = true
441
+
442
+ -- Memory optimization: TUI controls its own scrolling
443
+ config.scrollback_lines = 0
444
+
445
+ -- Reduce font rasterizer memory (no ligatures needed in TUI)
446
+ config.harfbuzz_features = { 'calt=0', 'clig=0', 'liga=0' }
447
+
448
+
449
+
450
+ ${generateFontConfig(font ?? {})}
451
+
452
+ -- Rendering
453
+ config.front_end = 'WebGpu'
454
+ config.webgpu_power_preference = 'HighPerformance'
455
+ config.max_fps = 60
456
+ config.freetype_render_target = 'HorizontalLcd'
457
+ config.freetype_load_target = 'Light'
458
+
459
+ ${keysBlock}
460
+ config.quote_dropped_files = '${quoteDroppedFiles}'
461
+
462
+ return config
463
+ `;
464
+ }
465
+ function generateLaunchScript({ weztermBinaryName, themeName }) {
466
+ return `\
467
+ #!/bin/bash
468
+ DIR="$(cd "$(dirname "$0")" && pwd)"
469
+ export TERMCAST_WEZTERM_CONFIG="$DIR/../Resources/wezterm.lua"
470
+ export TERMCAST_DEFAULT_THEME="${themeName}"
471
+ export TERMCAST_APP_MODE=1
472
+ exec "$DIR/${weztermBinaryName}" --config-file "$TERMCAST_WEZTERM_CONFIG"
473
+ `;
474
+ }
475
+ // Generate an NSIS installer script (.nsi) for a Windows app folder.
476
+ // NSIS (Nullsoft Scriptable Install System) cross-compiles on macOS via `makensis`.
477
+ // The installer:
478
+ // - Copies all files from the app folder to Program Files
479
+ // - Creates Start Menu shortcuts (launcher exe + uninstaller)
480
+ // - Creates a Desktop shortcut for the launcher
481
+ // - Registers uninstaller in Windows Add/Remove Programs
482
+ // - Embeds the app icon in installer/uninstaller/shortcuts
483
+ // RequestExecutionLevel admin is required for Program Files write access.
484
+ function generateNsisScript({ appName, safeName, version, appDir, launcherExeName, icoPath, outFile, }) {
485
+ // NSIS !define MUI_ICON uses build-host paths (POSIX on macOS/Linux).
486
+ // Do NOT convert to backslashes — makensis reads source files using host OS paths.
487
+ const iconDirective = icoPath
488
+ ? `!define MUI_ICON "${icoPath}"\n!define MUI_UNICON "${icoPath}"`
489
+ : '';
490
+ // Escape special NSIS characters in display strings.
491
+ // NSIS treats $ as variable prefix and " as string delimiter.
492
+ const escapeNsis = (s) => {
493
+ return s.replace(/\$/g, '$$$$').replace(/"/g, '$\\"');
494
+ };
495
+ const safeAppName = escapeNsis(appName);
496
+ const safeSafeName = escapeNsis(safeName);
497
+ // Collect all files from the app folder to generate File commands.
498
+ // We recursively walk the directory and emit SetOutPath + File for each subdir.
499
+ // File paths in the install section are build-host paths (POSIX) — makensis
500
+ // reads them on the host OS. Install-target paths ($INSTDIR\...) use backslashes.
501
+ const installFileCommands = [];
502
+ const uninstallFileCommands = [];
503
+ const uninstallDirCommands = [];
504
+ const walkDir = (dir, relPrefix) => {
505
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
506
+ const files = entries.filter((e) => !e.isDirectory());
507
+ const dirs = entries.filter((e) => e.isDirectory());
508
+ if (files.length > 0) {
509
+ installFileCommands.push(` SetOutPath "$INSTDIR${relPrefix ? '\\' + relPrefix : ''}"`);
510
+ for (const file of files) {
511
+ // Build-host path: keep POSIX slashes for makensis to read the file
512
+ const fullPath = path.join(dir, file.name);
513
+ installFileCommands.push(` File "${fullPath}"`);
514
+ uninstallFileCommands.push(` Delete "$INSTDIR${relPrefix ? '\\' + relPrefix : ''}\\${file.name}"`);
515
+ }
516
+ }
517
+ for (const subdir of dirs) {
518
+ const newRel = relPrefix ? `${relPrefix}\\${subdir.name}` : subdir.name;
519
+ walkDir(path.join(dir, subdir.name), newRel);
520
+ }
521
+ // Post-order: push AFTER recursing into children, so children dirs
522
+ // appear earlier in the array and get removed first by RMDir.
523
+ if (relPrefix) {
524
+ uninstallDirCommands.push(` RMDir "$INSTDIR\\${relPrefix}"`);
525
+ }
526
+ };
527
+ walkDir(appDir, '');
528
+ return `\
529
+ ; NSIS installer script for ${safeAppName}
530
+ ; Generated by termcast app build. Do not edit manually.
531
+ Unicode True
532
+ !include "MUI2.nsh"
533
+
534
+ Name "${safeAppName}"
535
+ OutFile "${outFile}"
536
+ InstallDir "$PROGRAMFILES64\\${safeAppName}"
537
+ InstallDirRegKey HKLM "Software\\${safeSafeName}" "InstallDir"
538
+ RequestExecutionLevel admin
539
+
540
+ ${iconDirective}
541
+
542
+ !define MUI_ABORTWARNING
543
+
544
+ ; Pages
545
+ !insertmacro MUI_PAGE_DIRECTORY
546
+ !insertmacro MUI_PAGE_INSTFILES
547
+
548
+ !insertmacro MUI_UNPAGE_CONFIRM
549
+ !insertmacro MUI_UNPAGE_INSTFILES
550
+
551
+ !insertmacro MUI_LANGUAGE "English"
552
+
553
+ Section "Install"
554
+ SetShellVarContext all
555
+
556
+ ${installFileCommands.join('\n')}
557
+
558
+ ; Store install dir in registry
559
+ WriteRegStr HKLM "Software\\${safeSafeName}" "InstallDir" "$INSTDIR"
560
+
561
+ ; Create uninstaller
562
+ WriteUninstaller "$INSTDIR\\Uninstall.exe"
563
+
564
+ ; Add/Remove Programs entry
565
+ WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}" "DisplayName" "${safeAppName}"
566
+ WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}" "UninstallString" '"$INSTDIR\\Uninstall.exe"'
567
+ WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}" "DisplayVersion" "${version}"
568
+ WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}" "Publisher" "termcast"
569
+ ${icoPath ? ` WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}" "DisplayIcon" "$INSTDIR\\${launcherExeName}"` : ''}
570
+ WriteRegDWORD HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}" "NoModify" 1
571
+ WriteRegDWORD HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}" "NoRepair" 1
572
+
573
+ ; Start Menu shortcuts
574
+ CreateDirectory "$SMPROGRAMS\\${safeAppName}"
575
+ CreateShortcut "$SMPROGRAMS\\${safeAppName}\\${safeAppName}.lnk" "$INSTDIR\\${launcherExeName}"
576
+ CreateShortcut "$SMPROGRAMS\\${safeAppName}\\Uninstall ${safeAppName}.lnk" "$INSTDIR\\Uninstall.exe"
577
+
578
+ ; Desktop shortcut
579
+ CreateShortcut "$DESKTOP\\${safeAppName}.lnk" "$INSTDIR\\${launcherExeName}"
580
+ SectionEnd
581
+
582
+ Section "Uninstall"
583
+ SetShellVarContext all
584
+
585
+ ${uninstallFileCommands.join('\n')}
586
+ Delete "$INSTDIR\\Uninstall.exe"
587
+
588
+ ${uninstallDirCommands.join('\n')}
589
+ RMDir "$INSTDIR"
590
+
591
+ ; Remove Start Menu shortcuts
592
+ Delete "$SMPROGRAMS\\${safeAppName}\\${safeAppName}.lnk"
593
+ Delete "$SMPROGRAMS\\${safeAppName}\\Uninstall ${safeAppName}.lnk"
594
+ RMDir "$SMPROGRAMS\\${safeAppName}"
595
+
596
+ ; Remove Desktop shortcut
597
+ Delete "$DESKTOP\\${safeAppName}.lnk"
598
+
599
+ ; Remove registry keys
600
+ DeleteRegKey HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}"
601
+ DeleteRegKey HKLM "Software\\${safeSafeName}"
602
+ SectionEnd
603
+ `;
604
+ }
605
+ // Build an NSIS installer (.exe) from the assembled app folder.
606
+ // Writes a temp .nsi script, runs makensis to compile it, and returns the
607
+ // path to the resulting Setup exe. Requires `makensis` in PATH (brew install nsis).
608
+ async function buildNsisInstaller({ appName, safeName, version, appDir, launcherExeName, icoPath, distDir, }) {
609
+ const installerExeName = `${safeName}-Setup-x64.exe`;
610
+ const installerPath = path.join(distDir, installerExeName);
611
+ const nsiScript = generateNsisScript({
612
+ appName,
613
+ safeName,
614
+ version,
615
+ appDir,
616
+ launcherExeName,
617
+ icoPath,
618
+ outFile: installerPath,
619
+ });
620
+ const buildTmpDir = path.join(distDir, `.nsis-tmp-${process.pid}`);
621
+ fs.mkdirSync(buildTmpDir, { recursive: true });
622
+ const nsiPath = path.join(buildTmpDir, 'installer.nsi');
623
+ fs.writeFileSync(nsiPath, nsiScript);
624
+ console.log('Building NSIS installer...');
625
+ try {
626
+ await execFileAsync('makensis', [nsiPath]);
627
+ }
628
+ catch (e) {
629
+ // makensis might not be installed — warn but don't fail the build
630
+ const msg = e instanceof Error ? e.message : String(e);
631
+ if (msg.includes('ENOENT') || msg.includes('not found')) {
632
+ console.log('Warning: makensis not found. Install NSIS to generate Windows installers:');
633
+ console.log(' macOS: brew install nsis');
634
+ console.log(' Linux: apt install nsis');
635
+ return '';
636
+ }
637
+ throw new Error(`NSIS installer build failed`, { cause: e });
638
+ }
639
+ finally {
640
+ fs.rmSync(buildTmpDir, { recursive: true, force: true });
641
+ }
642
+ const installerSize = fs.statSync(installerPath).size;
643
+ console.log(`Installer: ${installerExeName} (${(installerSize / 1024 / 1024).toFixed(1)}MB)`);
644
+ return installerPath;
645
+ }
646
+ function escapeXml(str) {
647
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
648
+ }
649
+ function generateInfoPlist({ appName, bundleId, version, iconFile = 'app.icns', }) {
650
+ const safeBundleId = escapeXml(bundleId);
651
+ const safeAppName = escapeXml(appName);
652
+ const safeVersion = escapeXml(version);
653
+ return `\
654
+ <?xml version="1.0" encoding="UTF-8"?>
655
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
656
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
657
+ <plist version="1.0">
658
+ <dict>
659
+ <key>CFBundleExecutable</key>
660
+ <string>launch</string>
661
+ <key>CFBundleIdentifier</key>
662
+ <string>${safeBundleId}</string>
663
+ <key>CFBundleName</key>
664
+ <string>${safeAppName}</string>
665
+ <key>CFBundleDisplayName</key>
666
+ <string>${safeAppName}</string>
667
+ <key>CFBundleIconFile</key>
668
+ <string>${iconFile}</string>
669
+ <key>CFBundlePackageType</key>
670
+ <string>APPL</string>
671
+ <key>CFBundleShortVersionString</key>
672
+ <string>${safeVersion}</string>
673
+ <key>CFBundleVersion</key>
674
+ <string>1</string>
675
+ <key>NSHighResolutionCapable</key>
676
+ <true/>
677
+ <key>NSSupportsAutomaticGraphicsSwitching</key>
678
+ <true/>
679
+ <key>NSRequiresAquaSystemAppearance</key>
680
+ <string>NO</string>
681
+ </dict>
682
+ </plist>
683
+ `;
684
+ }
685
+ async function resolveBuildContext({ extensionPath, name, icon, bundleId, entry, platform, arch, fontFamily, fontDir, fontSize, lineHeight, }) {
686
+ const resolvedPath = path.resolve(extensionPath);
687
+ if (!fs.existsSync(resolvedPath)) {
688
+ throw new Error(`Extension path does not exist: ${resolvedPath}`);
689
+ }
690
+ const packageJsonPath = path.join(resolvedPath, 'package.json');
691
+ if (!fs.existsSync(packageJsonPath)) {
692
+ throw new Error(`No package.json found at: ${packageJsonPath}`);
693
+ }
694
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
695
+ const rawExtensionName = packageJson.name;
696
+ if (!rawExtensionName) {
697
+ throw new Error('package.json must have a "name" field');
698
+ }
699
+ // Strip npm scope prefix (@scope/name -> name) for filesystem and lua paths
700
+ const extensionName = rawExtensionName.replace(/^@[^/]+\//, '');
701
+ const appName = name || packageJson.title || extensionName;
702
+ const resolvedBundleId = bundleId || `com.termcast.${extensionName.replace(/[^a-zA-Z0-9.-]/g, '-')}`;
703
+ const version = packageJson.version || '1.0.0';
704
+ const resolvedArch = arch || (process.arch === 'arm64' ? 'arm64' : 'x64');
705
+ // For Windows x64, always use baseline (no AVX2 requirement) so the app runs on
706
+ // all x64 CPUs. The default bun-windows-x64 target requires AVX2 (Haswell 2013+)
707
+ // which causes silent crashes on older machines.
708
+ const avx2 = platform === 'win32' ? false : undefined;
709
+ const target = { os: platform, arch: resolvedArch, avx2 };
710
+ console.log(`Building app "${appName}" for ${platform}-${resolvedArch}...`);
711
+ // Compile the termcast extension
712
+ console.log(`Compiling termcast extension...`);
713
+ const distDir = path.join(resolvedPath, 'dist');
714
+ fs.mkdirSync(distDir, { recursive: true });
715
+ const distGitignore = path.join(distDir, '.gitignore');
716
+ if (!fs.existsSync(distGitignore)) {
717
+ fs.writeFileSync(distGitignore, '*\n');
718
+ }
719
+ // Bun.build with compile appends .exe for Windows targets, so include it in the path
720
+ const exeSuffix = platform === 'win32' ? '.exe' : '';
721
+ const compiledBinaryPath = path.join(distDir, `${extensionName}-app-binary-${resolvedArch}${exeSuffix}`);
722
+ const compileResult = await compileExtension({
723
+ extensionPath: resolvedPath,
724
+ outfile: compiledBinaryPath,
725
+ minify: true,
726
+ target,
727
+ entry,
728
+ });
729
+ const iconPng = resolveIcon({
730
+ extensionPath: resolvedPath,
731
+ iconOverride: icon,
732
+ packageJson,
733
+ });
734
+ // Replace slashes, spaces, and other problematic chars with hyphens.
735
+ // Spaces in filenames break PowerShell (WezTerm's default shell on Windows)
736
+ // because PowerShell splits unquoted paths on whitespace.
737
+ const safeName = appName.replace(/[/\\\s]+/g, '-').replace(/^-+|-+$/g, '');
738
+ // Resolve font directory: --font-dir flag, or fonts/ in extension root.
739
+ // --font-dir is resolved relative to the extension path (not cwd) for consistency.
740
+ const fontDirPath = (() => {
741
+ if (fontDir) {
742
+ const resolved = path.isAbsolute(fontDir)
743
+ ? fontDir
744
+ : path.resolve(resolvedPath, fontDir);
745
+ if (!fs.existsSync(resolved)) {
746
+ throw new Error(`Font directory not found: ${resolved}`);
747
+ }
748
+ if (!fs.statSync(resolved).isDirectory()) {
749
+ throw new Error(`--font-dir must be a directory, not a file: ${resolved}`);
750
+ }
751
+ return resolved;
752
+ }
753
+ // Auto-detect fonts/ directory in extension root
754
+ const defaultFontDir = path.join(resolvedPath, 'fonts');
755
+ if (fs.existsSync(defaultFontDir) && fs.statSync(defaultFontDir).isDirectory()) {
756
+ return defaultFontDir;
757
+ }
758
+ return undefined;
759
+ })();
760
+ const fontOptions = {
761
+ fontFamily,
762
+ fontSize,
763
+ lineHeight,
764
+ hasBundledFonts: !!fontDirPath,
765
+ };
766
+ return {
767
+ resolvedPath,
768
+ extensionName,
769
+ appName,
770
+ safeName,
771
+ version,
772
+ resolvedArch,
773
+ target,
774
+ distDir,
775
+ compileResult,
776
+ iconPng,
777
+ packageJson,
778
+ resolvedBundleId,
779
+ fontOptions,
780
+ fontDirPath,
781
+ };
782
+ }
783
+ export async function buildApp(options) {
784
+ const resolvedPlatform = options.platform || process.platform;
785
+ if (resolvedPlatform === 'darwin') {
786
+ return buildDarwinApp(options, resolvedPlatform);
787
+ }
788
+ if (resolvedPlatform === 'win32') {
789
+ return buildWin32App(options, resolvedPlatform);
790
+ }
791
+ throw new Error(`Platform "${resolvedPlatform}" is not supported yet. Supported: darwin, win32.`);
792
+ }
793
+ // ── macOS .app bundle ────────────────────────────────────────────────────────
794
+ async function buildDarwinApp(options, resolvedPlatform) {
795
+ const ctx = await resolveBuildContext({ ...options, platform: resolvedPlatform });
796
+ // Download/cache WezTerm and thin to target arch (~65MB instead of ~130MB)
797
+ const weztermBinary = await getWeztermBinary({ arch: ctx.resolvedArch });
798
+ // Assemble .app bundle
799
+ const archSuffix = ctx.resolvedArch === 'x64' ? 'x86_64' : 'arm64';
800
+ const appDir = path.join(ctx.distDir, `${ctx.safeName}-${archSuffix}.app`);
801
+ if (fs.existsSync(appDir)) {
802
+ fs.rmSync(appDir, { recursive: true, force: true });
803
+ }
804
+ const macosDir = path.join(appDir, 'Contents', 'MacOS');
805
+ const resourcesDir = path.join(appDir, 'Contents', 'Resources');
806
+ fs.mkdirSync(macosDir, { recursive: true });
807
+ fs.mkdirSync(resourcesDir, { recursive: true });
808
+ console.log('Assembling .app bundle...');
809
+ // Copy wezterm-gui binary renamed to the app name so macOS Activity Monitor
810
+ // shows the app name instead of "wezterm-gui" (exec replaces the process image,
811
+ // and the OS derives the display name from the binary filename).
812
+ const weztermBinaryName = ctx.safeName;
813
+ fs.copyFileSync(weztermBinary, path.join(macosDir, weztermBinaryName));
814
+ fs.chmodSync(path.join(macosDir, weztermBinaryName), 0o755);
815
+ // Copy compiled extension binary
816
+ const binaryName = ctx.extensionName;
817
+ fs.copyFileSync(ctx.compileResult.outfile, path.join(resourcesDir, binaryName));
818
+ fs.chmodSync(path.join(resourcesDir, binaryName), 0o755);
819
+ // Bundle custom fonts if a font directory was provided/detected.
820
+ // Copies all .ttf/.otf files into Resources/fonts/ so wezterm's font_dirs can find them.
821
+ if (ctx.fontDirPath) {
822
+ const bundledFontsDir = path.join(resourcesDir, 'fonts');
823
+ fs.mkdirSync(bundledFontsDir, { recursive: true });
824
+ const fontFiles = fs.readdirSync(ctx.fontDirPath).filter((f) => {
825
+ return /\.(ttf|otf|woff2?)$/i.test(f);
826
+ });
827
+ for (const fontFile of fontFiles) {
828
+ fs.copyFileSync(path.join(ctx.fontDirPath, fontFile), path.join(bundledFontsDir, fontFile));
829
+ }
830
+ console.log(`Bundled ${fontFiles.length} font file(s)`);
831
+ }
832
+ // Resolve theme for config background and env var
833
+ const themeName = options.theme || defaultThemeName;
834
+ const themeBackground = getResolvedTheme(themeName).background;
835
+ // Write config, launch script
836
+ fs.writeFileSync(path.join(resourcesDir, 'wezterm.lua'), generateWeztermConfig({ binaryName, font: ctx.fontOptions, platform: 'darwin', backgroundColor: themeBackground }));
837
+ const launchPath = path.join(macosDir, 'launch');
838
+ fs.writeFileSync(launchPath, generateLaunchScript({ weztermBinaryName, themeName }));
839
+ fs.chmodSync(launchPath, 0o755);
840
+ // Convert and write icon, then write Info.plist with the correct icon filename
841
+ let iconFile = 'app.icns';
842
+ const icnsPath = path.join(resourcesDir, 'app.icns');
843
+ try {
844
+ await convertToIcns({ pngPath: ctx.iconPng, outputPath: icnsPath });
845
+ }
846
+ catch (e) {
847
+ console.log(`Warning: could not convert icon to .icns (${e instanceof Error ? e.message : e}), copying PNG as fallback`);
848
+ iconFile = 'app.png';
849
+ fs.copyFileSync(ctx.iconPng, path.join(resourcesDir, iconFile));
850
+ }
851
+ fs.writeFileSync(path.join(appDir, 'Contents', 'Info.plist'), generateInfoPlist({ appName: ctx.safeName, bundleId: ctx.resolvedBundleId, version: ctx.version, iconFile }));
852
+ // Clean up intermediate compiled binary + sourcemap
853
+ fs.rmSync(ctx.compileResult.outfile, { force: true });
854
+ fs.rmSync(ctx.compileResult.outfile + '.map', { force: true });
855
+ // Ad-hoc sign — only on macOS where codesign is available.
856
+ // The wezterm-gui binary's original signature is invalid in the new bundle.
857
+ if (process.platform === 'darwin') {
858
+ console.log('Ad-hoc signing...');
859
+ await execFileAsync('codesign', ['--force', '--deep', '-s', '-', appDir]);
860
+ }
861
+ else {
862
+ console.log('Skipping ad-hoc signing (not on macOS). Sign manually before distributing.');
863
+ }
864
+ const appSize = getDirectorySize(appDir);
865
+ console.log(`\nBuilt: ${appDir} (${(appSize / 1024 / 1024).toFixed(0)}MB)`);
866
+ if (options.release) {
867
+ await uploadToRelease({
868
+ extensionPath: ctx.resolvedPath,
869
+ extensionName: ctx.extensionName,
870
+ appDir,
871
+ appName: ctx.safeName,
872
+ arch: ctx.resolvedArch,
873
+ platform: 'darwin',
874
+ });
875
+ }
876
+ return { appPath: appDir, appName: ctx.safeName };
877
+ }
878
+ // ── Windows folder bundle ────────────────────────────────────────────────────
879
+ // TODO: Windows standalone executables compiled with Bun --compile segfault on
880
+ // launch. This is a known Bun bug (not our code), tracked across multiple issues:
881
+ // https://github.com/oven-sh/bun/issues/26862
882
+ // https://github.com/oven-sh/bun/issues/26853
883
+ // https://github.com/oven-sh/bun/issues/17406
884
+ // Crash report: https://bun.report/1.3.9/w_1cf6cdbbEggggCq6l3vCA2AoxG
885
+ // panic(main thread): Segmentation fault at address 0xD14
886
+ // Bun v1.3.9 on windows x86_64, Features: standalone_executable, jsc
887
+ // Until Bun fixes this, Windows app builds will produce a valid folder structure
888
+ // but the extension binary will crash on launch. Possible workaround: ship bun.exe
889
+ // + the JS bundle instead of a compiled standalone exe.
890
+ //
891
+ // Produces a clean folder where the user only sees the launcher exe at root.
892
+ // All WezTerm/extension files live in runtime/ so it's obvious what to click.
893
+ // MyApp/
894
+ // MyApp.exe ← tiny Zig-compiled launcher (hides console, has icon)
895
+ // runtime/
896
+ // wezterm-gui.exe ← from WezTerm release
897
+ // conpty.dll ← required for PTY
898
+ // OpenConsole.exe ← required for PTY
899
+ // libEGL.dll ← ANGLE (WebGpu/OpenGL compat)
900
+ // libGLESv2.dll ← ANGLE
901
+ // my-app.exe ← compiled termcast extension binary
902
+ // config/
903
+ // wezterm.lua ← baked config
904
+ async function buildWin32App(options, resolvedPlatform) {
905
+ const ctx = await resolveBuildContext({ ...options, platform: resolvedPlatform });
906
+ // Only x64 is supported for Windows (WezTerm doesn't ship arm64 Windows builds)
907
+ if (ctx.resolvedArch !== 'x64') {
908
+ throw new Error(`Windows app build only supports x64 architecture. WezTerm does not ship arm64 Windows binaries.`);
909
+ }
910
+ // Download/cache WezTerm Windows files
911
+ const weztermFiles = await downloadWeztermWindows();
912
+ // Assemble folder structure: launcher at root, everything else in runtime/
913
+ const appDir = path.join(ctx.distDir, ctx.safeName);
914
+ if (fs.existsSync(appDir)) {
915
+ fs.rmSync(appDir, { recursive: true, force: true });
916
+ }
917
+ const runtimeDir = path.join(appDir, 'runtime');
918
+ const configDir = path.join(runtimeDir, 'config');
919
+ fs.mkdirSync(configDir, { recursive: true });
920
+ console.log('Assembling Windows app folder...');
921
+ // Copy WezTerm files into runtime/ (wezterm-gui.exe, conpty.dll, OpenConsole.exe, ANGLE DLLs)
922
+ for (const [name, cachedPath] of weztermFiles) {
923
+ fs.copyFileSync(cachedPath, path.join(runtimeDir, name));
924
+ }
925
+ // Copy compiled extension binary into runtime/ (with .exe extension)
926
+ const binaryName = ctx.extensionName + '.exe';
927
+ fs.copyFileSync(ctx.compileResult.outfile, path.join(runtimeDir, binaryName));
928
+ // Bundle custom fonts if a font directory was provided/detected.
929
+ // Copies all .ttf/.otf files into runtime/fonts/ so wezterm's font_dirs can find them.
930
+ if (ctx.fontDirPath) {
931
+ const bundledFontsDir = path.join(configDir, 'fonts');
932
+ fs.mkdirSync(bundledFontsDir, { recursive: true });
933
+ const fontFiles = fs.readdirSync(ctx.fontDirPath).filter((f) => {
934
+ return /\.(ttf|otf|woff2?)$/i.test(f);
935
+ });
936
+ for (const fontFile of fontFiles) {
937
+ fs.copyFileSync(path.join(ctx.fontDirPath, fontFile), path.join(bundledFontsDir, fontFile));
938
+ }
939
+ console.log(`Bundled ${fontFiles.length} font file(s)`);
940
+ }
941
+ // Resolve theme for config background and env var
942
+ const themeName = options.theme || defaultThemeName;
943
+ const themeBackground = getResolvedTheme(themeName).background;
944
+ // Write wezterm.lua config
945
+ fs.writeFileSync(path.join(configDir, 'wezterm.lua'), generateWeztermConfig({ binaryName, font: ctx.fontOptions, platform: 'win32', backgroundColor: themeBackground }));
946
+ // Build the launcher .exe with Zig cross-compilation:
947
+ // 1. Write launcher.c source
948
+ // 2. Convert PNG icon to .ico
949
+ // 3. Write .rc resource file referencing the .ico
950
+ // 4. Cross-compile with: zig cc launcher.c launcher.rc -o MyApp.exe
951
+ // targeting x86_64-windows-gnu with --subsystem windows
952
+ const buildTmpDir = path.join(ctx.distDir, `.win-build-tmp-${process.pid}`);
953
+ fs.mkdirSync(buildTmpDir, { recursive: true });
954
+ // Persist the .ico in a dedicated temp dir (NOT inside appDir, to avoid it being
955
+ // included in the NSIS installer payload during folder walk).
956
+ const icoTmpDir = path.join(ctx.distDir, `.ico-tmp-${process.pid}`);
957
+ fs.mkdirSync(icoTmpDir, { recursive: true });
958
+ const persistedIcoPath = path.join(icoTmpDir, 'app.ico');
959
+ let hasIcon = false;
960
+ try {
961
+ const launcherCPath = path.join(buildTmpDir, 'launcher.c');
962
+ fs.writeFileSync(launcherCPath, generateLauncherC({ themeName }));
963
+ // Convert PNG → ICO → RC → RES for icon embedding.
964
+ // zig rc compiles .rc to .res, then zig cc links .res into the exe.
965
+ const icoPath = path.join(buildTmpDir, 'app.ico');
966
+ const rcPath = path.join(buildTmpDir, 'launcher.rc');
967
+ const resPath = path.join(buildTmpDir, 'launcher.res');
968
+ try {
969
+ await convertToIco({ pngPath: ctx.iconPng, outputPath: icoPath });
970
+ // Also persist for NSIS (the buildTmpDir gets cleaned up)
971
+ fs.copyFileSync(icoPath, persistedIcoPath);
972
+ fs.writeFileSync(rcPath, generateLauncherRc({ icoPath }));
973
+ await execFileAsync('zig', ['rc', rcPath, resPath]);
974
+ hasIcon = true;
975
+ }
976
+ catch (e) {
977
+ console.log(`Warning: could not build icon resource (${e instanceof Error ? e.message : e}), launcher will have no custom icon`);
978
+ }
979
+ const launcherExePath = path.join(appDir, `${ctx.safeName}.exe`);
980
+ // Zig cross-compiles C to Windows x64 from any host platform.
981
+ // -Wl,--subsystem,windows hides the console window on launch (GUI subsystem).
982
+ // -Os optimizes for size, -s strips symbols. Result is ~19-29KB.
983
+ const zigArgs = [
984
+ 'cc',
985
+ launcherCPath,
986
+ ...(hasIcon ? [resPath] : []),
987
+ '-o', launcherExePath,
988
+ '-target', 'x86_64-windows-gnu',
989
+ '-Os',
990
+ '-s',
991
+ '-Wl,--subsystem,windows',
992
+ ];
993
+ console.log('Cross-compiling Windows launcher with Zig...');
994
+ await execFileAsync('zig', zigArgs);
995
+ const launcherSize = fs.statSync(launcherExePath).size;
996
+ console.log(`Launcher: ${ctx.safeName}.exe (${(launcherSize / 1024).toFixed(0)}KB)`);
997
+ }
998
+ finally {
999
+ // Clean up build temp directory
1000
+ fs.rmSync(buildTmpDir, { recursive: true, force: true });
1001
+ }
1002
+ // Clean up intermediate compiled binary + sourcemap
1003
+ fs.rmSync(ctx.compileResult.outfile, { force: true });
1004
+ fs.rmSync(ctx.compileResult.outfile + '.map', { force: true });
1005
+ const appSize = getDirectorySize(appDir);
1006
+ console.log(`\nBuilt: ${appDir} (${(appSize / 1024 / 1024).toFixed(0)}MB)`);
1007
+ // Build NSIS installer by default. Skip with --no-installer.
1008
+ // Always clean up the .ico temp dir afterward, even if NSIS fails.
1009
+ const launcherExeName = `${ctx.safeName}.exe`;
1010
+ let installerPath;
1011
+ try {
1012
+ if (!options.noInstaller) {
1013
+ const result = await buildNsisInstaller({
1014
+ appName: ctx.appName,
1015
+ safeName: ctx.safeName,
1016
+ version: ctx.version,
1017
+ appDir,
1018
+ launcherExeName,
1019
+ icoPath: hasIcon ? persistedIcoPath : undefined,
1020
+ distDir: ctx.distDir,
1021
+ });
1022
+ if (result) {
1023
+ installerPath = result;
1024
+ }
1025
+ }
1026
+ }
1027
+ finally {
1028
+ fs.rmSync(icoTmpDir, { recursive: true, force: true });
1029
+ }
1030
+ if (options.release) {
1031
+ await uploadToRelease({
1032
+ extensionPath: ctx.resolvedPath,
1033
+ extensionName: ctx.extensionName,
1034
+ appDir,
1035
+ appName: ctx.safeName,
1036
+ arch: ctx.resolvedArch,
1037
+ platform: 'win32',
1038
+ installerPath,
1039
+ });
1040
+ }
1041
+ return { appPath: appDir, appName: ctx.safeName, installerPath };
1042
+ }
1043
+ function getDirectorySize(dirPath) {
1044
+ let total = 0;
1045
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1046
+ for (const entry of entries) {
1047
+ const fullPath = path.join(dirPath, entry.name);
1048
+ if (entry.isDirectory()) {
1049
+ total += getDirectorySize(fullPath);
1050
+ }
1051
+ else {
1052
+ total += fs.statSync(fullPath).size;
1053
+ }
1054
+ }
1055
+ return total;
1056
+ }
1057
+ async function uploadToRelease({ extensionPath, extensionName, appDir, appName, arch, platform, installerPath, }) {
1058
+ const distDir = path.dirname(appDir);
1059
+ // Find the latest release whose tag matches the extensionName@ prefix.
1060
+ // This mirrors the install script approach: scan releases for matching assets
1061
+ // rather than trusting --limit 1, which could pick an unrelated release
1062
+ // (e.g. npm-only releases, drafts, or prereleases in repos with mixed tags).
1063
+ console.log(`\nLooking for latest "${extensionName}@*" release...`);
1064
+ let latestTag;
1065
+ try {
1066
+ const { stdout } = await execFileAsync('gh', ['release', 'list', '--limit', '20', '--json', 'tagName', '--jq', '.[].tagName'], { cwd: extensionPath });
1067
+ const tags = stdout.trim().split('\n').filter(Boolean);
1068
+ const prefix = `${extensionName}@`;
1069
+ const matchingTag = tags.find((tag) => {
1070
+ return tag.startsWith(prefix);
1071
+ });
1072
+ latestTag = matchingTag || '';
1073
+ }
1074
+ catch (e) {
1075
+ throw new Error('No GitHub releases found. Run `termcast release` first to create a release.', { cause: e });
1076
+ }
1077
+ if (!latestTag) {
1078
+ throw new Error(`No release found matching "${extensionName}@*". Run \`termcast release\` first.`);
1079
+ }
1080
+ console.log(`Uploading to release ${latestTag}...`);
1081
+ // Zip the app bundle using JSZip (cross-platform).
1082
+ // Platform name in the zip: darwin, windows (not win32).
1083
+ const platformName = platform === 'win32' ? 'windows' : platform;
1084
+ const zipName = `${appName}-${platformName}-${arch}.zip`;
1085
+ const zipPath = path.join(distDir, zipName);
1086
+ fs.rmSync(zipPath, { force: true });
1087
+ const JSZip = (await import('jszip')).default;
1088
+ const zip = new JSZip();
1089
+ const appBasename = path.basename(appDir);
1090
+ // Use UNIX platform for macOS (preserves executable permissions) and DOS for Windows
1091
+ const zipPlatform = platform === 'win32' ? 'DOS' : 'UNIX';
1092
+ const addDirToZip = (dirPath, zipPrefix) => {
1093
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1094
+ for (const entry of entries) {
1095
+ const fullPath = path.join(dirPath, entry.name);
1096
+ const entryZipPath = `${zipPrefix}/${entry.name}`;
1097
+ if (entry.isDirectory()) {
1098
+ addDirToZip(fullPath, entryZipPath);
1099
+ }
1100
+ else {
1101
+ const data = fs.readFileSync(fullPath);
1102
+ const isExecutable = (fs.statSync(fullPath).mode & 0o111) !== 0;
1103
+ zip.file(entryZipPath, data, {
1104
+ unixPermissions: isExecutable ? 0o755 : 0o644,
1105
+ });
1106
+ }
1107
+ }
1108
+ };
1109
+ addDirToZip(appDir, appBasename);
1110
+ const zipBuffer = await zip.generateAsync({
1111
+ type: 'nodebuffer',
1112
+ platform: zipPlatform,
1113
+ });
1114
+ fs.writeFileSync(zipPath, zipBuffer);
1115
+ console.log(`Created ${zipName}`);
1116
+ await execFileAsync('gh', ['release', 'upload', latestTag, zipPath, '--clobber'], {
1117
+ cwd: extensionPath,
1118
+ });
1119
+ console.log(`Uploaded ${zipName} to release ${latestTag}`);
1120
+ fs.unlinkSync(zipPath);
1121
+ // Upload NSIS installer alongside the zip if available
1122
+ if (installerPath && fs.existsSync(installerPath)) {
1123
+ const installerName = path.basename(installerPath);
1124
+ await execFileAsync('gh', ['release', 'upload', latestTag, installerPath, '--clobber'], {
1125
+ cwd: extensionPath,
1126
+ });
1127
+ console.log(`Uploaded ${installerName} to release ${latestTag}`);
1128
+ }
1129
+ }
1130
+ //# sourceMappingURL=app.js.map