Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
98167fe9de feat: add --windows-rc flag for custom Windows resources in compiled executables
This adds support for overriding windows-app-info.rc with `bun build --compile`
using the new --windows-rc=<path> flag, allowing single-file executables on
Windows to customize the properties displayed for the executable.

Changes:
- Add --windows-rc CLI flag that requires --compile and Windows platform
- Implement RC file parsing in rescle-binding.cpp to extract version info,
  string resources, and icon references from .rc files
- Add rescle__applyRCFile() C++ function and corresponding Zig wrapper
- Apply custom RC file resources to PE executable after compilation
- Add comprehensive tests for the new functionality

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 03:18:46 +00:00
7 changed files with 370 additions and 0 deletions

View File

@@ -833,6 +833,7 @@ pub const StandaloneModuleGraph = struct {
output_format: bun.options.Format,
windows_hide_console: bool,
windows_icon: ?[]const u8,
windows_rc: ?[]const u8,
) !void {
const bytes = try toBytes(allocator, module_prefix, output_files, output_format);
if (bytes.len == 0) return;
@@ -884,6 +885,19 @@ pub const StandaloneModuleGraph = struct {
Output.warn("Failed to set executable icon", .{});
};
}
if (windows_rc) |rc_utf8| {
var rc_buf: bun.OSPathBuffer = undefined;
const rc = bun.strings.toWPathNormalized(&rc_buf, rc_utf8);
bun.windows.rescle.applyRCFile(outfile_slice, rc) catch |err| {
switch (err) {
error.RCFileNotFound => Output.warn("Custom RC file not found: {s}", .{rc_utf8}),
error.ExeLoadError => Output.warn("Failed to load executable for RC processing", .{}),
error.CommitError => Output.warn("Failed to commit RC file changes to executable", .{}),
else => Output.warn("Failed to apply custom RC file", .{}),
}
};
}
return;
}

View File

@@ -1,5 +1,9 @@
#include "root.h"
#include "rescle.h"
#include <fstream>
#include <sstream>
#include <regex>
#include <cwchar>
extern "C" int rescle__setIcon(const WCHAR* exeFilename, const WCHAR* iconFilename)
{
@@ -12,3 +16,142 @@ extern "C" int rescle__setIcon(const WCHAR* exeFilename, const WCHAR* iconFilena
return -3;
return 0;
}
extern "C" int rescle__applyRCFile(const WCHAR* exeFilename, const WCHAR* rcFilename)
{
rescle::ResourceUpdater updater;
if (!updater.Load(exeFilename))
return -1;
// Read the RC file
std::wifstream rcFile(rcFilename);
if (!rcFile.is_open())
return -2;
std::wstring line;
bool inVersionInfo = false;
bool inStringFileInfo = false;
bool inBlock = false;
// Regular expressions to parse RC content
std::wregex versionInfoStart(L"VS_VERSION_INFO\\s+VERSIONINFO");
std::wregex stringFileInfoStart(L"BLOCK\\s+\"StringFileInfo\"");
std::wregex blockStart(L"BLOCK\\s+\"[^\"]+\"");
std::wregex valueRegex(L"VALUE\\s+\"([^\"]+)\"\\s*,\\s*\"([^\"]+)\"");
std::wregex iconRegex(L"([A-Z_0-9]+)\\s+ICON\\s+\"([^\"]+)\"");
std::wregex fileVersionRegex(L"FILEVERSION\\s+(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)");
std::wregex productVersionRegex(L"PRODUCTVERSION\\s+(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)");
while (std::getline(rcFile, line)) {
// Trim whitespace
line.erase(0, line.find_first_not_of(L" \t"));
line.erase(line.find_last_not_of(L" \t") + 1);
if (line.empty() || line[0] == L'#' || line.substr(0, 2) == L"//")
continue;
std::wsmatch match;
// Check for ICON resources
if (std::regex_search(line, match, iconRegex)) {
std::wstring iconPath = match[2].str();
// Convert relative path to absolute if needed
if (!updater.SetIcon(iconPath.c_str())) {
// Icon setting failed, but continue processing other resources
}
continue;
}
// Check for VERSION_INFO start
if (std::regex_search(line, versionInfoStart)) {
inVersionInfo = true;
continue;
}
if (!inVersionInfo) continue;
// Check for FILEVERSION
if (std::regex_search(line, match, fileVersionRegex)) {
unsigned short v1 = (unsigned short)std::wcstol(match[1].str().c_str(), nullptr, 10);
unsigned short v2 = (unsigned short)std::wcstol(match[2].str().c_str(), nullptr, 10);
unsigned short v3 = (unsigned short)std::wcstol(match[3].str().c_str(), nullptr, 10);
unsigned short v4 = (unsigned short)std::wcstol(match[4].str().c_str(), nullptr, 10);
updater.SetFileVersion(v1, v2, v3, v4);
continue;
}
// Check for PRODUCTVERSION
if (std::regex_search(line, match, productVersionRegex)) {
unsigned short v1 = (unsigned short)std::wcstol(match[1].str().c_str(), nullptr, 10);
unsigned short v2 = (unsigned short)std::wcstol(match[2].str().c_str(), nullptr, 10);
unsigned short v3 = (unsigned short)std::wcstol(match[3].str().c_str(), nullptr, 10);
unsigned short v4 = (unsigned short)std::wcstol(match[4].str().c_str(), nullptr, 10);
updater.SetProductVersion(v1, v2, v3, v4);
continue;
}
// Check for StringFileInfo
if (std::regex_search(line, stringFileInfoStart)) {
inStringFileInfo = true;
continue;
}
// Check for block start
if (inStringFileInfo && std::regex_search(line, blockStart)) {
inBlock = true;
continue;
}
// Check for END
if (line == L"END") {
if (inBlock) {
inBlock = false;
} else if (inStringFileInfo) {
inStringFileInfo = false;
} else if (inVersionInfo) {
inVersionInfo = false;
}
continue;
}
// Check for VALUE entries
if (inBlock && std::regex_search(line, match, valueRegex)) {
std::wstring key = match[1].str();
std::wstring value = match[2].str();
// Map common RC file keys to rescle constants
if (key == L"FileDescription") {
updater.SetVersionString(L"FileDescription", value.c_str());
} else if (key == L"FileVersion") {
updater.SetVersionString(L"FileVersion", value.c_str());
} else if (key == L"InternalName") {
updater.SetVersionString(L"InternalName", value.c_str());
} else if (key == L"OriginalFilename") {
updater.SetVersionString(L"OriginalFilename", value.c_str());
} else if (key == L"ProductName") {
updater.SetVersionString(L"ProductName", value.c_str());
} else if (key == L"ProductVersion") {
updater.SetVersionString(L"ProductVersion", value.c_str());
} else if (key == L"CompanyName") {
updater.SetVersionString(L"CompanyName", value.c_str());
} else if (key == L"LegalCopyright") {
updater.SetVersionString(L"LegalCopyright", value.c_str());
} else if (key == L"LegalTrademarks") {
updater.SetVersionString(L"LegalTrademarks", value.c_str());
} else if (key == L"Comments") {
updater.SetVersionString(L"Comments", value.c_str());
} else if (key == L"PrivateBuild") {
updater.SetVersionString(L"PrivateBuild", value.c_str());
} else if (key == L"SpecialBuild") {
updater.SetVersionString(L"SpecialBuild", value.c_str());
}
}
}
rcFile.close();
if (!updater.Commit())
return -3;
return 0;
}

View File

@@ -422,6 +422,7 @@ pub const Command = struct {
compile_target: Cli.CompileTarget = .{},
windows_hide_console: bool = false,
windows_icon: ?[]const u8 = null,
windows_rc: ?[]const u8 = null,
};
pub fn create(allocator: std.mem.Allocator, log: *logger.Log, comptime command: Command.Tag) anyerror!Context {

View File

@@ -171,6 +171,7 @@ pub const build_only_params = [_]ParamType{
clap.parseParam("--env <inline|prefix*|disable> 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 <STR> When using --compile targeting Windows, assign an executable icon") catch unreachable,
clap.parseParam("--windows-rc <STR> When using --compile targeting Windows, use a custom Windows resource (.rc) file") 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,
@@ -905,6 +906,17 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
}
ctx.bundler_options.windows_icon = path;
}
if (args.option("--windows-rc")) |path| {
if (!Environment.isWindows) {
Output.errGeneric("Using --windows-rc is only available when compiling on Windows", .{});
Global.crash();
}
if (!ctx.bundler_options.compile) {
Output.errGeneric("--windows-rc requires --compile", .{});
Global.crash();
}
ctx.bundler_options.windows_rc = path;
}
if (args.option("--outdir")) |outdir| {
if (outdir.len > 0) {

View File

@@ -436,6 +436,7 @@ pub const BuildCommand = struct {
this_transpiler.options.output_format,
ctx.bundler_options.windows_hide_console,
ctx.bundler_options.windows_icon,
ctx.bundler_options.windows_rc,
);
const compiled_elapsed = @divTrunc(@as(i64, @truncate(std.time.nanoTimestamp() - bundled_end)), @as(i64, std.time.ns_per_ms));
const compiled_elapsed_digit_count: isize = switch (compiled_elapsed) {

View File

@@ -3644,6 +3644,7 @@ 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__applyRCFile([*:0]const u16, [*:0]const u16) c_int;
pub fn setIcon(exe_path: [*:0]const u16, icon: [*:0]const u16) !void {
comptime bun.assert(bun.Environment.isWindows);
@@ -3653,6 +3654,18 @@ pub const rescle = struct {
else => error.IconEditError,
};
}
pub fn applyRCFile(exe_path: [*:0]const u16, rc_path: [*:0]const u16) !void {
comptime bun.assert(bun.Environment.isWindows);
const status = rescle__applyRCFile(exe_path, rc_path);
return switch (status) {
0 => {},
-1 => error.ExeLoadError,
-2 => error.RCFileNotFound,
-3 => error.CommitError,
else => error.RCEditError,
};
}
};
pub extern "kernel32" fn CloseHandle(hObject: HANDLE) callconv(.winapi) BOOL;

View File

@@ -0,0 +1,186 @@
import { tempDirWithFiles, bunExe, bunEnv, isWindows } from "harness";
import { test, expect, describe } from "bun:test";
import path from "path";
describe("--windows-rc flag", () => {
test.if(isWindows)("should apply custom RC file to compiled executable", async () => {
const dir = tempDirWithFiles("windows-rc-test", {
"index.js": `console.log("Hello from custom RC test!");`,
"custom.rc": `#include "windows.h"
VS_VERSION_INFO VERSIONINFO
FILEVERSION 2,0,0,1
PRODUCTVERSION 2,0,0,1
FILEFLAGSMASK 0x3fL
#ifdef _DEBUG
FILEFLAGS 0x1L
#else
FILEFLAGS 0x0L
#endif
FILEOS 0x4L
FILETYPE 0x1L
FILESUBTYPE 0x0L
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0"
BEGIN
VALUE "FileDescription", "Custom Test Application\\0"
VALUE "FileVersion", "2.0.0.1\\0"
VALUE "InternalName", "test-app\\0"
VALUE "OriginalFilename", "test-app.exe\\0"
VALUE "ProductName", "My Custom Product\\0"
VALUE "ProductVersion", "2.0.0.1\\0"
VALUE "CompanyName", "Test Company Inc.\\0"
VALUE "LegalCopyright", "Copyright (C) 2024 Test Company\\0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END`,
});
const outfile = path.join(dir, "test-app.exe");
const rcFile = path.join(dir, "custom.rc");
// Build the executable with custom RC file
await using proc = Bun.spawn({
cmd: [
bunExe(),
"build",
path.join(dir, "index.js"),
"--compile",
"--windows-rc",
rcFile,
"--outfile",
outfile,
],
env: bunEnv,
cwd: dir,
});
const [exitCode] = await Promise.all([proc.exited]);
expect(exitCode).toBe(0);
// Verify the executable exists
expect(Bun.file(outfile).size).toBeGreaterThan(0);
// Test that the executable runs
const { exitCode: runExitCode, stdout } = Bun.spawnSync({
cmd: [outfile],
env: bunEnv,
stdout: "pipe",
});
expect(runExitCode).toBe(0);
expect(stdout.toString()).toContain("Hello from custom RC test!");
});
test.if(isWindows)("should handle non-existent RC file gracefully", async () => {
const dir = tempDirWithFiles("windows-rc-fail-test", {
"index.js": `console.log("Hello world!");`,
});
const outfile = path.join(dir, "test-app.exe");
const nonExistentRc = path.join(dir, "nonexistent.rc");
// Build should still succeed but warn about missing RC file
await using proc = Bun.spawn({
cmd: [
bunExe(),
"build",
path.join(dir, "index.js"),
"--compile",
"--windows-rc",
nonExistentRc,
"--outfile",
outfile,
],
env: bunEnv,
cwd: dir,
stderr: "pipe",
});
const [exitCode, stderr] = await Promise.all([
proc.exited,
new Response(proc.stderr).text(),
]);
expect(exitCode).toBe(0);
expect(stderr).toContain("Custom RC file not found");
// Verify the executable still exists and runs
expect(Bun.file(outfile).size).toBeGreaterThan(0);
const { exitCode: runExitCode } = Bun.spawnSync({
cmd: [outfile],
env: bunEnv,
});
expect(runExitCode).toBe(0);
});
test.if(!isWindows)("should error when --windows-rc is used on non-Windows", async () => {
const dir = tempDirWithFiles("windows-rc-non-win-test", {
"index.js": `console.log("Hello world!");`,
"custom.rc": "/* dummy rc file */",
});
await using proc = Bun.spawn({
cmd: [
bunExe(),
"build",
path.join(dir, "index.js"),
"--compile",
"--windows-rc",
path.join(dir, "custom.rc"),
"--outfile",
path.join(dir, "test-app"),
],
env: bunEnv,
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [exitCode, stderr] = await Promise.all([
proc.exited,
new Response(proc.stderr).text(),
]);
expect(exitCode).toBe(1);
expect(stderr).toContain("--windows-rc is only available when compiling on Windows");
});
test.if(isWindows)("should error when --windows-rc is used without --compile", async () => {
const dir = tempDirWithFiles("windows-rc-no-compile-test", {
"index.js": `console.log("Hello world!");`,
"custom.rc": "/* dummy rc file */",
});
await using proc = Bun.spawn({
cmd: [
bunExe(),
"build",
path.join(dir, "index.js"),
"--windows-rc",
path.join(dir, "custom.rc"),
"--outfile",
path.join(dir, "test-app.js"),
],
env: bunEnv,
cwd: dir,
stderr: "pipe",
});
const [exitCode, stderr] = await Promise.all([
proc.exited,
new Response(proc.stderr).text(),
]);
expect(exitCode).toBe(1);
expect(stderr).toContain("--windows-rc requires --compile");
});
});