From 75f0ac4395c2fb8c54ce8e0223c669354fda62ba Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 23 Aug 2025 00:33:24 -0700 Subject: [PATCH] Add Windows metadata flags to bun build --compile (#22067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds support for setting Windows executable metadata through CLI flags when using `bun build --compile` - Implements efficient single-operation metadata updates using the rescle library - Provides comprehensive error handling and validation ## New CLI Flags - `--windows-title`: Set the application title - `--windows-publisher`: Set the publisher/company name - `--windows-version`: Set the file version (e.g. "1.0.0.0") - `--windows-description`: Set the file description - `--windows-copyright`: Set the copyright notice ## JavaScript API These options are also available through the `Bun.build()` JavaScript API: ```javascript await Bun.build({ entrypoints: ["./app.js"], outfile: "./app.exe", compile: true, windows: { title: "My Application", publisher: "My Company", version: "1.0.0.0", description: "Application description", copyright: "© 2025 My Company" } }); ``` ## Implementation Details - Uses a unified `rescle__setWindowsMetadata` C++ function that loads the Windows executable only once for efficiency - Properly handles UTF-16 string conversion for Windows APIs - Validates version format (supports "1", "1.2", "1.2.3", or "1.2.3.4" formats) - Returns specific error codes for better debugging - All operations return errors instead of calling `Global.exit(1)` ## Test Plan Comprehensive test suite added in `test/bundler/compile-windows-metadata.test.ts` covering: - All CLI flags individually and in combination - JavaScript API usage - Error cases (invalid versions, missing --compile flag, etc.) - Special character handling in metadata strings All 20 tests passing (1 skipped as not applicable on Windows). 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Zack Radisic Co-authored-by: Claude Co-authored-by: Jarred-Sumner <709451+Jarred-Sumner@users.noreply.github.com> --- docs/bundler/executables.md | 110 +++- packages/bun-types/bun.d.ts | 4 + src/StandaloneModuleGraph.zig | 128 +++- src/bun.js/api/JSBundler.zig | 45 ++ .../bindings/windows/rescle-binding.cpp | 81 +++ src/bundler/bundle_v2.zig | 41 +- src/cli.zig | 3 +- src/cli/Arguments.zig | 64 +- src/cli/build_command.zig | 3 +- src/options.zig | 10 + src/string/immutable/unicode.zig | 2 +- src/windows.zig | 101 +++ test/bundler/compile-windows-metadata.test.ts | 618 ++++++++++++++++++ 13 files changed, 1155 insertions(+), 55 deletions(-) create mode 100644 test/bundler/compile-windows-metadata.test.ts diff --git a/docs/bundler/executables.md b/docs/bundler/executables.md index 6f7f841288..785d107979 100644 --- a/docs/bundler/executables.md +++ b/docs/bundler/executables.md @@ -408,16 +408,118 @@ $ bun build --compile --asset-naming="[name].[ext]" ./index.ts To trim down the size of the executable a little, pass `--minify` to `bun build --compile`. This uses Bun's minifier to reduce the code size. Overall though, Bun's binary is still way too big and we need to make it smaller. +## Using Bun.build() API + +You can also generate standalone executables using the `Bun.build()` JavaScript API. This is useful when you need programmatic control over the build process. + +### Basic usage + +```js +await Bun.build({ + entrypoints: ['./app.ts'], + outdir: './dist', + compile: { + target: 'bun-windows-x64', + outfile: 'myapp.exe', + }, +}); +``` + +### Windows metadata with Bun.build() + +When targeting Windows, you can specify metadata through the `windows` object: + +```js +await Bun.build({ + entrypoints: ['./app.ts'], + outdir: './dist', + compile: { + target: 'bun-windows-x64', + outfile: 'myapp.exe', + windows: { + title: 'My Application', + publisher: 'My Company Inc', + version: '1.2.3.4', + description: 'A powerful application built with Bun', + copyright: '© 2024 My Company Inc', + hideConsole: false, // Set to true for GUI applications + icon: './icon.ico', // Path to icon file + }, + }, +}); +``` + +### Cross-compilation with Bun.build() + +You can cross-compile for different platforms: + +```js +// Build for multiple platforms +const platforms = [ + { target: 'bun-windows-x64', outfile: 'app-windows.exe' }, + { target: 'bun-linux-x64', outfile: 'app-linux' }, + { target: 'bun-darwin-arm64', outfile: 'app-macos' }, +]; + +for (const platform of platforms) { + await Bun.build({ + entrypoints: ['./app.ts'], + outdir: './dist', + compile: platform, + }); +} +``` + ## Windows-specific flags -When compiling a standalone executable on Windows, there are two platform-specific options that can be used to customize metadata on the generated `.exe` file: +When compiling a standalone executable for Windows, there are several platform-specific options that can be used to customize the generated `.exe` file: -- `--windows-icon=path/to/icon.ico` to customize the executable file icon. -- `--windows-hide-console` to disable the background terminal, which can be used for applications that do not need a TTY. +### Visual customization + +- `--windows-icon=path/to/icon.ico` - Set the executable file icon +- `--windows-hide-console` - Disable the background terminal window (useful for GUI applications) + +### Metadata customization + +You can embed version information and other metadata into your Windows executable: + +- `--windows-title ` - Set the product name (appears in file properties) +- `--windows-publisher ` - Set the company name +- `--windows-version ` - Set the version number (e.g. "1.2.3.4") +- `--windows-description ` - Set the file description +- `--windows-copyright ` - Set the copyright information + +#### Example with all metadata flags: + +```sh +bun build --compile ./app.ts \ + --outfile myapp.exe \ + --windows-title "My Application" \ + --windows-publisher "My Company Inc" \ + --windows-version "1.2.3.4" \ + --windows-description "A powerful application built with Bun" \ + --windows-copyright "© 2024 My Company Inc" +``` + +This metadata will be visible in Windows Explorer when viewing the file properties: + +1. Right-click the executable in Windows Explorer +2. Select "Properties" +3. Go to the "Details" tab + +#### Version string format + +The `--windows-version` flag accepts version strings in the following formats: +- `"1"` - Will be normalized to "1.0.0.0" +- `"1.2"` - Will be normalized to "1.2.0.0" +- `"1.2.3"` - Will be normalized to "1.2.3.0" +- `"1.2.3.4"` - Full version format + +Each version component must be a number between 0 and 65535. {% callout %} -These flags currently cannot be used when cross-compiling because they depend on Windows APIs. +These flags currently cannot be used when cross-compiling because they depend on Windows APIs. They are only available when building on Windows itself. {% /callout %} diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 7eb2f26883..bd2bfab6fd 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -1844,6 +1844,10 @@ declare module "bun" { hideConsole?: boolean; icon?: string; title?: string; + publisher?: string; + version?: string; + description?: string; + copyright?: string; }; } diff --git a/src/StandaloneModuleGraph.zig b/src/StandaloneModuleGraph.zig index f4ab253942..4b1e08fd6a 100644 --- a/src/StandaloneModuleGraph.zig +++ b/src/StandaloneModuleGraph.zig @@ -492,9 +492,7 @@ pub const StandaloneModuleGraph = struct { const page_size = std.heap.page_size_max; - pub const InjectOptions = struct { - windows_hide_console: bool = false, - }; + pub const InjectOptions = bun.options.WindowsOptions; pub const CompileResult = union(enum) { success: void, @@ -515,7 +513,7 @@ pub const StandaloneModuleGraph = struct { var buf: bun.PathBuffer = undefined; var zname: [:0]const u8 = bun.span(bun.fs.FileSystem.instance.tmpname("bun-build", &buf, @as(u64, @bitCast(std.time.milliTimestamp()))) catch |err| { Output.prettyErrorln("error: failed to get temporary file name: {s}", .{@errorName(err)}); - Global.exit(1); + return bun.invalid_fd; }); const cleanup = struct { @@ -545,7 +543,7 @@ pub const StandaloneModuleGraph = struct { bun.copyFile(in, out).unwrap() catch |err| { Output.prettyErrorln("error: failed to copy bun executable into temporary file: {s}", .{@errorName(err)}); - Global.exit(1); + return bun.invalid_fd; }; const file = bun.sys.openFileAtWindows( bun.invalid_fd, @@ -557,7 +555,7 @@ pub const StandaloneModuleGraph = struct { }, ).unwrap() catch |e| { Output.prettyErrorln("error: failed to open temporary file to copy bun into\n{}", .{e}); - Global.exit(1); + return bun.invalid_fd; }; break :brk file; @@ -611,7 +609,8 @@ pub const StandaloneModuleGraph = struct { } Output.prettyErrorln("error: failed to open temporary file to copy bun into\n{}", .{err}); - Global.exit(1); + // No fd to cleanup yet, just return error + return bun.invalid_fd; } }, } @@ -633,7 +632,7 @@ pub const StandaloneModuleGraph = struct { Output.prettyErrorln("error: failed to open bun executable to copy from as read-only\n{}", .{err}); cleanup(zname, fd); - Global.exit(1); + return bun.invalid_fd; }, } } @@ -645,7 +644,7 @@ pub const StandaloneModuleGraph = struct { bun.copyFile(self_fd, fd).unwrap() catch |err| { Output.prettyErrorln("error: failed to copy bun executable into temporary file: {s}", .{@errorName(err)}); cleanup(zname, fd); - Global.exit(1); + return bun.invalid_fd; }; break :brk fd; @@ -657,18 +656,18 @@ pub const StandaloneModuleGraph = struct { if (input_result.err) |err| { Output.prettyErrorln("Error reading standalone module graph: {}", .{err}); cleanup(zname, cloned_executable_fd); - Global.exit(1); + return bun.invalid_fd; } var macho_file = bun.macho.MachoFile.init(bun.default_allocator, input_result.bytes.items, bytes.len) catch |err| { Output.prettyErrorln("Error initializing standalone module graph: {}", .{err}); cleanup(zname, cloned_executable_fd); - Global.exit(1); + return bun.invalid_fd; }; defer macho_file.deinit(); macho_file.writeSection(bytes) catch |err| { Output.prettyErrorln("Error writing standalone module graph: {}", .{err}); cleanup(zname, cloned_executable_fd); - Global.exit(1); + return bun.invalid_fd; }; input_result.bytes.deinit(); @@ -676,7 +675,7 @@ pub const StandaloneModuleGraph = struct { .err => |err| { Output.prettyErrorln("Error seeking to start of temporary file: {}", .{err}); cleanup(zname, cloned_executable_fd); - Global.exit(1); + return bun.invalid_fd; }, else => {}, } @@ -691,12 +690,12 @@ pub const StandaloneModuleGraph = struct { macho_file.buildAndSign(buffered_writer.writer()) catch |err| { Output.prettyErrorln("Error writing standalone module graph: {}", .{err}); cleanup(zname, cloned_executable_fd); - Global.exit(1); + return bun.invalid_fd; }; buffered_writer.flush() catch |err| { Output.prettyErrorln("Error flushing standalone module graph: {}", .{err}); cleanup(zname, cloned_executable_fd); - Global.exit(1); + return bun.invalid_fd; }; if (comptime !Environment.isWindows) { _ = bun.c.fchmod(cloned_executable_fd.native(), 0o777); @@ -708,18 +707,18 @@ pub const StandaloneModuleGraph = struct { if (input_result.err) |err| { Output.prettyErrorln("Error reading standalone module graph: {}", .{err}); cleanup(zname, cloned_executable_fd); - Global.exit(1); + return bun.invalid_fd; } var pe_file = bun.pe.PEFile.init(bun.default_allocator, input_result.bytes.items) catch |err| { Output.prettyErrorln("Error initializing PE file: {}", .{err}); cleanup(zname, cloned_executable_fd); - Global.exit(1); + return bun.invalid_fd; }; defer pe_file.deinit(); pe_file.addBunSection(bytes) catch |err| { Output.prettyErrorln("Error adding Bun section to PE file: {}", .{err}); cleanup(zname, cloned_executable_fd); - Global.exit(1); + return bun.invalid_fd; }; input_result.bytes.deinit(); @@ -727,7 +726,7 @@ pub const StandaloneModuleGraph = struct { .err => |err| { Output.prettyErrorln("Error seeking to start of temporary file: {}", .{err}); cleanup(zname, cloned_executable_fd); - Global.exit(1); + return bun.invalid_fd; }, else => {}, } @@ -737,7 +736,7 @@ pub const StandaloneModuleGraph = struct { pe_file.write(writer) catch |err| { Output.prettyErrorln("Error writing PE file: {}", .{err}); cleanup(zname, cloned_executable_fd); - Global.exit(1); + return bun.invalid_fd; }; // Set executable permissions when running on POSIX hosts, even for Windows targets if (comptime !Environment.isWindows) { @@ -751,7 +750,7 @@ pub const StandaloneModuleGraph = struct { total_byte_count = bytes.len + 8 + (Syscall.setFileOffsetToEndWindows(cloned_executable_fd).unwrap() catch |err| { Output.prettyErrorln("error: failed to seek to end of temporary file\n{}", .{err}); cleanup(zname, cloned_executable_fd); - Global.exit(1); + return bun.invalid_fd; }); } else { const seek_position = @as(u64, @intCast(brk: { @@ -760,7 +759,7 @@ pub const StandaloneModuleGraph = struct { .err => |err| { Output.prettyErrorln("{}", .{err}); cleanup(zname, cloned_executable_fd); - Global.exit(1); + return bun.invalid_fd; }, }; @@ -787,7 +786,7 @@ pub const StandaloneModuleGraph = struct { }, ); cleanup(zname, cloned_executable_fd); - Global.exit(1); + return bun.invalid_fd; }, else => {}, } @@ -800,8 +799,7 @@ pub const StandaloneModuleGraph = struct { .err => |err| { Output.prettyErrorln("error: failed to write to temporary file\n{}", .{err}); cleanup(zname, cloned_executable_fd); - - Global.exit(1); + return bun.invalid_fd; }, } } @@ -816,12 +814,42 @@ pub const StandaloneModuleGraph = struct { }, } - if (Environment.isWindows and inject_options.windows_hide_console) { + if (Environment.isWindows and inject_options.hide_console) { bun.windows.editWin32BinarySubsystem(.{ .handle = cloned_executable_fd }, .windows_gui) catch |err| { Output.err(err, "failed to disable console on executable", .{}); cleanup(zname, cloned_executable_fd); + return bun.invalid_fd; + }; + } - Global.exit(1); + // Set Windows icon and/or metadata if any options are provided (single operation) + if (Environment.isWindows and (inject_options.icon != null or + inject_options.title != null or + inject_options.publisher != null or + inject_options.version != null or + inject_options.description != null or + inject_options.copyright != null)) + { + var zname_buf: bun.OSPathBuffer = undefined; + const zname_w = bun.strings.toWPathNormalized(&zname_buf, zname) catch |err| { + Output.err(err, "failed to resolve executable path", .{}); + cleanup(zname, cloned_executable_fd); + return bun.invalid_fd; + }; + + // Single call to set all Windows metadata at once + bun.windows.rescle.setWindowsMetadata( + zname_w.ptr, + inject_options.icon, + inject_options.title, + inject_options.publisher, + inject_options.version, + inject_options.description, + inject_options.copyright, + ) catch |err| { + Output.err(err, "failed to set Windows metadata on executable", .{}); + cleanup(zname, cloned_executable_fd); + return bun.invalid_fd; }; } @@ -872,7 +900,7 @@ pub const StandaloneModuleGraph = struct { Output.errGeneric("Failed to download {}: {s}", .{ target.*, @errorName(err) }); }, } - Global.exit(1); + return error.DownloadFailed; }; } @@ -888,8 +916,7 @@ pub const StandaloneModuleGraph = struct { outfile: []const u8, env: *bun.DotEnv.Loader, output_format: bun.options.Format, - windows_hide_console: bool, - windows_icon: ?[]const u8, + windows_options: bun.options.WindowsOptions, compile_exec_argv: []const u8, self_exe_path: ?[]const u8, ) !CompileResult { @@ -941,7 +968,7 @@ pub const StandaloneModuleGraph = struct { var fd = inject( bytes, self_exe, - .{ .windows_hide_console = windows_hide_console }, + windows_options, target, ); defer if (fd != bun.invalid_fd) fd.close(); @@ -974,11 +1001,40 @@ pub const StandaloneModuleGraph = struct { fd.close(); fd = bun.invalid_fd; - if (windows_icon) |icon_utf8| { - var icon_buf: bun.OSPathBuffer = undefined; - const icon = bun.strings.toWPathNormalized(&icon_buf, icon_utf8); - bun.windows.rescle.setIcon(outfile_slice, icon) catch |err| { - Output.debug("Warning: Failed to set Windows icon for executable: {s}", .{@errorName(err)}); + // Set Windows icon and/or metadata using unified function + if (windows_options.icon != null or + windows_options.title != null or + windows_options.publisher != null or + windows_options.version != null or + windows_options.description != null or + windows_options.copyright != null) { + // Need to get the full path to the executable + var full_path_buf: bun.OSPathBuffer = undefined; + const full_path = brk: { + // Get the directory path + var dir_buf: bun.PathBuffer = undefined; + const dir_path = bun.getFdPath(bun.FD.fromStdDir(root_dir), &dir_buf) catch |err| { + return CompileResult.fail(std.fmt.allocPrint(allocator, "Failed to get directory path: {s}", .{@errorName(err)}) catch "Failed to get directory path"); + }; + + // Join with the outfile name + const full_path_str = bun.path.joinAbsString(dir_path, &[_][]const u8{outfile}, .auto); + const full_path_w = bun.strings.toWPathNormalized(&full_path_buf, full_path_str); + const buf_u16 = bun.reinterpretSlice(u16, &full_path_buf); + buf_u16[full_path_w.len] = 0; + break :brk buf_u16[0..full_path_w.len :0]; + }; + + bun.windows.rescle.setWindowsMetadata( + full_path.ptr, + windows_options.icon, + windows_options.title, + windows_options.publisher, + windows_options.version, + windows_options.description, + windows_options.copyright, + ) catch |err| { + return CompileResult.fail(std.fmt.allocPrint(allocator, "Failed to set Windows metadata: {s}", .{@errorName(err)}) catch "Failed to set Windows metadata"); }; } return .success; diff --git a/src/bun.js/api/JSBundler.zig b/src/bun.js/api/JSBundler.zig index 5249d197a3..a0ea3c6cb0 100644 --- a/src/bun.js/api/JSBundler.zig +++ b/src/bun.js/api/JSBundler.zig @@ -46,6 +46,10 @@ pub const JSBundler = struct { windows_hide_console: bool = false, windows_icon_path: OwnedString = OwnedString.initEmpty(bun.default_allocator), windows_title: OwnedString = OwnedString.initEmpty(bun.default_allocator), + windows_publisher: OwnedString = OwnedString.initEmpty(bun.default_allocator), + windows_version: OwnedString = OwnedString.initEmpty(bun.default_allocator), + windows_description: OwnedString = OwnedString.initEmpty(bun.default_allocator), + windows_copyright: OwnedString = OwnedString.initEmpty(bun.default_allocator), outfile: OwnedString = OwnedString.initEmpty(bun.default_allocator), pub fn fromJS(globalThis: *jsc.JSGlobalObject, config: jsc.JSValue, allocator: std.mem.Allocator, compile_target: ?CompileTarget) JSError!?CompileOptions { @@ -54,6 +58,10 @@ pub const JSBundler = struct { .executable_path = OwnedString.initEmpty(allocator), .windows_icon_path = OwnedString.initEmpty(allocator), .windows_title = OwnedString.initEmpty(allocator), + .windows_publisher = OwnedString.initEmpty(allocator), + .windows_version = OwnedString.initEmpty(allocator), + .windows_description = OwnedString.initEmpty(allocator), + .windows_copyright = OwnedString.initEmpty(allocator), .outfile = OwnedString.initEmpty(allocator), .compile_target = compile_target orelse .{}, }; @@ -131,6 +139,30 @@ pub const JSBundler = struct { defer slice.deinit(); try this.windows_title.appendSliceExact(slice.slice()); } + + if (try windows.getOwn(globalThis, "publisher")) |windows_publisher| { + var slice = try windows_publisher.toSlice(globalThis, bun.default_allocator); + defer slice.deinit(); + try this.windows_publisher.appendSliceExact(slice.slice()); + } + + if (try windows.getOwn(globalThis, "version")) |windows_version| { + var slice = try windows_version.toSlice(globalThis, bun.default_allocator); + defer slice.deinit(); + try this.windows_version.appendSliceExact(slice.slice()); + } + + if (try windows.getOwn(globalThis, "description")) |windows_description| { + var slice = try windows_description.toSlice(globalThis, bun.default_allocator); + defer slice.deinit(); + try this.windows_description.appendSliceExact(slice.slice()); + } + + if (try windows.getOwn(globalThis, "copyright")) |windows_copyright| { + var slice = try windows_copyright.toSlice(globalThis, bun.default_allocator); + defer slice.deinit(); + try this.windows_copyright.appendSliceExact(slice.slice()); + } } if (try object.getOwn(globalThis, "outfile")) |outfile| { @@ -147,6 +179,10 @@ pub const JSBundler = struct { this.executable_path.deinit(); this.windows_icon_path.deinit(); this.windows_title.deinit(); + this.windows_publisher.deinit(); + this.windows_version.deinit(); + this.windows_description.deinit(); + this.windows_copyright.deinit(); this.outfile.deinit(); } }; @@ -176,6 +212,15 @@ pub const JSBundler = struct { if (strings.hasPrefixComptime(slice.slice(), "bun-")) { this.compile = .{ .compile_target = try CompileTarget.fromSlice(globalThis, slice.slice()), + .exec_argv = OwnedString.initEmpty(allocator), + .executable_path = OwnedString.initEmpty(allocator), + .windows_icon_path = OwnedString.initEmpty(allocator), + .windows_title = OwnedString.initEmpty(allocator), + .windows_publisher = OwnedString.initEmpty(allocator), + .windows_version = OwnedString.initEmpty(allocator), + .windows_description = OwnedString.initEmpty(allocator), + .windows_copyright = OwnedString.initEmpty(allocator), + .outfile = OwnedString.initEmpty(allocator), }; this.target = .bun; did_set_target = true; diff --git a/src/bun.js/bindings/windows/rescle-binding.cpp b/src/bun.js/bindings/windows/rescle-binding.cpp index 31514168e2..0bb1f6e1d4 100644 --- a/src/bun.js/bindings/windows/rescle-binding.cpp +++ b/src/bun.js/bindings/windows/rescle-binding.cpp @@ -12,3 +12,84 @@ extern "C" int rescle__setIcon(const WCHAR* exeFilename, const WCHAR* iconFilena return -3; return 0; } + +// Unified function to set all Windows metadata in a single operation +extern "C" int rescle__setWindowsMetadata( + const WCHAR* exeFilename, + const WCHAR* iconFilename, + const WCHAR* title, + const WCHAR* publisher, + const WCHAR* version, + const WCHAR* description, + const WCHAR* copyright) +{ + rescle::ResourceUpdater updater; + + // Load the executable once + if (!updater.Load(exeFilename)) + return -1; + + // Set icon if provided (check for non-null and non-empty) + if (iconFilename && iconFilename != nullptr && *iconFilename != L'\0') { + if (!updater.SetIcon(iconFilename)) + return -2; + } + + // Set Product Name (title) + if (title && *title) { + if (!updater.SetVersionString(RU_VS_PRODUCT_NAME, title)) + return -3; + } + + // Set Company Name (publisher) + if (publisher && *publisher) { + if (!updater.SetVersionString(RU_VS_COMPANY_NAME, publisher)) + return -4; + } + + // Set File Description + if (description && *description) { + if (!updater.SetVersionString(RU_VS_FILE_DESCRIPTION, description)) + return -5; + } + + // Set Legal Copyright + if (copyright && *copyright) { + if (!updater.SetVersionString(RU_VS_LEGAL_COPYRIGHT, copyright)) + return -6; + } + + // Set File Version and Product Version + if (version && *version) { + // Parse version string like "1", "1.2", "1.2.3", or "1.2.3.4" + unsigned short v1 = 0, v2 = 0, v3 = 0, v4 = 0; + int parsed = swscanf_s(version, L"%hu.%hu.%hu.%hu", &v1, &v2, &v3, &v4); + + if (parsed > 0) { + // Set both file version and product version + if (!updater.SetFileVersion(v1, v2, v3, v4)) + return -7; + if (!updater.SetProductVersion(v1, v2, v3, v4)) + return -8; + + // Create normalized version string "v1.v2.v3.v4" + WCHAR normalizedVersion[32]; + swprintf_s(normalizedVersion, 32, L"%hu.%hu.%hu.%hu", v1, v2, v3, v4); + + // Set the string representation with normalized version + if (!updater.SetVersionString(RU_VS_FILE_VERSION, normalizedVersion)) + return -9; + if (!updater.SetVersionString(RU_VS_PRODUCT_VERSION, normalizedVersion)) + return -10; + } else { + // Invalid version format + return -11; + } + } + + // Commit all changes at once + if (!updater.Commit()) + return -12; + + return 0; +} diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index 2b95109b22..4c52042a9b 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -1799,9 +1799,12 @@ pub const BundleV2 = struct { const output_file = &output_files.items[entry_point_index]; const outbuf = bun.path_buffer_pool.get(); defer bun.path_buffer_pool.put(outbuf); - var full_outfile_path = if (this.config.outdir.slice().len > 0) - bun.path.joinAbsStringBuf(this.config.outdir.slice(), outbuf, &[_][]const u8{compile_options.outfile.slice()}, .loose) - else + + var full_outfile_path = if (this.config.outdir.slice().len > 0) brk: { + const outdir_slice = this.config.outdir.slice(); + const top_level_dir = bun.fs.FileSystem.instance.top_level_dir; + break :brk bun.path.joinAbsStringBuf(top_level_dir, outbuf, &[_][]const u8{ outdir_slice, compile_options.outfile.slice() }, .auto); + } else compile_options.outfile.slice(); // Add .exe extension for Windows targets if not already present @@ -1836,11 +1839,33 @@ pub const BundleV2 = struct { basename, this.env, this.config.format, - compile_options.windows_hide_console, - if (compile_options.windows_icon_path.slice().len > 0) - compile_options.windows_icon_path.slice() - else - null, + .{ + .hide_console = compile_options.windows_hide_console, + .icon = if (compile_options.windows_icon_path.slice().len > 0) + compile_options.windows_icon_path.slice() + else + null, + .title = if (compile_options.windows_title.slice().len > 0) + compile_options.windows_title.slice() + else + null, + .publisher = if (compile_options.windows_publisher.slice().len > 0) + compile_options.windows_publisher.slice() + else + null, + .version = if (compile_options.windows_version.slice().len > 0) + compile_options.windows_version.slice() + else + null, + .description = if (compile_options.windows_description.slice().len > 0) + compile_options.windows_description.slice() + else + null, + .copyright = if (compile_options.windows_copyright.slice().len > 0) + compile_options.windows_copyright.slice() + else + null, + }, compile_options.exec_argv.slice(), if (compile_options.executable_path.slice().len > 0) compile_options.executable_path.slice() diff --git a/src/cli.zig b/src/cli.zig index e459ba778e..c00479ab6d 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -421,8 +421,7 @@ pub const Command = struct { compile: bool = false, compile_target: Cli.CompileTarget = .{}, compile_exec_argv: ?[]const u8 = null, - windows_hide_console: bool = false, - windows_icon: ?[]const u8 = null, + windows: options.WindowsOptions = .{}, }; pub fn create(allocator: std.mem.Allocator, log: *logger.Log, comptime command: Command.Tag) anyerror!Context { diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index e0556a97a1..48d5669523 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -173,6 +173,11 @@ pub const build_only_params = [_]ParamType{ clap.parseParam("--env Inline environment variables into the bundle as process.env.${name}. Defaults to 'disable'. To inline environment variables matching a prefix, use my prefix like 'FOO_PUBLIC_*'.") catch unreachable, clap.parseParam("--windows-hide-console When using --compile targeting Windows, prevent a Command prompt from opening alongside the executable") catch unreachable, clap.parseParam("--windows-icon When using --compile targeting Windows, assign an executable icon") catch unreachable, + clap.parseParam("--windows-title When using --compile targeting Windows, set the executable product name") catch unreachable, + clap.parseParam("--windows-publisher When using --compile targeting Windows, set the executable company name") catch unreachable, + clap.parseParam("--windows-version When using --compile targeting Windows, set the executable version (e.g. 1.2.3.4)") catch unreachable, + clap.parseParam("--windows-description When using --compile targeting Windows, set the executable description") catch unreachable, + clap.parseParam("--windows-copyright When using --compile targeting Windows, set the executable copyright") catch unreachable, } ++ if (FeatureFlags.bake_debugging_features) [_]ParamType{ clap.parseParam("--debug-dump-server-files When --app is set, dump all server files to disk even when building statically") catch unreachable, clap.parseParam("--debug-no-minify When --app is set, do not minify anything") catch unreachable, @@ -906,7 +911,7 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C Output.errGeneric("--windows-hide-console requires --compile", .{}); Global.crash(); } - ctx.bundler_options.windows_hide_console = true; + ctx.bundler_options.windows.hide_console = true; } if (args.option("--windows-icon")) |path| { if (!Environment.isWindows) { @@ -917,7 +922,62 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C Output.errGeneric("--windows-icon requires --compile", .{}); Global.crash(); } - ctx.bundler_options.windows_icon = path; + ctx.bundler_options.windows.icon = path; + } + if (args.option("--windows-title")) |title| { + if (!Environment.isWindows) { + Output.errGeneric("Using --windows-title is only available when compiling on Windows", .{}); + Global.crash(); + } + if (!ctx.bundler_options.compile) { + Output.errGeneric("--windows-title requires --compile", .{}); + Global.crash(); + } + ctx.bundler_options.windows.title = title; + } + if (args.option("--windows-publisher")) |publisher| { + if (!Environment.isWindows) { + Output.errGeneric("Using --windows-publisher is only available when compiling on Windows", .{}); + Global.crash(); + } + if (!ctx.bundler_options.compile) { + Output.errGeneric("--windows-publisher requires --compile", .{}); + Global.crash(); + } + ctx.bundler_options.windows.publisher = publisher; + } + if (args.option("--windows-version")) |version| { + if (!Environment.isWindows) { + Output.errGeneric("Using --windows-version is only available when compiling on Windows", .{}); + Global.crash(); + } + if (!ctx.bundler_options.compile) { + Output.errGeneric("--windows-version requires --compile", .{}); + Global.crash(); + } + ctx.bundler_options.windows.version = version; + } + if (args.option("--windows-description")) |description| { + if (!Environment.isWindows) { + Output.errGeneric("Using --windows-description is only available when compiling on Windows", .{}); + Global.crash(); + } + if (!ctx.bundler_options.compile) { + Output.errGeneric("--windows-description requires --compile", .{}); + Global.crash(); + } + ctx.bundler_options.windows.description = description; + } + if (args.option("--windows-copyright")) |copyright| { + if (!Environment.isWindows) { + Output.errGeneric("Using --windows-copyright is only available when compiling on Windows", .{}); + Global.crash(); + } + if (!ctx.bundler_options.compile) { + Output.errGeneric("--windows-copyright requires --compile", .{}); + Global.crash(); + } + ctx.bundler_options.windows.copyright = copyright; } if (args.option("--outdir")) |outdir| { diff --git a/src/cli/build_command.zig b/src/cli/build_command.zig index 1b6772cbe4..2ca273f76a 100644 --- a/src/cli/build_command.zig +++ b/src/cli/build_command.zig @@ -431,8 +431,7 @@ pub const BuildCommand = struct { outfile, this_transpiler.env, this_transpiler.options.output_format, - ctx.bundler_options.windows_hide_console, - ctx.bundler_options.windows_icon, + ctx.bundler_options.windows, ctx.bundler_options.compile_exec_argv orelse "", null, ) catch |err| { diff --git a/src/options.zig b/src/options.zig index 8f69c83ed9..3dccc4c341 100644 --- a/src/options.zig +++ b/src/options.zig @@ -600,6 +600,16 @@ pub const Format = enum { } }; +pub const WindowsOptions = struct { + hide_console: bool = false, + icon: ?[]const u8 = null, + title: ?[]const u8 = null, + publisher: ?[]const u8 = null, + version: ?[]const u8 = null, + description: ?[]const u8 = null, + copyright: ?[]const u8 = null, +}; + pub const Loader = enum(u8) { jsx, js, diff --git a/src/string/immutable/unicode.zig b/src/string/immutable/unicode.zig index c090999f3c..e2206855e0 100644 --- a/src/string/immutable/unicode.zig +++ b/src/string/immutable/unicode.zig @@ -1168,7 +1168,7 @@ pub fn toUTF16Alloc(allocator: std.mem.Allocator, bytes: []const u8, comptime fa if (res.status == .success) { if (comptime sentinel) { out[out_length] = 0; - return out[0 .. out_length + 1 :0]; + return out[0 .. out_length :0]; } return out; } diff --git a/src/windows.zig b/src/windows.zig index 58c3af773e..59d96a1d14 100644 --- a/src/windows.zig +++ b/src/windows.zig @@ -3644,6 +3644,15 @@ pub fn editWin32BinarySubsystem(fd: bun.sys.File, subsystem: Subsystem) !void { pub const rescle = struct { extern fn rescle__setIcon([*:0]const u16, [*:0]const u16) c_int; + extern fn rescle__setWindowsMetadata( + [*:0]const u16, // exe_path + ?[*:0]const u16, // icon_path (nullable) + ?[*:0]const u16, // title (nullable) + ?[*:0]const u16, // publisher (nullable) + ?[*:0]const u16, // version (nullable) + ?[*:0]const u16, // description (nullable) + ?[*:0]const u16, // copyright (nullable) + ) c_int; pub fn setIcon(exe_path: [*:0]const u16, icon: [*:0]const u16) !void { comptime bun.assert(bun.Environment.isWindows); @@ -3653,6 +3662,98 @@ pub const rescle = struct { else => error.IconEditError, }; } + + + pub fn setWindowsMetadata( + exe_path: [*:0]const u16, + icon: ?[]const u8, + title: ?[]const u8, + publisher: ?[]const u8, + version: ?[]const u8, + description: ?[]const u8, + copyright: ?[]const u8, + ) !void { + comptime bun.assert(bun.Environment.isWindows); + + // Validate version string format if provided + if (version) |v| { + // Empty version string is invalid + if (v.len == 0) { + return error.InvalidVersionFormat; + } + + // Basic validation: check format and ranges + var parts_count: u32 = 0; + var iter = std.mem.tokenizeAny(u8, v, "."); + while (iter.next()) |part| : (parts_count += 1) { + if (parts_count >= 4) { + return error.InvalidVersionFormat; + } + const num = std.fmt.parseInt(u16, part, 10) catch { + return error.InvalidVersionFormat; + }; + // u16 already ensures value is 0-65535 + _ = num; + } + if (parts_count == 0) { + return error.InvalidVersionFormat; + } + } + + // Allocate UTF-16 strings + const allocator = bun.default_allocator; + + // Icon is a path, so use toWPathNormalized with proper buffer handling + var icon_buf: bun.OSPathBuffer = undefined; + const icon_w = if (icon) |i| brk: { + const path_w = bun.strings.toWPathNormalized(&icon_buf, i); + // toWPathNormalized returns a slice into icon_buf, need to null-terminate it + const buf_u16 = bun.reinterpretSlice(u16, &icon_buf); + buf_u16[path_w.len] = 0; + break :brk buf_u16[0..path_w.len :0]; + } else null; + + const title_w = if (title) |t| try bun.strings.toUTF16AllocForReal(allocator, t, false, true) else null; + defer if (title_w) |tw| allocator.free(tw); + + const publisher_w = if (publisher) |p| try bun.strings.toUTF16AllocForReal(allocator, p, false, true) else null; + defer if (publisher_w) |pw| allocator.free(pw); + + const version_w = if (version) |v| try bun.strings.toUTF16AllocForReal(allocator, v, false, true) else null; + defer if (version_w) |vw| allocator.free(vw); + + const description_w = if (description) |d| try bun.strings.toUTF16AllocForReal(allocator, d, false, true) else null; + defer if (description_w) |dw| allocator.free(dw); + + const copyright_w = if (copyright) |cr| try bun.strings.toUTF16AllocForReal(allocator, cr, false, true) else null; + defer if (copyright_w) |cw| allocator.free(cw); + + const status = rescle__setWindowsMetadata( + exe_path, + if (icon_w) |iw| iw.ptr else null, + if (title_w) |tw| tw.ptr else null, + if (publisher_w) |pw| pw.ptr else null, + if (version_w) |vw| vw.ptr else null, + if (description_w) |dw| dw.ptr else null, + if (copyright_w) |cw| cw.ptr else null, + ); + return switch (status) { + 0 => {}, + -1 => error.FailedToLoadExecutable, + -2 => error.FailedToSetIcon, + -3 => error.FailedToSetProductName, + -4 => error.FailedToSetCompanyName, + -5 => error.FailedToSetDescription, + -6 => error.FailedToSetCopyright, + -7 => error.FailedToSetFileVersion, + -8 => error.FailedToSetProductVersion, + -9 => error.FailedToSetFileVersionString, + -10 => error.FailedToSetProductVersionString, + -11 => error.InvalidVersionFormat, + -12 => error.FailedToCommit, + else => error.WindowsMetadataEditError, + }; + } }; pub extern "kernel32" fn CloseHandle(hObject: HANDLE) callconv(.winapi) BOOL; diff --git a/test/bundler/compile-windows-metadata.test.ts b/test/bundler/compile-windows-metadata.test.ts new file mode 100644 index 0000000000..524fc629aa --- /dev/null +++ b/test/bundler/compile-windows-metadata.test.ts @@ -0,0 +1,618 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDirWithFiles, isWindows } from "harness"; +import { join } from "path"; +import { execSync } from "child_process"; +import { promises as fs } from "fs"; + +// Helper to ensure executable cleanup +function cleanup(outfile: string) { + return { + [Symbol.asyncDispose]: async () => { + try { + await fs.rm(outfile, { force: true }); + } catch {} + } + }; +} + +describe.skipIf(!isWindows)("Windows compile metadata", () => { + describe("CLI flags", () => { + test("all metadata flags via CLI", async () => { + const dir = tempDirWithFiles("windows-metadata-cli", { + "app.js": `console.log("Test app with metadata");`, + }); + + const outfile = join(dir, "app-with-metadata.exe"); + await using _cleanup = cleanup(outfile); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--compile", + join(dir, "app.js"), + "--outfile", outfile, + "--windows-title", "My Application", + "--windows-publisher", "Test Company Inc", + "--windows-version", "1.2.3.4", + "--windows-description", "A test application with metadata", + "--windows-copyright", "Copyright © 2024 Test Company Inc", + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + + // Verify executable was created + const exists = await Bun.file(outfile).exists(); + expect(exists).toBe(true); + + // Verify metadata using PowerShell + const getMetadata = (field: string) => { + try { + return execSync( + `powershell -Command "(Get-ItemProperty '${outfile}').VersionInfo.${field}"`, + { encoding: "utf8" } + ).trim(); + } catch { + return ""; + } + }; + + expect(getMetadata("ProductName")).toBe("My Application"); + expect(getMetadata("CompanyName")).toBe("Test Company Inc"); + expect(getMetadata("FileDescription")).toBe("A test application with metadata"); + expect(getMetadata("LegalCopyright")).toBe("Copyright © 2024 Test Company Inc"); + expect(getMetadata("ProductVersion")).toBe("1.2.3.4"); + expect(getMetadata("FileVersion")).toBe("1.2.3.4"); + }); + + test("partial metadata flags", async () => { + const dir = tempDirWithFiles("windows-metadata-partial", { + "app.js": `console.log("Partial metadata test");`, + }); + + const outfile = join(dir, "app-partial.exe"); + await using _cleanup = cleanup(outfile); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--compile", + join(dir, "app.js"), + "--outfile", outfile, + "--windows-title", "Simple App", + "--windows-version", "2.0.0.0", + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + + const getMetadata = (field: string) => { + try { + return execSync( + `powershell -Command "(Get-ItemProperty '${outfile}').VersionInfo.${field}"`, + { encoding: "utf8" } + ).trim(); + } catch { + return ""; + } + }; + + expect(getMetadata("ProductName")).toBe("Simple App"); + expect(getMetadata("ProductVersion")).toBe("2.0.0.0"); + expect(getMetadata("FileVersion")).toBe("2.0.0.0"); + }); + + test("windows flags without --compile should error", async () => { + const dir = tempDirWithFiles("windows-no-compile", { + "app.js": `console.log("test");`, + }); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + join(dir, "app.js"), + "--windows-title", "Should Fail", + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([ + proc.stderr.text(), + proc.exited, + ]); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("--windows-title requires --compile"); + }); + + test("windows flags with non-Windows target should error", async () => { + const dir = tempDirWithFiles("windows-wrong-target", { + "app.js": `console.log("test");`, + }); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--compile", + "--target", "bun-linux-x64", + join(dir, "app.js"), + "--windows-title", "Should Fail", + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([ + proc.stderr.text(), + proc.exited, + ]); + + expect(exitCode).not.toBe(0); + // When cross-compiling to non-Windows, it tries to download the target but fails + expect(stderr.toLowerCase()).toContain("target platform"); + }); + }); + + describe("Bun.build() API", () => { + test("all metadata via Bun.build()", async () => { + const dir = tempDirWithFiles("windows-metadata-api", { + "app.js": `console.log("API metadata test");`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "app.js")], + outdir: dir, + compile: { + target: "bun-windows-x64", + outfile: "app-api.exe", + windows: { + title: "API App", + publisher: "API Company", + version: "3.0.0.0", + description: "Built with Bun.build API", + copyright: "© 2024 API Company", + }, + }, + }); + + expect(result.success).toBe(true); + expect(result.outputs.length).toBe(1); + + const outfile = result.outputs[0].path; + await using _cleanup = cleanup(outfile); + + const exists = await Bun.file(outfile).exists(); + expect(exists).toBe(true); + + const getMetadata = (field: string) => { + try { + return execSync( + `powershell -Command "(Get-ItemProperty '${outfile}').VersionInfo.${field}"`, + { encoding: "utf8" } + ).trim(); + } catch { + return ""; + } + }; + + expect(getMetadata("ProductName")).toBe("API App"); + expect(getMetadata("CompanyName")).toBe("API Company"); + expect(getMetadata("FileDescription")).toBe("Built with Bun.build API"); + expect(getMetadata("LegalCopyright")).toBe("© 2024 API Company"); + expect(getMetadata("ProductVersion")).toBe("3.0.0.0"); + }); + + test("partial metadata via Bun.build()", async () => { + const dir = tempDirWithFiles("windows-metadata-api-partial", { + "app.js": `console.log("Partial API test");`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "app.js")], + outdir: dir, + compile: { + target: "bun-windows-x64", + outfile: "partial-api.exe", + windows: { + title: "Partial App", + version: "1.0.0.0", + }, + }, + }); + + expect(result.success).toBe(true); + + const outfile = result.outputs[0].path; + await using _cleanup = cleanup(outfile); + + const getMetadata = (field: string) => { + try { + return execSync( + `powershell -Command "(Get-ItemProperty '${outfile}').VersionInfo.${field}"`, + { encoding: "utf8" } + ).trim(); + } catch { + return ""; + } + }; + + expect(getMetadata("ProductName")).toBe("Partial App"); + expect(getMetadata("ProductVersion")).toBe("1.0.0.0"); + }); + + test("relative outdir with compile", async () => { + const dir = tempDirWithFiles("windows-relative-outdir", { + "app.js": `console.log("Relative outdir test");`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "app.js")], + outdir: "./out", + compile: { + target: "bun-windows-x64", + outfile: "relative.exe", + windows: { + title: "Relative Path App", + }, + }, + }); + + expect(result.success).toBe(true); + expect(result.outputs.length).toBe(1); + + // Should not crash with assertion error + const exists = await Bun.file(result.outputs[0].path).exists(); + expect(exists).toBe(true); + }); + }); + + describe("Version string formats", () => { + const testVersionFormats = [ + { input: "1", expected: "1.0.0.0" }, + { input: "1.2", expected: "1.2.0.0" }, + { input: "1.2.3", expected: "1.2.3.0" }, + { input: "1.2.3.4", expected: "1.2.3.4" }, + { input: "10.20.30.40", expected: "10.20.30.40" }, + { input: "999.999.999.999", expected: "999.999.999.999" }, + ]; + + test.each(testVersionFormats)("version format: $input", async ({ input, expected }) => { + const dir = tempDirWithFiles(`windows-version-${input.replace(/\./g, "-")}`, { + "app.js": `console.log("Version test");`, + }); + + const outfile = join(dir, "version-test.exe"); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--compile", + join(dir, "app.js"), + "--outfile", outfile, + "--windows-version", input, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + + const version = execSync( + `powershell -Command "(Get-ItemProperty '${outfile}').VersionInfo.ProductVersion"`, + { encoding: "utf8" } + ).trim(); + + expect(version).toBe(expected); + }); + + test("invalid version format should error gracefully", async () => { + const dir = tempDirWithFiles("windows-invalid-version", { + "app.js": `console.log("Invalid version test");`, + }); + + const invalidVersions = [ + "not.a.version", + "1.2.3.4.5", + "1.-2.3.4", + "65536.0.0.0", // > 65535 + "", + ]; + + for (const version of invalidVersions) { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--compile", + join(dir, "app.js"), + "--outfile", join(dir, "test.exe"), + "--windows-version", version, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + expect(exitCode).not.toBe(0); + } + }); + }); + + describe("Edge cases", () => { + test("long strings in metadata", async () => { + const dir = tempDirWithFiles("windows-long-strings", { + "app.js": `console.log("Long strings test");`, + }); + + const longString = Buffer.alloc(255, "A").toString(); + const outfile = join(dir, "long-strings.exe"); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--compile", + join(dir, "app.js"), + "--outfile", outfile, + "--windows-title", longString, + "--windows-description", longString, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + + const exists = await Bun.file(outfile).exists(); + expect(exists).toBe(true); + }); + + test("special characters in metadata", async () => { + const dir = tempDirWithFiles("windows-special-chars", { + "app.js": `console.log("Special chars test");`, + }); + + const outfile = join(dir, "special-chars.exe"); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--compile", + join(dir, "app.js"), + "--outfile", outfile, + "--windows-title", "App™ with® Special© Characters", + "--windows-publisher", "Company & Co.", + "--windows-description", "Test \"quotes\" and 'apostrophes'", + "--windows-copyright", "© 2024 ", + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + + const exists = await Bun.file(outfile).exists(); + expect(exists).toBe(true); + + const getMetadata = (field: string) => { + try { + return execSync( + `powershell -Command "(Get-ItemProperty '${outfile}').VersionInfo.${field}"`, + { encoding: "utf8" } + ).trim(); + } catch { + return ""; + } + }; + + expect(getMetadata("ProductName")).toContain("App"); + expect(getMetadata("CompanyName")).toContain("Company & Co."); + }); + + test("unicode in metadata", async () => { + const dir = tempDirWithFiles("windows-unicode", { + "app.js": `console.log("Unicode test");`, + }); + + const outfile = join(dir, "unicode.exe"); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--compile", + join(dir, "app.js"), + "--outfile", outfile, + "--windows-title", "アプリケーション", + "--windows-publisher", "会社名", + "--windows-description", "Émoji test 🚀 🎉", + "--windows-copyright", "© 2024 世界", + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + + const exists = await Bun.file(outfile).exists(); + expect(exists).toBe(true); + }); + + test("empty strings in metadata", async () => { + const dir = tempDirWithFiles("windows-empty-strings", { + "app.js": `console.log("Empty strings test");`, + }); + + const outfile = join(dir, "empty.exe"); + await using _cleanup = cleanup(outfile); + + // Empty strings should be treated as not provided + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--compile", + join(dir, "app.js"), + "--outfile", outfile, + "--windows-title", "", + "--windows-description", "", + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + + const exists = await Bun.file(outfile).exists(); + expect(exists).toBe(true); + }); + }); + + describe("Combined with other compile options", () => { + test("metadata with --windows-hide-console", async () => { + const dir = tempDirWithFiles("windows-metadata-hide-console", { + "app.js": `console.log("Hidden console test");`, + }); + + const outfile = join(dir, "hidden-with-metadata.exe"); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--compile", + join(dir, "app.js"), + "--outfile", outfile, + "--windows-hide-console", + "--windows-title", "Hidden Console App", + "--windows-version", "1.0.0.0", + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + + const exists = await Bun.file(outfile).exists(); + expect(exists).toBe(true); + + const getMetadata = (field: string) => { + try { + return execSync( + `powershell -Command "(Get-ItemProperty '${outfile}').VersionInfo.${field}"`, + { encoding: "utf8" } + ).trim(); + } catch { + return ""; + } + }; + + expect(getMetadata("ProductName")).toBe("Hidden Console App"); + expect(getMetadata("ProductVersion")).toBe("1.0.0.0"); + }); + + test("metadata with --windows-icon", async () => { + // Create a simple .ico file (minimal valid ICO header) + const icoHeader = Buffer.from([ + 0x00, 0x00, // Reserved + 0x01, 0x00, // Type (1 = ICO) + 0x01, 0x00, // Count (1 image) + 0x10, // Width (16) + 0x10, // Height (16) + 0x00, // Color count + 0x00, // Reserved + 0x01, 0x00, // Color planes + 0x20, 0x00, // Bits per pixel + 0x68, 0x01, 0x00, 0x00, // Size + 0x16, 0x00, 0x00, 0x00, // Offset + ]); + + const dir = tempDirWithFiles("windows-metadata-icon", { + "app.js": `console.log("Icon test");`, + "icon.ico": icoHeader, + }); + + const outfile = join(dir, "icon-with-metadata.exe"); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--compile", + join(dir, "app.js"), + "--outfile", outfile, + "--windows-icon", join(dir, "icon.ico"), + "--windows-title", "App with Icon", + "--windows-version", "2.0.0.0", + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + // Icon might fail but metadata should still work + if (exitCode === 0) { + const exists = await Bun.file(outfile).exists(); + expect(exists).toBe(true); + + const getMetadata = (field: string) => { + try { + return execSync( + `powershell -Command "(Get-ItemProperty '${outfile}').VersionInfo.${field}"`, + { encoding: "utf8" } + ).trim(); + } catch { + return ""; + } + }; + + expect(getMetadata("ProductName")).toBe("App with Icon"); + expect(getMetadata("ProductVersion")).toBe("2.0.0.0"); + } + }); + }); +}); + +// Test for non-Windows platforms