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/src/app.tsx ADDED
@@ -0,0 +1,1492 @@
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, Cmd+K, and Cmd+Arrows to the TUI instead of WezTerm handling them.
511
+ // Uses SendString with raw kitty CSI sequences because SendKey drops the SUPER modifier
512
+ // — WezTerm treats SUPER as a window-manager modifier and doesn't encode it into
513
+ // kitty protocol sequences. Raw CSI sequences bypass this limitation.
514
+ // Kitty modifier encoding: SUPER = bit 3 (value 8), encoded field = bitmask + 1 = 9.
515
+ // On Windows there is no Cmd key, so no key overrides needed.
516
+ const keysBlock = platform === 'darwin'
517
+ ? `
518
+ -- Forward Cmd keys to the TUI using raw kitty protocol CSI sequences.
519
+ -- SendKey { mods = 'SUPER' } doesn't encode SUPER in kitty protocol, so we
520
+ -- use SendString with the exact CSI bytes that opentui's kitty parser expects.
521
+ -- Kitty modifier 9 = SUPER(8) + 1 (base offset per kitty spec).
522
+ config.keys = {
523
+ { key = 'c', mods = 'SUPER', action = wezterm.action.SendString('\\x1b[99;9u') },
524
+ { key = 'k', mods = 'SUPER', action = wezterm.action.SendString('\\x1b[107;9u') },
525
+ { key = 'LeftArrow', mods = 'SUPER', action = wezterm.action.SendString('\\x1b[1;9D') },
526
+ { key = 'RightArrow', mods = 'SUPER', action = wezterm.action.SendString('\\x1b[1;9C') },
527
+ { key = 'UpArrow', mods = 'SUPER', action = wezterm.action.SendString('\\x1b[1;9A') },
528
+ { key = 'DownArrow', mods = 'SUPER', action = wezterm.action.SendString('\\x1b[1;9B') },
529
+ }
530
+ `
531
+ : ''
532
+
533
+ const quoteDroppedFiles = platform === 'win32' ? 'Windows' : 'Posix'
534
+
535
+ return `\
536
+ local wezterm = require 'wezterm'
537
+ local config = wezterm.config_builder()
538
+
539
+ local config_dir = wezterm.config_dir
540
+ config.default_prog = { ${defaultProg} }
541
+
542
+ -- Window chrome
543
+ config.enable_tab_bar = false
544
+ config.window_decorations = '${platform === 'darwin' ? 'TITLE|RESIZE' : 'RESIZE'}'
545
+ config.window_padding = { left = 0, right = 0, top = 0, bottom = 0 }
546
+ config.window_close_confirmation = 'NeverPrompt'
547
+
548
+ -- Background color matching the configured termcast theme.
549
+ -- The TUI rewrites this file on theme change so WezTerm auto-reloads it,
550
+ -- keeping the window edges/padding in sync with the active theme.
551
+ config.colors = { background = '${backgroundColor}' }
552
+
553
+ -- Default window size: 120x36 is comfortable for TUI apps (WezTerm default is 80x24)
554
+ config.initial_cols = 120
555
+ config.initial_rows = 36
556
+
557
+ -- Snap resize to cell grid
558
+ config.use_resize_increments = true
559
+
560
+ -- Kitty protocols
561
+ config.enable_kitty_graphics = true
562
+ config.enable_kitty_keyboard = true
563
+
564
+ -- Memory optimization: TUI controls its own scrolling
565
+ config.scrollback_lines = 0
566
+
567
+ -- Reduce font rasterizer memory (no ligatures needed in TUI)
568
+ config.harfbuzz_features = { 'calt=0', 'clig=0', 'liga=0' }
569
+
570
+
571
+
572
+ ${generateFontConfig(font ?? {})}
573
+
574
+ -- Rendering
575
+ config.front_end = 'WebGpu'
576
+ config.webgpu_power_preference = 'HighPerformance'
577
+ config.max_fps = 60
578
+ config.freetype_render_target = 'HorizontalLcd'
579
+ config.freetype_load_target = 'Light'
580
+
581
+ ${keysBlock}
582
+ config.quote_dropped_files = '${quoteDroppedFiles}'
583
+
584
+ return config
585
+ `
586
+ }
587
+
588
+ function generateLaunchScript({ weztermBinaryName, themeName }: { weztermBinaryName: string; themeName: string }): string {
589
+ return `\
590
+ #!/bin/bash
591
+ DIR="$(cd "$(dirname "$0")" && pwd)"
592
+ export TERMCAST_WEZTERM_CONFIG="$DIR/../Resources/wezterm.lua"
593
+ export TERMCAST_DEFAULT_THEME="${themeName}"
594
+ export TERMCAST_APP_MODE=1
595
+ exec "$DIR/${weztermBinaryName}" --config-file "$TERMCAST_WEZTERM_CONFIG"
596
+ `
597
+ }
598
+
599
+ // Generate an NSIS installer script (.nsi) for a Windows app folder.
600
+ // NSIS (Nullsoft Scriptable Install System) cross-compiles on macOS via `makensis`.
601
+ // The installer:
602
+ // - Copies all files from the app folder to Program Files
603
+ // - Creates Start Menu shortcuts (launcher exe + uninstaller)
604
+ // - Creates a Desktop shortcut for the launcher
605
+ // - Registers uninstaller in Windows Add/Remove Programs
606
+ // - Embeds the app icon in installer/uninstaller/shortcuts
607
+ // RequestExecutionLevel admin is required for Program Files write access.
608
+ function generateNsisScript({
609
+ appName,
610
+ safeName,
611
+ version,
612
+ appDir,
613
+ launcherExeName,
614
+ icoPath,
615
+ outFile,
616
+ }: {
617
+ appName: string
618
+ safeName: string
619
+ version: string
620
+ appDir: string
621
+ launcherExeName: string
622
+ icoPath?: string
623
+ /** Absolute path for the output installer exe. */
624
+ outFile: string
625
+ }): string {
626
+ // NSIS !define MUI_ICON uses build-host paths (POSIX on macOS/Linux).
627
+ // Do NOT convert to backslashes — makensis reads source files using host OS paths.
628
+ const iconDirective = icoPath
629
+ ? `!define MUI_ICON "${icoPath}"\n!define MUI_UNICON "${icoPath}"`
630
+ : ''
631
+
632
+ // Escape special NSIS characters in display strings.
633
+ // NSIS treats $ as variable prefix and " as string delimiter.
634
+ const escapeNsis = (s: string): string => {
635
+ return s.replace(/\$/g, '$$$$').replace(/"/g, '$\\"')
636
+ }
637
+ const safeAppName = escapeNsis(appName)
638
+ const safeSafeName = escapeNsis(safeName)
639
+
640
+ // Collect all files from the app folder to generate File commands.
641
+ // We recursively walk the directory and emit SetOutPath + File for each subdir.
642
+ // File paths in the install section are build-host paths (POSIX) — makensis
643
+ // reads them on the host OS. Install-target paths ($INSTDIR\...) use backslashes.
644
+ const installFileCommands: string[] = []
645
+ const uninstallFileCommands: string[] = []
646
+ const uninstallDirCommands: string[] = []
647
+
648
+ const walkDir = (dir: string, relPrefix: string) => {
649
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
650
+ const files = entries.filter((e) => !e.isDirectory())
651
+ const dirs = entries.filter((e) => e.isDirectory())
652
+
653
+ if (files.length > 0) {
654
+ installFileCommands.push(` SetOutPath "$INSTDIR${relPrefix ? '\\' + relPrefix : ''}"`)
655
+ for (const file of files) {
656
+ // Build-host path: keep POSIX slashes for makensis to read the file
657
+ const fullPath = path.join(dir, file.name)
658
+ installFileCommands.push(` File "${fullPath}"`)
659
+ uninstallFileCommands.push(` Delete "$INSTDIR${relPrefix ? '\\' + relPrefix : ''}\\${file.name}"`)
660
+ }
661
+ }
662
+
663
+ for (const subdir of dirs) {
664
+ const newRel = relPrefix ? `${relPrefix}\\${subdir.name}` : subdir.name
665
+ walkDir(path.join(dir, subdir.name), newRel)
666
+ }
667
+
668
+ // Post-order: push AFTER recursing into children, so children dirs
669
+ // appear earlier in the array and get removed first by RMDir.
670
+ if (relPrefix) {
671
+ uninstallDirCommands.push(` RMDir "$INSTDIR\\${relPrefix}"`)
672
+ }
673
+ }
674
+
675
+ walkDir(appDir, '')
676
+
677
+ return `\
678
+ ; NSIS installer script for ${safeAppName}
679
+ ; Generated by termcast app build. Do not edit manually.
680
+ Unicode True
681
+ !include "MUI2.nsh"
682
+
683
+ Name "${safeAppName}"
684
+ OutFile "${outFile}"
685
+ InstallDir "$PROGRAMFILES64\\${safeAppName}"
686
+ InstallDirRegKey HKLM "Software\\${safeSafeName}" "InstallDir"
687
+ RequestExecutionLevel admin
688
+
689
+ ${iconDirective}
690
+
691
+ !define MUI_ABORTWARNING
692
+
693
+ ; Pages
694
+ !insertmacro MUI_PAGE_DIRECTORY
695
+ !insertmacro MUI_PAGE_INSTFILES
696
+
697
+ !insertmacro MUI_UNPAGE_CONFIRM
698
+ !insertmacro MUI_UNPAGE_INSTFILES
699
+
700
+ !insertmacro MUI_LANGUAGE "English"
701
+
702
+ Section "Install"
703
+ SetShellVarContext all
704
+
705
+ ${installFileCommands.join('\n')}
706
+
707
+ ; Store install dir in registry
708
+ WriteRegStr HKLM "Software\\${safeSafeName}" "InstallDir" "$INSTDIR"
709
+
710
+ ; Create uninstaller
711
+ WriteUninstaller "$INSTDIR\\Uninstall.exe"
712
+
713
+ ; Add/Remove Programs entry
714
+ WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}" "DisplayName" "${safeAppName}"
715
+ WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}" "UninstallString" '"$INSTDIR\\Uninstall.exe"'
716
+ WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}" "DisplayVersion" "${version}"
717
+ WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}" "Publisher" "termcast"
718
+ ${icoPath ? ` WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}" "DisplayIcon" "$INSTDIR\\${launcherExeName}"` : ''}
719
+ WriteRegDWORD HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}" "NoModify" 1
720
+ WriteRegDWORD HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}" "NoRepair" 1
721
+
722
+ ; Start Menu shortcuts
723
+ CreateDirectory "$SMPROGRAMS\\${safeAppName}"
724
+ CreateShortcut "$SMPROGRAMS\\${safeAppName}\\${safeAppName}.lnk" "$INSTDIR\\${launcherExeName}"
725
+ CreateShortcut "$SMPROGRAMS\\${safeAppName}\\Uninstall ${safeAppName}.lnk" "$INSTDIR\\Uninstall.exe"
726
+
727
+ ; Desktop shortcut
728
+ CreateShortcut "$DESKTOP\\${safeAppName}.lnk" "$INSTDIR\\${launcherExeName}"
729
+ SectionEnd
730
+
731
+ Section "Uninstall"
732
+ SetShellVarContext all
733
+
734
+ ${uninstallFileCommands.join('\n')}
735
+ Delete "$INSTDIR\\Uninstall.exe"
736
+
737
+ ${uninstallDirCommands.join('\n')}
738
+ RMDir "$INSTDIR"
739
+
740
+ ; Remove Start Menu shortcuts
741
+ Delete "$SMPROGRAMS\\${safeAppName}\\${safeAppName}.lnk"
742
+ Delete "$SMPROGRAMS\\${safeAppName}\\Uninstall ${safeAppName}.lnk"
743
+ RMDir "$SMPROGRAMS\\${safeAppName}"
744
+
745
+ ; Remove Desktop shortcut
746
+ Delete "$DESKTOP\\${safeAppName}.lnk"
747
+
748
+ ; Remove registry keys
749
+ DeleteRegKey HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${safeSafeName}"
750
+ DeleteRegKey HKLM "Software\\${safeSafeName}"
751
+ SectionEnd
752
+ `
753
+ }
754
+
755
+ // Build an NSIS installer (.exe) from the assembled app folder.
756
+ // Writes a temp .nsi script, runs makensis to compile it, and returns the
757
+ // path to the resulting Setup exe. Requires `makensis` in PATH (brew install nsis).
758
+ async function buildNsisInstaller({
759
+ appName,
760
+ safeName,
761
+ version,
762
+ appDir,
763
+ launcherExeName,
764
+ icoPath,
765
+ distDir,
766
+ }: {
767
+ appName: string
768
+ safeName: string
769
+ version: string
770
+ appDir: string
771
+ launcherExeName: string
772
+ icoPath?: string
773
+ distDir: string
774
+ }): Promise<string> {
775
+ const installerExeName = `${safeName}-Setup-x64.exe`
776
+ const installerPath = path.join(distDir, installerExeName)
777
+
778
+ const nsiScript = generateNsisScript({
779
+ appName,
780
+ safeName,
781
+ version,
782
+ appDir,
783
+ launcherExeName,
784
+ icoPath,
785
+ outFile: installerPath,
786
+ })
787
+
788
+ const buildTmpDir = path.join(distDir, `.nsis-tmp-${process.pid}`)
789
+ fs.mkdirSync(buildTmpDir, { recursive: true })
790
+
791
+ const nsiPath = path.join(buildTmpDir, 'installer.nsi')
792
+ fs.writeFileSync(nsiPath, nsiScript)
793
+
794
+ console.log('Building NSIS installer...')
795
+ try {
796
+ await execFileAsync('makensis', [nsiPath])
797
+ } catch (e) {
798
+ // makensis might not be installed — warn but don't fail the build
799
+ const msg = e instanceof Error ? e.message : String(e)
800
+ if (msg.includes('ENOENT') || msg.includes('not found')) {
801
+ console.log('Warning: makensis not found. Install NSIS to generate Windows installers:')
802
+ console.log(' macOS: brew install nsis')
803
+ console.log(' Linux: apt install nsis')
804
+ return ''
805
+ }
806
+ throw new Error(`NSIS installer build failed`, { cause: e })
807
+ } finally {
808
+ fs.rmSync(buildTmpDir, { recursive: true, force: true })
809
+ }
810
+
811
+ const installerSize = fs.statSync(installerPath).size
812
+ console.log(`Installer: ${installerExeName} (${(installerSize / 1024 / 1024).toFixed(1)}MB)`)
813
+ return installerPath
814
+ }
815
+
816
+ function escapeXml(str: string): string {
817
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
818
+ }
819
+
820
+ function generateInfoPlist({
821
+ appName,
822
+ bundleId,
823
+ version,
824
+ iconFile = 'app.icns',
825
+ }: {
826
+ appName: string
827
+ bundleId: string
828
+ version: string
829
+ iconFile?: string
830
+ }): string {
831
+ const safeBundleId = escapeXml(bundleId)
832
+ const safeAppName = escapeXml(appName)
833
+ const safeVersion = escapeXml(version)
834
+ return `\
835
+ <?xml version="1.0" encoding="UTF-8"?>
836
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
837
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
838
+ <plist version="1.0">
839
+ <dict>
840
+ <key>CFBundleExecutable</key>
841
+ <string>launch</string>
842
+ <key>CFBundleIdentifier</key>
843
+ <string>${safeBundleId}</string>
844
+ <key>CFBundleName</key>
845
+ <string>${safeAppName}</string>
846
+ <key>CFBundleDisplayName</key>
847
+ <string>${safeAppName}</string>
848
+ <key>CFBundleIconFile</key>
849
+ <string>${iconFile}</string>
850
+ <key>CFBundlePackageType</key>
851
+ <string>APPL</string>
852
+ <key>CFBundleShortVersionString</key>
853
+ <string>${safeVersion}</string>
854
+ <key>CFBundleVersion</key>
855
+ <string>1</string>
856
+ <key>NSHighResolutionCapable</key>
857
+ <true/>
858
+ <key>NSSupportsAutomaticGraphicsSwitching</key>
859
+ <true/>
860
+ <key>NSRequiresAquaSystemAppearance</key>
861
+ <string>NO</string>
862
+ </dict>
863
+ </plist>
864
+ `
865
+ }
866
+
867
+ export interface BuildAppOptions {
868
+ extensionPath: string
869
+ name?: string
870
+ icon?: string
871
+ bundleId?: string
872
+ release?: boolean
873
+ entry?: string
874
+ /** Target OS: 'darwin' | 'linux' | 'win32'. */
875
+ platform?: CompileTarget['os']
876
+ /** Target arch: 'arm64' | 'x64'. Defaults to current machine arch. */
877
+ arch?: CompileTarget['arch']
878
+ /** Skip NSIS installer generation on Windows (default: false, installer is built). */
879
+ noInstaller?: boolean
880
+ /** Font family name to use (e.g. 'Inter Mono'). Default: WezTerm built-in JetBrains Mono. */
881
+ fontFamily?: string
882
+ /** Directory of .ttf/.otf font files to bundle in the app. Enables font_dirs in wezterm config. */
883
+ fontDir?: string
884
+ /** Font size in points. Default: 14 */
885
+ fontSize?: number
886
+ /** Line height multiplier. 1.0 = tight, 1.2 = comfortable. Default: 1.2 */
887
+ lineHeight?: number
888
+ /** Default theme name (e.g. 'nerv', 'catppuccin-mocha'). Default: 'nerv' */
889
+ theme?: string
890
+ }
891
+
892
+ export interface BuildAppResult {
893
+ appPath: string
894
+ appName: string
895
+ /** Path to NSIS installer exe (Windows only, absent if --no-installer or makensis missing). */
896
+ installerPath?: string
897
+ }
898
+
899
+ // Shared setup for all platforms: resolve paths, read package.json, compile extension.
900
+ interface ResolvedBuildContext {
901
+ resolvedPath: string
902
+ extensionName: string
903
+ appName: string
904
+ safeName: string
905
+ version: string
906
+ resolvedArch: CompileTarget['arch']
907
+ target: CompileTarget
908
+ distDir: string
909
+ compileResult: { outfile: string }
910
+ iconPng: string
911
+ packageJson: Record<string, string>
912
+ resolvedBundleId: string
913
+ fontOptions: WeztermFontOptions
914
+ /** Resolved absolute path to font directory, if provided and exists. */
915
+ fontDirPath?: string
916
+ }
917
+
918
+ async function resolveBuildContext({
919
+ extensionPath,
920
+ name,
921
+ icon,
922
+ bundleId,
923
+ entry,
924
+ platform,
925
+ arch,
926
+ fontFamily,
927
+ fontDir,
928
+ fontSize,
929
+ lineHeight,
930
+ }: BuildAppOptions & { platform: CompileTarget['os'] }): Promise<ResolvedBuildContext> {
931
+ const resolvedPath = path.resolve(extensionPath)
932
+
933
+ if (!fs.existsSync(resolvedPath)) {
934
+ throw new Error(`Extension path does not exist: ${resolvedPath}`)
935
+ }
936
+
937
+ const packageJsonPath = path.join(resolvedPath, 'package.json')
938
+ if (!fs.existsSync(packageJsonPath)) {
939
+ throw new Error(`No package.json found at: ${packageJsonPath}`)
940
+ }
941
+
942
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'))
943
+ const rawExtensionName: string = packageJson.name
944
+ if (!rawExtensionName) {
945
+ throw new Error('package.json must have a "name" field')
946
+ }
947
+ // Strip npm scope prefix (@scope/name -> name) for filesystem and lua paths
948
+ const extensionName = rawExtensionName.replace(/^@[^/]+\//, '')
949
+
950
+ const appName = name || packageJson.title || extensionName
951
+ const resolvedBundleId =
952
+ bundleId || `com.termcast.${extensionName.replace(/[^a-zA-Z0-9.-]/g, '-')}`
953
+ const version: string = packageJson.version || '1.0.0'
954
+ const resolvedArch: CompileTarget['arch'] = arch || (process.arch === 'arm64' ? 'arm64' : 'x64')
955
+ // For Windows x64, always use baseline (no AVX2 requirement) so the app runs on
956
+ // all x64 CPUs. The default bun-windows-x64 target requires AVX2 (Haswell 2013+)
957
+ // which causes silent crashes on older machines.
958
+ const avx2 = platform === 'win32' ? false as const : undefined
959
+ const target: CompileTarget = { os: platform, arch: resolvedArch, avx2 }
960
+
961
+ console.log(`Building app "${appName}" for ${platform}-${resolvedArch}...`)
962
+
963
+ // Compile the termcast extension
964
+ console.log(`Compiling termcast extension...`)
965
+ const distDir = path.join(resolvedPath, 'dist')
966
+ fs.mkdirSync(distDir, { recursive: true })
967
+ const distGitignore = path.join(distDir, '.gitignore')
968
+ if (!fs.existsSync(distGitignore)) {
969
+ fs.writeFileSync(distGitignore, '*\n')
970
+ }
971
+ // Bun.build with compile appends .exe for Windows targets, so include it in the path
972
+ const exeSuffix = platform === 'win32' ? '.exe' : ''
973
+ const compiledBinaryPath = path.join(distDir, `${extensionName}-app-binary-${resolvedArch}${exeSuffix}`)
974
+
975
+ const compileResult = await compileExtension({
976
+ extensionPath: resolvedPath,
977
+ outfile: compiledBinaryPath,
978
+ minify: true,
979
+ target,
980
+ entry,
981
+ })
982
+
983
+ const iconPng = resolveIcon({
984
+ extensionPath: resolvedPath,
985
+ iconOverride: icon,
986
+ packageJson,
987
+ })
988
+
989
+ // Replace slashes, spaces, and other problematic chars with hyphens.
990
+ // Spaces in filenames break PowerShell (WezTerm's default shell on Windows)
991
+ // because PowerShell splits unquoted paths on whitespace.
992
+ const safeName = appName.replace(/[/\\\s]+/g, '-').replace(/^-+|-+$/g, '')
993
+
994
+ // Resolve font directory: --font-dir flag, or fonts/ in extension root.
995
+ // --font-dir is resolved relative to the extension path (not cwd) for consistency.
996
+ const fontDirPath = (() => {
997
+ if (fontDir) {
998
+ const resolved = path.isAbsolute(fontDir)
999
+ ? fontDir
1000
+ : path.resolve(resolvedPath, fontDir)
1001
+ if (!fs.existsSync(resolved)) {
1002
+ throw new Error(`Font directory not found: ${resolved}`)
1003
+ }
1004
+ if (!fs.statSync(resolved).isDirectory()) {
1005
+ throw new Error(`--font-dir must be a directory, not a file: ${resolved}`)
1006
+ }
1007
+ return resolved
1008
+ }
1009
+ // Auto-detect fonts/ directory in extension root
1010
+ const defaultFontDir = path.join(resolvedPath, 'fonts')
1011
+ if (fs.existsSync(defaultFontDir) && fs.statSync(defaultFontDir).isDirectory()) {
1012
+ return defaultFontDir
1013
+ }
1014
+ return undefined
1015
+ })()
1016
+
1017
+ const fontOptions: WeztermFontOptions = {
1018
+ fontFamily,
1019
+ fontSize,
1020
+ lineHeight,
1021
+ hasBundledFonts: !!fontDirPath,
1022
+ }
1023
+
1024
+ return {
1025
+ resolvedPath,
1026
+ extensionName,
1027
+ appName,
1028
+ safeName,
1029
+ version,
1030
+ resolvedArch,
1031
+ target,
1032
+ distDir,
1033
+ compileResult,
1034
+ iconPng,
1035
+ packageJson,
1036
+ resolvedBundleId,
1037
+ fontOptions,
1038
+ fontDirPath,
1039
+ }
1040
+ }
1041
+
1042
+ export async function buildApp(options: BuildAppOptions): Promise<BuildAppResult> {
1043
+ const resolvedPlatform = options.platform || (process.platform as CompileTarget['os'])
1044
+
1045
+ if (resolvedPlatform === 'darwin') {
1046
+ return buildDarwinApp(options, resolvedPlatform)
1047
+ }
1048
+ if (resolvedPlatform === 'win32') {
1049
+ return buildWin32App(options, resolvedPlatform)
1050
+ }
1051
+
1052
+ throw new Error(
1053
+ `Platform "${resolvedPlatform}" is not supported yet. Supported: darwin, win32.`,
1054
+ )
1055
+ }
1056
+
1057
+ // ── macOS .app bundle ────────────────────────────────────────────────────────
1058
+
1059
+ async function buildDarwinApp(
1060
+ options: BuildAppOptions,
1061
+ resolvedPlatform: 'darwin',
1062
+ ): Promise<BuildAppResult> {
1063
+ const ctx = await resolveBuildContext({ ...options, platform: resolvedPlatform })
1064
+
1065
+ // Download/cache WezTerm and thin to target arch (~65MB instead of ~130MB)
1066
+ const weztermBinary = await getWeztermBinary({ arch: ctx.resolvedArch })
1067
+
1068
+ // Assemble .app bundle
1069
+ const archSuffix = ctx.resolvedArch === 'x64' ? 'x86_64' : 'arm64'
1070
+ const appDir = path.join(ctx.distDir, `${ctx.safeName}-${archSuffix}.app`)
1071
+
1072
+ if (fs.existsSync(appDir)) {
1073
+ fs.rmSync(appDir, { recursive: true, force: true })
1074
+ }
1075
+
1076
+ const macosDir = path.join(appDir, 'Contents', 'MacOS')
1077
+ const resourcesDir = path.join(appDir, 'Contents', 'Resources')
1078
+ fs.mkdirSync(macosDir, { recursive: true })
1079
+ fs.mkdirSync(resourcesDir, { recursive: true })
1080
+
1081
+ console.log('Assembling .app bundle...')
1082
+
1083
+ // Copy wezterm-gui binary renamed to the app name so macOS Activity Monitor
1084
+ // shows the app name instead of "wezterm-gui" (exec replaces the process image,
1085
+ // and the OS derives the display name from the binary filename).
1086
+ const weztermBinaryName = ctx.safeName
1087
+ fs.copyFileSync(weztermBinary, path.join(macosDir, weztermBinaryName))
1088
+ fs.chmodSync(path.join(macosDir, weztermBinaryName), 0o755)
1089
+
1090
+ // Copy compiled extension binary
1091
+ const binaryName = ctx.extensionName
1092
+ fs.copyFileSync(ctx.compileResult.outfile, path.join(resourcesDir, binaryName))
1093
+ fs.chmodSync(path.join(resourcesDir, binaryName), 0o755)
1094
+
1095
+ // Bundle custom fonts if a font directory was provided/detected.
1096
+ // Copies all .ttf/.otf files into Resources/fonts/ so wezterm's font_dirs can find them.
1097
+ if (ctx.fontDirPath) {
1098
+ const bundledFontsDir = path.join(resourcesDir, 'fonts')
1099
+ fs.mkdirSync(bundledFontsDir, { recursive: true })
1100
+ const fontFiles = fs.readdirSync(ctx.fontDirPath).filter((f) => {
1101
+ return /\.(ttf|otf|woff2?)$/i.test(f)
1102
+ })
1103
+ for (const fontFile of fontFiles) {
1104
+ fs.copyFileSync(
1105
+ path.join(ctx.fontDirPath, fontFile),
1106
+ path.join(bundledFontsDir, fontFile),
1107
+ )
1108
+ }
1109
+ console.log(`Bundled ${fontFiles.length} font file(s)`)
1110
+ }
1111
+
1112
+ // Resolve theme for config background and env var
1113
+ const themeName = options.theme || defaultThemeName
1114
+ const themeBackground = getResolvedTheme(themeName).background
1115
+
1116
+ // Write config, launch script
1117
+ fs.writeFileSync(
1118
+ path.join(resourcesDir, 'wezterm.lua'),
1119
+ generateWeztermConfig({ binaryName, font: ctx.fontOptions, platform: 'darwin', backgroundColor: themeBackground }),
1120
+ )
1121
+
1122
+ const launchPath = path.join(macosDir, 'launch')
1123
+ fs.writeFileSync(launchPath, generateLaunchScript({ weztermBinaryName, themeName }))
1124
+ fs.chmodSync(launchPath, 0o755)
1125
+
1126
+ // Convert and write icon, then write Info.plist with the correct icon filename
1127
+ let iconFile = 'app.icns'
1128
+ const icnsPath = path.join(resourcesDir, 'app.icns')
1129
+ try {
1130
+ await convertToIcns({ pngPath: ctx.iconPng, outputPath: icnsPath })
1131
+ } catch (e) {
1132
+ console.log(`Warning: could not convert icon to .icns (${e instanceof Error ? e.message : e}), copying PNG as fallback`)
1133
+ iconFile = 'app.png'
1134
+ fs.copyFileSync(ctx.iconPng, path.join(resourcesDir, iconFile))
1135
+ }
1136
+
1137
+ fs.writeFileSync(
1138
+ path.join(appDir, 'Contents', 'Info.plist'),
1139
+ generateInfoPlist({ appName: ctx.safeName, bundleId: ctx.resolvedBundleId, version: ctx.version, iconFile }),
1140
+ )
1141
+
1142
+ // Clean up intermediate compiled binary + sourcemap
1143
+ fs.rmSync(ctx.compileResult.outfile, { force: true })
1144
+ fs.rmSync(ctx.compileResult.outfile + '.map', { force: true })
1145
+
1146
+ // Ad-hoc sign — only on macOS where codesign is available.
1147
+ // The wezterm-gui binary's original signature is invalid in the new bundle.
1148
+ if (process.platform === 'darwin') {
1149
+ console.log('Ad-hoc signing...')
1150
+ await execFileAsync('codesign', ['--force', '--deep', '-s', '-', appDir])
1151
+ } else {
1152
+ console.log('Skipping ad-hoc signing (not on macOS). Sign manually before distributing.')
1153
+ }
1154
+
1155
+ const appSize = getDirectorySize(appDir)
1156
+ console.log(`\nBuilt: ${appDir} (${(appSize / 1024 / 1024).toFixed(0)}MB)`)
1157
+
1158
+ if (options.release) {
1159
+ await uploadToRelease({
1160
+ extensionPath: ctx.resolvedPath,
1161
+ extensionName: ctx.extensionName,
1162
+ appDir,
1163
+ appName: ctx.safeName,
1164
+ arch: ctx.resolvedArch,
1165
+ platform: 'darwin',
1166
+ })
1167
+ }
1168
+
1169
+ return { appPath: appDir, appName: ctx.safeName }
1170
+ }
1171
+
1172
+ // ── Windows folder bundle ────────────────────────────────────────────────────
1173
+ // TODO: Windows standalone executables compiled with Bun --compile segfault on
1174
+ // launch. This is a known Bun bug (not our code), tracked across multiple issues:
1175
+ // https://github.com/oven-sh/bun/issues/26862
1176
+ // https://github.com/oven-sh/bun/issues/26853
1177
+ // https://github.com/oven-sh/bun/issues/17406
1178
+ // Crash report: https://bun.report/1.3.9/w_1cf6cdbbEggggCq6l3vCA2AoxG
1179
+ // panic(main thread): Segmentation fault at address 0xD14
1180
+ // Bun v1.3.9 on windows x86_64, Features: standalone_executable, jsc
1181
+ // Until Bun fixes this, Windows app builds will produce a valid folder structure
1182
+ // but the extension binary will crash on launch. Possible workaround: ship bun.exe
1183
+ // + the JS bundle instead of a compiled standalone exe.
1184
+ //
1185
+ // Produces a clean folder where the user only sees the launcher exe at root.
1186
+ // All WezTerm/extension files live in runtime/ so it's obvious what to click.
1187
+ // MyApp/
1188
+ // MyApp.exe ← tiny Zig-compiled launcher (hides console, has icon)
1189
+ // runtime/
1190
+ // wezterm-gui.exe ← from WezTerm release
1191
+ // conpty.dll ← required for PTY
1192
+ // OpenConsole.exe ← required for PTY
1193
+ // libEGL.dll ← ANGLE (WebGpu/OpenGL compat)
1194
+ // libGLESv2.dll ← ANGLE
1195
+ // my-app.exe ← compiled termcast extension binary
1196
+ // config/
1197
+ // wezterm.lua ← baked config
1198
+
1199
+ async function buildWin32App(
1200
+ options: BuildAppOptions,
1201
+ resolvedPlatform: 'win32',
1202
+ ): Promise<BuildAppResult> {
1203
+ const ctx = await resolveBuildContext({ ...options, platform: resolvedPlatform })
1204
+
1205
+ // Only x64 is supported for Windows (WezTerm doesn't ship arm64 Windows builds)
1206
+ if (ctx.resolvedArch !== 'x64') {
1207
+ throw new Error(
1208
+ `Windows app build only supports x64 architecture. WezTerm does not ship arm64 Windows binaries.`,
1209
+ )
1210
+ }
1211
+
1212
+ // Download/cache WezTerm Windows files
1213
+ const weztermFiles = await downloadWeztermWindows()
1214
+
1215
+ // Assemble folder structure: launcher at root, everything else in runtime/
1216
+ const appDir = path.join(ctx.distDir, ctx.safeName)
1217
+
1218
+ if (fs.existsSync(appDir)) {
1219
+ fs.rmSync(appDir, { recursive: true, force: true })
1220
+ }
1221
+
1222
+ const runtimeDir = path.join(appDir, 'runtime')
1223
+ const configDir = path.join(runtimeDir, 'config')
1224
+ fs.mkdirSync(configDir, { recursive: true })
1225
+
1226
+ console.log('Assembling Windows app folder...')
1227
+
1228
+ // Copy WezTerm files into runtime/ (wezterm-gui.exe, conpty.dll, OpenConsole.exe, ANGLE DLLs)
1229
+ for (const [name, cachedPath] of weztermFiles) {
1230
+ fs.copyFileSync(cachedPath, path.join(runtimeDir, name))
1231
+ }
1232
+
1233
+ // Copy compiled extension binary into runtime/ (with .exe extension)
1234
+ const binaryName = ctx.extensionName + '.exe'
1235
+ fs.copyFileSync(ctx.compileResult.outfile, path.join(runtimeDir, binaryName))
1236
+
1237
+ // Bundle custom fonts if a font directory was provided/detected.
1238
+ // Copies all .ttf/.otf files into runtime/fonts/ so wezterm's font_dirs can find them.
1239
+ if (ctx.fontDirPath) {
1240
+ const bundledFontsDir = path.join(configDir, 'fonts')
1241
+ fs.mkdirSync(bundledFontsDir, { recursive: true })
1242
+ const fontFiles = fs.readdirSync(ctx.fontDirPath).filter((f) => {
1243
+ return /\.(ttf|otf|woff2?)$/i.test(f)
1244
+ })
1245
+ for (const fontFile of fontFiles) {
1246
+ fs.copyFileSync(
1247
+ path.join(ctx.fontDirPath, fontFile),
1248
+ path.join(bundledFontsDir, fontFile),
1249
+ )
1250
+ }
1251
+ console.log(`Bundled ${fontFiles.length} font file(s)`)
1252
+ }
1253
+
1254
+ // Resolve theme for config background and env var
1255
+ const themeName = options.theme || defaultThemeName
1256
+ const themeBackground = getResolvedTheme(themeName).background
1257
+
1258
+ // Write wezterm.lua config
1259
+ fs.writeFileSync(
1260
+ path.join(configDir, 'wezterm.lua'),
1261
+ generateWeztermConfig({ binaryName, font: ctx.fontOptions, platform: 'win32', backgroundColor: themeBackground }),
1262
+ )
1263
+
1264
+ // Build the launcher .exe with Zig cross-compilation:
1265
+ // 1. Write launcher.c source
1266
+ // 2. Convert PNG icon to .ico
1267
+ // 3. Write .rc resource file referencing the .ico
1268
+ // 4. Cross-compile with: zig cc launcher.c launcher.rc -o MyApp.exe
1269
+ // targeting x86_64-windows-gnu with --subsystem windows
1270
+ const buildTmpDir = path.join(ctx.distDir, `.win-build-tmp-${process.pid}`)
1271
+ fs.mkdirSync(buildTmpDir, { recursive: true })
1272
+
1273
+ // Persist the .ico in a dedicated temp dir (NOT inside appDir, to avoid it being
1274
+ // included in the NSIS installer payload during folder walk).
1275
+ const icoTmpDir = path.join(ctx.distDir, `.ico-tmp-${process.pid}`)
1276
+ fs.mkdirSync(icoTmpDir, { recursive: true })
1277
+ const persistedIcoPath = path.join(icoTmpDir, 'app.ico')
1278
+ let hasIcon = false
1279
+
1280
+ try {
1281
+ const launcherCPath = path.join(buildTmpDir, 'launcher.c')
1282
+ fs.writeFileSync(launcherCPath, generateLauncherC({ themeName }))
1283
+
1284
+ // Convert PNG → ICO → RC → RES for icon embedding.
1285
+ // zig rc compiles .rc to .res, then zig cc links .res into the exe.
1286
+ const icoPath = path.join(buildTmpDir, 'app.ico')
1287
+ const rcPath = path.join(buildTmpDir, 'launcher.rc')
1288
+ const resPath = path.join(buildTmpDir, 'launcher.res')
1289
+
1290
+ try {
1291
+ await convertToIco({ pngPath: ctx.iconPng, outputPath: icoPath })
1292
+ // Also persist for NSIS (the buildTmpDir gets cleaned up)
1293
+ fs.copyFileSync(icoPath, persistedIcoPath)
1294
+ fs.writeFileSync(rcPath, generateLauncherRc({ icoPath }))
1295
+ await execFileAsync('zig', ['rc', rcPath, resPath])
1296
+ hasIcon = true
1297
+ } catch (e) {
1298
+ console.log(`Warning: could not build icon resource (${e instanceof Error ? e.message : e}), launcher will have no custom icon`)
1299
+ }
1300
+
1301
+ const launcherExePath = path.join(appDir, `${ctx.safeName}.exe`)
1302
+
1303
+ // Zig cross-compiles C to Windows x64 from any host platform.
1304
+ // -Wl,--subsystem,windows hides the console window on launch (GUI subsystem).
1305
+ // -Os optimizes for size, -s strips symbols. Result is ~19-29KB.
1306
+ const zigArgs = [
1307
+ 'cc',
1308
+ launcherCPath,
1309
+ ...(hasIcon ? [resPath] : []),
1310
+ '-o', launcherExePath,
1311
+ '-target', 'x86_64-windows-gnu',
1312
+ '-Os',
1313
+ '-s',
1314
+ '-Wl,--subsystem,windows',
1315
+ ]
1316
+
1317
+ console.log('Cross-compiling Windows launcher with Zig...')
1318
+ await execFileAsync('zig', zigArgs)
1319
+
1320
+ const launcherSize = fs.statSync(launcherExePath).size
1321
+ console.log(`Launcher: ${ctx.safeName}.exe (${(launcherSize / 1024).toFixed(0)}KB)`)
1322
+ } finally {
1323
+ // Clean up build temp directory
1324
+ fs.rmSync(buildTmpDir, { recursive: true, force: true })
1325
+ }
1326
+
1327
+ // Clean up intermediate compiled binary + sourcemap
1328
+ fs.rmSync(ctx.compileResult.outfile, { force: true })
1329
+ fs.rmSync(ctx.compileResult.outfile + '.map', { force: true })
1330
+
1331
+ const appSize = getDirectorySize(appDir)
1332
+ console.log(`\nBuilt: ${appDir} (${(appSize / 1024 / 1024).toFixed(0)}MB)`)
1333
+
1334
+ // Build NSIS installer by default. Skip with --no-installer.
1335
+ // Always clean up the .ico temp dir afterward, even if NSIS fails.
1336
+ const launcherExeName = `${ctx.safeName}.exe`
1337
+ let installerPath: string | undefined
1338
+ try {
1339
+ if (!options.noInstaller) {
1340
+ const result = await buildNsisInstaller({
1341
+ appName: ctx.appName,
1342
+ safeName: ctx.safeName,
1343
+ version: ctx.version,
1344
+ appDir,
1345
+ launcherExeName,
1346
+ icoPath: hasIcon ? persistedIcoPath : undefined,
1347
+ distDir: ctx.distDir,
1348
+ })
1349
+ if (result) {
1350
+ installerPath = result
1351
+ }
1352
+ }
1353
+ } finally {
1354
+ fs.rmSync(icoTmpDir, { recursive: true, force: true })
1355
+ }
1356
+
1357
+ if (options.release) {
1358
+ await uploadToRelease({
1359
+ extensionPath: ctx.resolvedPath,
1360
+ extensionName: ctx.extensionName,
1361
+ appDir,
1362
+ appName: ctx.safeName,
1363
+ arch: ctx.resolvedArch,
1364
+ platform: 'win32',
1365
+ installerPath,
1366
+ })
1367
+ }
1368
+
1369
+ return { appPath: appDir, appName: ctx.safeName, installerPath }
1370
+ }
1371
+
1372
+ function getDirectorySize(dirPath: string): number {
1373
+ let total = 0
1374
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true })
1375
+ for (const entry of entries) {
1376
+ const fullPath = path.join(dirPath, entry.name)
1377
+ if (entry.isDirectory()) {
1378
+ total += getDirectorySize(fullPath)
1379
+ } else {
1380
+ total += fs.statSync(fullPath).size
1381
+ }
1382
+ }
1383
+ return total
1384
+ }
1385
+
1386
+ async function uploadToRelease({
1387
+ extensionPath,
1388
+ extensionName,
1389
+ appDir,
1390
+ appName,
1391
+ arch,
1392
+ platform,
1393
+ installerPath,
1394
+ }: {
1395
+ extensionPath: string
1396
+ extensionName: string
1397
+ appDir: string
1398
+ appName: string
1399
+ arch: CompileTarget['arch']
1400
+ platform: CompileTarget['os']
1401
+ installerPath?: string
1402
+ }): Promise<void> {
1403
+ const distDir = path.dirname(appDir)
1404
+
1405
+ // Find the latest release whose tag matches the extensionName@ prefix.
1406
+ // This mirrors the install script approach: scan releases for matching assets
1407
+ // rather than trusting --limit 1, which could pick an unrelated release
1408
+ // (e.g. npm-only releases, drafts, or prereleases in repos with mixed tags).
1409
+ console.log(`\nLooking for latest "${extensionName}@*" release...`)
1410
+ let latestTag: string
1411
+ try {
1412
+ const { stdout } = await execFileAsync(
1413
+ 'gh',
1414
+ ['release', 'list', '--limit', '20', '--json', 'tagName', '--jq', '.[].tagName'],
1415
+ { cwd: extensionPath },
1416
+ )
1417
+ const tags = stdout.trim().split('\n').filter(Boolean)
1418
+ const prefix = `${extensionName}@`
1419
+ const matchingTag = tags.find((tag) => {
1420
+ return tag.startsWith(prefix)
1421
+ })
1422
+ latestTag = matchingTag || ''
1423
+ } catch (e) {
1424
+ throw new Error(
1425
+ 'No GitHub releases found. Run `termcast release` first to create a release.',
1426
+ { cause: e },
1427
+ )
1428
+ }
1429
+
1430
+ if (!latestTag) {
1431
+ throw new Error(
1432
+ `No release found matching "${extensionName}@*". Run \`termcast release\` first.`,
1433
+ )
1434
+ }
1435
+
1436
+ console.log(`Uploading to release ${latestTag}...`)
1437
+
1438
+ // Zip the app bundle using JSZip (cross-platform).
1439
+ // Platform name in the zip: darwin, windows (not win32).
1440
+ const platformName = platform === 'win32' ? 'windows' : platform
1441
+ const zipName = `${appName}-${platformName}-${arch}.zip`
1442
+ const zipPath = path.join(distDir, zipName)
1443
+ fs.rmSync(zipPath, { force: true })
1444
+
1445
+ const JSZip = (await import('jszip')).default
1446
+ const zip = new JSZip()
1447
+ const appBasename = path.basename(appDir)
1448
+
1449
+ // Use UNIX platform for macOS (preserves executable permissions) and DOS for Windows
1450
+ const zipPlatform = platform === 'win32' ? 'DOS' : 'UNIX'
1451
+
1452
+ const addDirToZip = (dirPath: string, zipPrefix: string) => {
1453
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true })
1454
+ for (const entry of entries) {
1455
+ const fullPath = path.join(dirPath, entry.name)
1456
+ const entryZipPath = `${zipPrefix}/${entry.name}`
1457
+ if (entry.isDirectory()) {
1458
+ addDirToZip(fullPath, entryZipPath)
1459
+ } else {
1460
+ const data = fs.readFileSync(fullPath)
1461
+ const isExecutable = (fs.statSync(fullPath).mode & 0o111) !== 0
1462
+ zip.file(entryZipPath, data, {
1463
+ unixPermissions: isExecutable ? 0o755 : 0o644,
1464
+ })
1465
+ }
1466
+ }
1467
+ }
1468
+ addDirToZip(appDir, appBasename)
1469
+
1470
+ const zipBuffer = await zip.generateAsync({
1471
+ type: 'nodebuffer',
1472
+ platform: zipPlatform as 'UNIX' | 'DOS',
1473
+ })
1474
+ fs.writeFileSync(zipPath, zipBuffer)
1475
+ console.log(`Created ${zipName}`)
1476
+
1477
+ await execFileAsync('gh', ['release', 'upload', latestTag, zipPath, '--clobber'], {
1478
+ cwd: extensionPath,
1479
+ })
1480
+
1481
+ console.log(`Uploaded ${zipName} to release ${latestTag}`)
1482
+ fs.unlinkSync(zipPath)
1483
+
1484
+ // Upload NSIS installer alongside the zip if available
1485
+ if (installerPath && fs.existsSync(installerPath)) {
1486
+ const installerName = path.basename(installerPath)
1487
+ await execFileAsync('gh', ['release', 'upload', latestTag, installerPath, '--clobber'], {
1488
+ cwd: extensionPath,
1489
+ })
1490
+ console.log(`Uploaded ${installerName} to release ${latestTag}`)
1491
+ }
1492
+ }