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