Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions .github/workflows/build-windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
name: Build Windows

on:
push:
pull_request:
branches: [main]

env:
ZIG_VERSION: 0.15.2

jobs:
build-native-windows:
name: Windows - Native Build and Test
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Setup Zig
uses: goto-bus-stop/setup-zig@v2
with:
version: ${{ env.ZIG_VERSION }}

- name: Install dependencies
run: bun install

# Workaround for Zig 0.15.2 bug on Windows (https://github.com/ziglang/zig/issues/25805)
# When cwd and cache path are on different drives, build fails
# Setting cache dir explicitly to be on the same drive as the source
- name: Build native with Zig (Windows only)
working-directory: packages/core/src/zig
run: zig build -Doptimize=ReleaseFast --cache-dir .zig-cache --global-cache-dir .zig-cache

- name: Copy native binaries to node_modules
working-directory: packages/core
run: bun scripts/build.ts --native --skip-zig-build

- name: Verify Windows binary exists
shell: pwsh
run: |
if (!(Test-Path "packages/core/node_modules/@opentui/core-win32-x64/opentui.dll")) {
Write-Error "Windows x64 binary missing!"
exit 1
}
Write-Host "Windows x64 binary exists"
Get-ChildItem "packages/core/node_modules/@opentui/core-win32-x64/"

- name: Run native tests
working-directory: packages/core/src/zig
run: zig build test --summary all --cache-dir .zig-cache --global-cache-dir .zig-cache

- name: Build lib
working-directory: packages/core
run: bun run build:lib

- name: Run JS tests
working-directory: packages/core
run: bun run test:js

build-on-macos:
name: macOS - Cross-compile for Windows
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Setup Zig
uses: goto-bus-stop/setup-zig@v2
with:
version: ${{ env.ZIG_VERSION }}

- name: Install dependencies
run: bun install

- name: Build for Windows (cross-compile)
working-directory: packages/core/src/zig
run: zig build -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseFast

- name: Package Windows binaries
run: |
mkdir -p artifacts
cp packages/core/src/zig/lib/x86_64-windows/opentui.dll artifacts/
ls -la artifacts/

- name: Upload cross-compiled Windows binaries
uses: actions/upload-artifact@v4
with:
name: windows-binaries-cross-compiled
path: artifacts/opentui.dll
if-no-files-found: error
retention-days: 1

test-cross-compiled-on-windows:
name: Windows - Test macOS Cross-Compiled Binaries
runs-on: windows-latest
needs: build-on-macos
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Download cross-compiled Windows binaries
uses: actions/download-artifact@v4
with:
name: windows-binaries-cross-compiled
path: artifacts/

- name: Install dependencies
run: bun install

- name: Setup cross-compiled binaries
shell: bash
run: |
# Create the target directory
targetDir="packages/core/node_modules/@opentui/core-win32-x64"
mkdir -p "$targetDir"

# Copy the cross-compiled DLL
cp artifacts/opentui.dll "$targetDir/"

# Create index.ts that exports the path to the DLL
echo 'const module = await import("./opentui.dll", { with: { type: "file" } })' > "$targetDir/index.ts"
echo 'const path = module.default' >> "$targetDir/index.ts"
echo 'export default path;' >> "$targetDir/index.ts"

# Create package.json
echo '{"name":"@opentui/core-win32-x64","version":"0.0.0","main":"index.ts","types":"index.ts"}' > "$targetDir/package.json"

echo "Cross-compiled binary placed at:"
ls -la "$targetDir"

- name: Build lib (uses cross-compiled binary)
working-directory: packages/core
run: bun run build:lib

- name: Run JS tests with cross-compiled binary
working-directory: packages/core
run: bun run test:js
37 changes: 21 additions & 16 deletions packages/core/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const buildLib = args.find((arg) => arg === "--lib")
const buildNative = args.find((arg) => arg === "--native")
const isDev = args.includes("--dev")
const buildAll = args.includes("--all") // Build for all platforms (requires macOS or cross-compilation setup)
const skipZigBuild = args.includes("--skip-zig-build") // Skip zig build, just copy existing binaries

const variants: Variant[] = [
{ platform: "darwin", arch: "x64" },
Expand Down Expand Up @@ -79,26 +80,30 @@ if (missingRequired.length > 0) {
}

if (buildNative) {
console.log(`Building native ${isDev ? "dev" : "prod"} binaries${buildAll ? " for all platforms" : ""}...`)
if (skipZigBuild) {
console.log("Skipping zig build, copying existing binaries...")
} else {
console.log(`Building native ${isDev ? "dev" : "prod"} binaries${buildAll ? " for all platforms" : ""}...`)

const zigArgs = ["build", `-Doptimize=${isDev ? "Debug" : "ReleaseFast"}`]
if (buildAll) {
zigArgs.push("-Dall")
}
const zigArgs = ["build", `-Doptimize=${isDev ? "Debug" : "ReleaseFast"}`]
if (buildAll) {
zigArgs.push("-Dall")
}

const zigBuild: SpawnSyncReturns<Buffer> = spawnSync("zig", zigArgs, {
cwd: join(rootDir, "src", "zig"),
stdio: "inherit",
})
const zigBuild: SpawnSyncReturns<Buffer> = spawnSync("zig", zigArgs, {
cwd: join(rootDir, "src", "zig"),
stdio: "inherit",
})

if (zigBuild.error) {
console.error("Error: Zig is not installed or not in PATH")
process.exit(1)
}
if (zigBuild.error) {
console.error("Error: Zig is not installed or not in PATH")
process.exit(1)
}

if (zigBuild.status !== 0) {
console.error("Error: Zig build failed")
process.exit(1)
if (zigBuild.status !== 0) {
console.error("Error: Zig build failed")
process.exit(1)
}
}

for (const { platform, arch } of variants) {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/zig/bench-utils.zig
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ pub fn formatBytes(bytes: usize) struct { value: f64, unit: []const u8 } {
pub fn printResults(writer: anytype, results: []const BenchResult) !void {
if (results.len == 0) return;

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
Comment on lines +64 to +66
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The local GeneralPurposeAllocator is created and deinitialized within this function scope. However, the arena allocator initialized from this GPA outlives the GPA itself. The arena.deinit() on line 67 will attempt to free memory after the GPA has been deinitialized on line 65, which could lead to use-after-free or undefined behavior. The GPA must remain valid for the lifetime of the arena allocator.

Copilot uses AI. Check for mistakes.
defer arena.deinit();
const allocator = arena.allocator();

Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/zig/build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
.hash = "uucode-0.1.0-ZZjBPvoGQACkgWDKIrtI8CQcSXIufU3Kvty-pIfh02i2",
},
.ghostty = .{
.url = "git+https://github.com/ghostty-org/ghostty.git#fbed63b0474ce0fff66859c1563a0359589ef179",
.hash = "ghostty-1.3.0-dev-5UdBC_dRRAS-mGtQa1JGeQPgcubWBeell0hGr_47fW75",
// Using fork with Windows fix: https://github.com/ghostty-org/ghostty/issues/10147
.url = "git+https://github.com/remorses/ghostty.git#fd725490a14e4500b6676d9f4d67f6a566006bd0",
.hash = "ghostty-1.3.0-dev-5UdBC1YFPgTu_jyuC0G4FGOhjsKgATa1Eqi5MY02ycYh",
},
},
.paths = .{
Expand Down
37 changes: 19 additions & 18 deletions packages/core/src/zig/lib.zig
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ export fn setEventCallback(callback: ?*const fn (namePtr: [*]const u8, nameLen:
event_bus.setEventCallback(callback);
}

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const gpa_allocator = gpa.allocator();

var arena = std.heap.ArenaAllocator.init(gpa_allocator);
const globalArena = arena.allocator();

export fn getArenaAllocatedBytes() usize {
Expand All @@ -46,7 +49,7 @@ export fn createRenderer(width: u32, height: u32, testing: bool) ?*renderer.CliR

const pool = gp.initGlobalPool(globalArena);
_ = link.initGlobalLinkPool(globalArena);
return renderer.CliRenderer.create(std.heap.page_allocator, width, height, pool, testing) catch |err| {
return renderer.CliRenderer.create(gpa_allocator, width, height, pool, testing) catch |err| {
logger.err("Failed to create renderer: {}", .{err});
return null;
};
Expand Down Expand Up @@ -107,7 +110,7 @@ export fn createOptimizedBuffer(width: u32, height: u32, respectAlpha: bool, wid
const wMethod: utf8.WidthMethod = if (widthMethod == 0) .wcwidth else .unicode;
const id = idPtr[0..idLen];

return buffer.OptimizedBuffer.init(std.heap.page_allocator, width, height, .{
return buffer.OptimizedBuffer.init(gpa_allocator, width, height, .{
.respectAlpha = respectAlpha,
.pool = pool,
.width_method = wMethod,
Expand Down Expand Up @@ -505,7 +508,7 @@ export fn createTextBuffer(widthMethod: u8) ?*text_buffer.UnifiedTextBuffer {
const pool = gp.initGlobalPool(globalArena);
const wMethod: utf8.WidthMethod = if (widthMethod == 0) .wcwidth else .unicode;

const tb = text_buffer.UnifiedTextBuffer.init(std.heap.page_allocator, pool, wMethod) catch {
const tb = text_buffer.UnifiedTextBuffer.init(gpa_allocator, pool, wMethod) catch {
return null;
};

Expand Down Expand Up @@ -615,7 +618,7 @@ export fn textBufferGetPlainText(tb: *text_buffer.UnifiedTextBuffer, outPtr: [*]

// TextBufferView functions (Array-based for backward compatibility)
export fn createTextBufferView(tb: *text_buffer.UnifiedTextBuffer) ?*text_buffer_view.UnifiedTextBufferView {
const view = text_buffer_view.UnifiedTextBufferView.init(std.heap.page_allocator, tb) catch {
const view = text_buffer_view.UnifiedTextBufferView.init(gpa_allocator, tb) catch {
return null;
};
return view;
Expand Down Expand Up @@ -763,7 +766,7 @@ export fn createEditBuffer(widthMethod: u8) ?*edit_buffer_mod.EditBuffer {
const wMethod: utf8.WidthMethod = if (widthMethod == 0) .wcwidth else .unicode;

return edit_buffer_mod.EditBuffer.init(
std.heap.page_allocator,
gpa_allocator,
pool,
wMethod,
) catch null;
Expand Down Expand Up @@ -1323,8 +1326,7 @@ export fn textBufferGetLineHighlightsPtr(
return null;
}

const alloc = std.heap.page_allocator;
var slice = alloc.alloc(ExternalHighlight, highs.len) catch return null;
var slice = gpa_allocator.alloc(ExternalHighlight, highs.len) catch return null;

for (highs, 0..) |hl, i| {
slice[i] = .{
Expand All @@ -1341,8 +1343,7 @@ export fn textBufferGetLineHighlightsPtr(
}

export fn textBufferFreeLineHighlights(ptr: [*]const ExternalHighlight, count: usize) void {
const alloc = std.heap.page_allocator;
alloc.free(@constCast(ptr)[0..count]);
gpa_allocator.free(@constCast(ptr)[0..count]);
}

export fn textBufferGetHighlightCount(tb: *text_buffer.UnifiedTextBuffer) u32 {
Expand All @@ -1361,7 +1362,7 @@ export fn textBufferGetTextRangeByCoords(tb: *text_buffer.UnifiedTextBuffer, sta

// SyntaxStyle functions
export fn createSyntaxStyle() ?*syntax_style.SyntaxStyle {
return syntax_style.SyntaxStyle.init(std.heap.page_allocator) catch |err| {
return syntax_style.SyntaxStyle.init(gpa_allocator) catch |err| {
logger.err("Failed to create SyntaxStyle: {}", .{err});
return null;
};
Expand Down Expand Up @@ -1410,15 +1411,15 @@ export fn encodeUnicode(

// Find grapheme info
var grapheme_list: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{};
defer grapheme_list.deinit(std.heap.page_allocator);
defer grapheme_list.deinit(gpa_allocator);

const tab_width: u8 = 2;
utf8.findGraphemeInfo(text, tab_width, is_ascii_only, wMethod, std.heap.page_allocator, &grapheme_list) catch return false;
utf8.findGraphemeInfo(text, tab_width, is_ascii_only, wMethod, gpa_allocator, &grapheme_list) catch return false;
const specials = grapheme_list.items;

// Allocate output array
const estimated_count = if (is_ascii_only) text.len else text.len * 2;
var result = std.heap.page_allocator.alloc(EncodedChar, estimated_count) catch return false;
var result = gpa_allocator.alloc(EncodedChar, estimated_count) catch return false;
var result_idx: usize = 0;
var success = false;
var pending_gid: ?u32 = null; // Track grapheme allocated but not yet stored in result
Expand All @@ -1441,7 +1442,7 @@ export fn encodeUnicode(
pool.decref(gid) catch {};
}
}
std.heap.page_allocator.free(result);
gpa_allocator.free(result);
}
}

Expand Down Expand Up @@ -1495,7 +1496,7 @@ export fn encodeUnicode(
// Ensure we have space
if (result_idx >= result.len) {
const new_len = result.len * 2;
result = std.heap.page_allocator.realloc(result, new_len) catch return false;
result = gpa_allocator.realloc(result, new_len) catch return false;
}

result[result_idx] = EncodedChar{
Expand All @@ -1508,7 +1509,7 @@ export fn encodeUnicode(
}

// Trim to actual size
result = std.heap.page_allocator.realloc(result, result_idx) catch result;
result = gpa_allocator.realloc(result, result_idx) catch result;

outPtr.* = result.ptr;
outLenPtr.* = result_idx;
Expand All @@ -1531,7 +1532,7 @@ export fn freeUnicode(charsPtr: [*]const EncodedChar, charsLen: usize) void {
}

// Free the array itself
std.heap.page_allocator.free(chars);
gpa_allocator.free(chars);
}

export fn bufferDrawChar(
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/zig/terminal.zig
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,9 @@ pub fn enableDetectedFeatures(self: *Terminal, tty: anytype, use_kitty_keyboard:
}

fn checkEnvironmentOverrides(self: *Terminal) void {
var env_map = std.process.getEnvMap(std.heap.page_allocator) catch return;
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var env_map = std.process.getEnvMap(gpa.allocator()) catch return;
Comment on lines +226 to +228
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The local GeneralPurposeAllocator is created and deinitialized within this function scope. However, the env_map allocated from this allocator outlives the allocator itself due to the early return on line 228. If getEnvMap succeeds, env_map.deinit() on line 229 will be called after the GPA has been deinitialized on line 227, which could lead to use-after-free or undefined behavior. The allocator must remain valid for the lifetime of all allocations.

Copilot uses AI. Check for mistakes.
defer env_map.deinit();

// Always just try to enable bracketed paste, even if it was reported as not supported
Expand Down
Loading