termcast 1.3.49 → 1.3.51

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