From af111feaab90a5319fdcd159a00753941d59ca05 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Tue, 14 Apr 2026 12:45:35 +0300 Subject: [PATCH] builder: Add a .ico or .png to the .exe file in vlang? (fixes #19211) --- vlib/v/builder/cc.v | 6 + vlib/v/builder/icon.v | 235 ++++++++++++++++++++++++++++++++ vlib/v/builder/icon_nix.c.v | 3 + vlib/v/builder/icon_test.v | 59 ++++++++ vlib/v/builder/icon_windows.c.v | 45 ++++++ vlib/v/builder/msvc_windows.v | 1 + vlib/v/help/build/build-c.txt | 6 + vlib/v/pref/pref.v | 26 ++++ 8 files changed, 381 insertions(+) create mode 100644 vlib/v/builder/icon.v create mode 100644 vlib/v/builder/icon_nix.c.v create mode 100644 vlib/v/builder/icon_test.v create mode 100644 vlib/v/builder/icon_windows.c.v diff --git a/vlib/v/builder/cc.v b/vlib/v/builder/cc.v index d52439179..cfb5cd38c 100644 --- a/vlib/v/builder/cc.v +++ b/vlib/v/builder/cc.v @@ -1022,6 +1022,7 @@ pub fn (mut v Builder) cc() { } return } + v.ensure_windows_icon_flag_is_valid() if v.pref.should_output_to_stdout() { // output to stdout content := os.read_file(v.out_name_c) or { panic(err) } @@ -1229,6 +1230,7 @@ pub fn (mut v Builder) cc() { } break } + v.apply_windows_icon_to_executable() or { verror(err.msg()) } if v.pref.compress { ret := os.system('strip ${os.quoted_path(v.pref.out_name)}') if ret != 0 { @@ -1499,6 +1501,7 @@ fn (mut c Builder) cc_windows_cross() { c.setup_ccompiler_options(c.pref.ccompiler) c.build_thirdparty_obj_files() c.setup_output_name() + icon_object := c.prepare_cross_windows_icon_resource() or { verror(err.msg()) } mut args := []string{} args << '${c.pref.cflags}' args << '-o ${os.quoted_path(c.pref.out_name)}' @@ -1548,6 +1551,9 @@ fn (mut c Builder) cc_windows_cross() { // add the thirdparty .o files, produced by all the #flag directives: args << cflags.c_options_only_object_files() args << os.quoted_path(c.out_name_c) + if icon_object != '' { + args << os.quoted_path(icon_object) + } mut c_options_after_target := []string{} if c.pref.ccompiler == 'msvc' { diff --git a/vlib/v/builder/icon.v b/vlib/v/builder/icon.v new file mode 100644 index 000000000..86ab32088 --- /dev/null +++ b/vlib/v/builder/icon.v @@ -0,0 +1,235 @@ +module builder + +import encoding.binary +import os + +const windows_icon_group_resource_id = 1 +const max_windows_icon_dimension = 256 + +struct WindowsIconImage { + width u8 + height u8 + color_count u8 + planes u16 + bit_count u16 + bytes_in_res u32 + image_data []u8 +} + +struct WindowsIconSize { + width int + height int +} + +fn (b &Builder) ensure_windows_icon_flag_is_valid() { + if b.pref.icon_path == '' { + return + } + if b.pref.os != .windows || b.pref.build_mode == .build_module || b.pref.is_o + || b.pref.is_shared { + verror('`-icon` is supported only when building Windows executables') + } + if b.pref.generate_c_project != '' || b.pref.out_name.ends_with('.c') + || b.pref.out_name.ends_with('.js') || b.pref.should_output_to_stdout() { + verror('`-icon` cannot be used when emitting generated C/JS output instead of a Windows executable') + } +} + +fn (mut b Builder) prepare_cross_windows_icon_resource() !string { + if b.pref.icon_path == '' { + return '' + } + ico_path := b.prepare_windows_icon_ico_path()! + rc_path := b.get_vtmp_filename(b.pref.out_name, '.icon.rc') + obj_path := b.get_vtmp_filename(b.pref.out_name, '.icon.o') + os.write_file(rc_path, '1 ICON ${rc_quoted_string(ico_path)}\n')! + b.pref.cleanup_files << rc_path + b.pref.cleanup_files << obj_path + windres := b.find_windres()! + cmd := '${os.quoted_path(windres)} -i ${os.quoted_path(rc_path)} -o ${os.quoted_path(obj_path)} -O coff' + res := os.execute(cmd) + if res.exit_code != 0 { + return error('failed to compile Windows icon resource with `${windres}`: ${res.output.trim_space()}') + } + return obj_path +} + +fn (mut b Builder) prepare_windows_icon_ico_path() !string { + if b.pref.icon_path == '' { + return '' + } + icon_path := os.real_path(b.pref.icon_path) + if !os.is_file(icon_path) { + return error('icon file `${icon_path}` does not exist') + } + match os.file_ext(icon_path).to_lower_ascii() { + '.ico' { + return icon_path + } + '.png' { + png_bytes := os.read_bytes(icon_path)! + ico_bytes := png_to_ico_bytes(png_bytes)! + ico_path := b.get_vtmp_filename(b.pref.out_name, '.icon.ico') + os.write_file_array(ico_path, ico_bytes)! + b.pref.cleanup_files << ico_path + return ico_path + } + else { + return error('`-icon` accepts only `.ico` or `.png` files') + } + } +} + +fn (b &Builder) find_windres() !string { + compiler_dir := if b.pref.ccompiler.contains('/') || b.pref.ccompiler.contains('\\') { + os.dir(b.pref.ccompiler) + } else { + '' + } + compiler_name := executable_stem(os.file_name(b.pref.ccompiler)) + mut candidates := []string{} + for suffix in ['-gcc', '-clang', '-cc', '-g++', '-clang++'] { + if compiler_name.ends_with(suffix) { + candidates << '${compiler_name[..compiler_name.len - suffix.len]}-windres' + } + } + candidates << 'windres' + candidates << 'llvm-windres' + for candidate in candidates { + for name in [candidate, '${candidate}.exe'] { + if compiler_dir != '' { + full_path := os.join_path(compiler_dir, name) + if os.is_file(full_path) { + return full_path + } + } + if resolved := os.find_abs_path_of_executable(name) { + return resolved + } + } + } + return error('could not find `windres`, which is needed for `-icon` while cross-compiling to Windows') +} + +fn executable_stem(name string) string { + lower_name := name.to_lower_ascii() + if lower_name.ends_with('.exe') { + return name[..name.len - 4] + } + return name +} + +fn rc_quoted_string(path string) string { + return '"' + path.replace('\\', '\\\\').replace('"', '\\"') + '"' +} + +fn parse_ico_file(path string) ![]WindowsIconImage { + return parse_ico_bytes(os.read_bytes(path)!) +} + +fn parse_ico_bytes(data []u8) ![]WindowsIconImage { + if data.len < 6 { + return error('invalid icon file: missing ICO header') + } + if binary.little_endian_u16(data[0..2]) != 0 || binary.little_endian_u16(data[2..4]) != 1 { + return error('invalid icon file: expected an ICO header') + } + image_count := int(binary.little_endian_u16(data[4..6])) + if image_count <= 0 { + return error('invalid icon file: no icon images were found') + } + if data.len < 6 + (image_count * 16) { + return error('invalid icon file: truncated icon directory') + } + mut images := []WindowsIconImage{cap: image_count} + for i := 0; i < image_count; i++ { + entry_offset := 6 + (i * 16) + image_size := int(binary.little_endian_u32(data[entry_offset + 8..entry_offset + 12])) + image_offset := int(binary.little_endian_u32(data[entry_offset + 12..entry_offset + 16])) + image_end := image_offset + image_size + if image_offset < 0 || image_size <= 0 || image_offset > data.len || image_end > data.len { + return error('invalid icon file: icon image ${i + 1} points outside the file') + } + images << WindowsIconImage{ + width: data[entry_offset] + height: data[entry_offset + 1] + color_count: data[entry_offset + 2] + planes: binary.little_endian_u16(data[entry_offset + 4..entry_offset + 6]) + bit_count: binary.little_endian_u16(data[entry_offset + 6..entry_offset + 8]) + bytes_in_res: u32(image_size) + image_data: data[image_offset..image_end].clone() + } + } + return images +} + +fn png_to_ico_bytes(png []u8) ![]u8 { + size := png_dimensions(png)! + mut ico := []u8{cap: 22 + png.len} + append_le_u16(mut ico, 0) + append_le_u16(mut ico, 1) + append_le_u16(mut ico, 1) + ico << u8(if size.width == max_windows_icon_dimension { 0 } else { size.width }) + ico << u8(if size.height == max_windows_icon_dimension { 0 } else { size.height }) + ico << u8(0) + ico << u8(0) + append_le_u16(mut ico, 1) + append_le_u16(mut ico, 32) + append_le_u32(mut ico, u32(png.len)) + append_le_u32(mut ico, u32(22)) + ico << png + return ico +} + +fn png_dimensions(png []u8) !WindowsIconSize { + if png.len < 24 { + return error('invalid PNG icon file: missing PNG header') + } + if png[0] != 0x89 || png[1] != `P` || png[2] != `N` || png[3] != `G` || png[4] != 0x0d + || png[5] != 0x0a || png[6] != 0x1a || png[7] != 0x0a { + return error('invalid PNG icon file: bad PNG signature') + } + if png[12] != `I` || png[13] != `H` || png[14] != `D` || png[15] != `R` { + return error('invalid PNG icon file: missing IHDR chunk') + } + width := int(binary.big_endian_u32(png[16..20])) + height := int(binary.big_endian_u32(png[20..24])) + if width <= 0 || height <= 0 || width > max_windows_icon_dimension + || height > max_windows_icon_dimension { + return error('PNG icons must be between 1x1 and 256x256 pixels') + } + return WindowsIconSize{ + width: width + height: height + } +} + +fn build_group_icon_resource(images []WindowsIconImage) []u8 { + mut data := []u8{cap: 6 + (images.len * 14)} + append_le_u16(mut data, 0) + append_le_u16(mut data, 1) + append_le_u16(mut data, u16(images.len)) + for i, image in images { + data << image.width + data << image.height + data << image.color_count + data << u8(0) + append_le_u16(mut data, image.planes) + append_le_u16(mut data, image.bit_count) + append_le_u32(mut data, image.bytes_in_res) + append_le_u16(mut data, u16(i + 1)) + } + return data +} + +fn append_le_u16(mut data []u8, value u16) { + mut buf := []u8{len: 2} + binary.little_endian_put_u16(mut buf, value) + data << buf +} + +fn append_le_u32(mut data []u8, value u32) { + mut buf := []u8{len: 4} + binary.little_endian_put_u32(mut buf, value) + data << buf +} diff --git a/vlib/v/builder/icon_nix.c.v b/vlib/v/builder/icon_nix.c.v new file mode 100644 index 000000000..b5eb5e256 --- /dev/null +++ b/vlib/v/builder/icon_nix.c.v @@ -0,0 +1,3 @@ +module builder + +fn (mut b Builder) apply_windows_icon_to_executable() ! {} diff --git a/vlib/v/builder/icon_test.v b/vlib/v/builder/icon_test.v new file mode 100644 index 000000000..ba035c784 --- /dev/null +++ b/vlib/v/builder/icon_test.v @@ -0,0 +1,59 @@ +module builder + +import os +import v.pref + +fn test_parse_existing_ico_file() { + icon_path := os.join_path(@VEXEROOT, 'cmd', 'tools', 'vdoc', 'theme', 'favicons', 'favicon.ico') + images := parse_ico_file(icon_path)! + assert images.len > 0 + for image in images { + assert image.image_data.len > 0 + } +} + +fn test_png_to_ico_bytes_roundtrip() { + png_path := os.join_path(@VEXEROOT, 'examples', 'assets', 'logo.png') + png_bytes := os.read_bytes(png_path)! + ico_bytes := png_to_ico_bytes(png_bytes)! + images := parse_ico_bytes(ico_bytes)! + assert images.len == 1 + assert images[0].bytes_in_res == png_bytes.len + assert images[0].image_data == png_bytes +} + +fn test_png_dimensions_reads_valid_png() { + png_path := os.join_path(@VEXEROOT, 'examples', 'assets', 'logo.png') + size := png_dimensions(os.read_bytes(png_path)!)! + assert size.width > 0 + assert size.height > 0 + assert size.width <= max_windows_icon_dimension + assert size.height <= max_windows_icon_dimension +} + +fn test_parse_ico_bytes_rejects_invalid_data() { + if _ := parse_ico_bytes([]u8{}) { + assert false + } else { + assert err.msg().contains('invalid icon file') + } +} + +fn test_windows_icon_flag_parsing() { + target := os.join_path(@VEXEROOT, 'examples', 'hello_world.v') + icon := os.join_path(@VEXEROOT, 'cmd', 'tools', 'vdoc', 'theme', 'favicons', 'favicon.ico') + prefs, _ := pref.parse_args_and_show_errors([], ['', '-os', 'windows', '-icon', icon, target], + false) + assert prefs.icon_path == os.real_path(icon) + assert prefs.build_options.contains('-icon "${os.real_path(icon)}"') +} + +fn test_windows_icon_flag_parsing_with_inline_aliases() { + target := os.join_path(@VEXEROOT, 'examples', 'hello_world.v') + icon := os.join_path(@VEXEROOT, 'examples', 'assets', 'logo.png') + for arg in ['-icon=${icon}', '--icon=${icon}', '-seticon=${icon}', '--seticon=${icon}'] { + prefs, _ := pref.parse_args_and_show_errors([], ['', '-os', 'windows', arg, target], false) + assert prefs.icon_path == os.real_path(icon) + assert prefs.build_options.contains('-icon "${os.real_path(icon)}"') + } +} diff --git a/vlib/v/builder/icon_windows.c.v b/vlib/v/builder/icon_windows.c.v new file mode 100644 index 000000000..ff6575341 --- /dev/null +++ b/vlib/v/builder/icon_windows.c.v @@ -0,0 +1,45 @@ +module builder + +#include + +fn C.BeginUpdateResourceW(pfilename &u16, delete_existing_resources int) voidptr +fn C.UpdateResourceW(update_handle voidptr, type_ voidptr, name voidptr, language u16, data voidptr, data_size u32) int +fn C.EndUpdateResourceW(update_handle voidptr, discard int) int +fn C.GetLastError() u32 + +fn (mut b Builder) apply_windows_icon_to_executable() ! { + if b.pref.icon_path == '' { + return + } + icon_path := b.prepare_windows_icon_ico_path()! + images := parse_ico_file(icon_path)! + if images.len == 0 { + return error('icon file `${icon_path}` does not contain any icon images') + } + exe_path := b.pref.out_name.replace('/', '\\') + update_handle := C.BeginUpdateResourceW(exe_path.to_wide(), 0) + if isnil(update_handle) { + return error('failed to open `${exe_path}` for icon updates (Windows error ${C.GetLastError()})') + } + group_resource := build_group_icon_resource(images) + if C.UpdateResourceW(update_handle, windows_resource_id(14), + windows_resource_id(windows_icon_group_resource_id), 0, &u8(group_resource.data), + u32(group_resource.len)) == 0 { + C.EndUpdateResourceW(update_handle, 1) + return error('failed to write the icon group resource to `${exe_path}` (Windows error ${C.GetLastError()})') + } + for i, image in images { + if C.UpdateResourceW(update_handle, windows_resource_id(3), windows_resource_id(i + 1), 0, + &u8(image.image_data.data), image.bytes_in_res) == 0 { + C.EndUpdateResourceW(update_handle, 1) + return error('failed to write icon image ${i + 1} to `${exe_path}` (Windows error ${C.GetLastError()})') + } + } + if C.EndUpdateResourceW(update_handle, 0) == 0 { + return error('failed to finalize icon updates for `${exe_path}` (Windows error ${C.GetLastError()})') + } +} + +fn windows_resource_id(id int) voidptr { + return voidptr(usize(id)) +} diff --git a/vlib/v/builder/msvc_windows.v b/vlib/v/builder/msvc_windows.v index 112c29c22..584703312 100644 --- a/vlib/v/builder/msvc_windows.v +++ b/vlib/v/builder/msvc_windows.v @@ -416,6 +416,7 @@ pub fn (mut v Builder) cc_msvc() { } else { v.post_process_c_compiler_output(r.full_cl_exe_path, res) } + v.apply_windows_icon_to_executable() or { verror(err.msg()) } // println(res) // println('C OUTPUT:') } diff --git a/vlib/v/help/build/build-c.txt b/vlib/v/help/build/build-c.txt index 35e9d4071..245d5099f 100644 --- a/vlib/v/help/build/build-c.txt +++ b/vlib/v/help/build/build-c.txt @@ -58,6 +58,12 @@ see also `v help build`. When set to `windows` V will generate a `wWinMain` main function, even when you are not compiling `gg` apps. + -icon + --icon= + -seticon + Embed the provided `.ico` or `.png` file as the icon of the generated + Windows executable. Use it only when V is producing a Windows `.exe`. + -showcc Prints the C command that is used to build the program. diff --git a/vlib/v/pref/pref.v b/vlib/v/pref/pref.v index a8edc719c..b25210735 100644 --- a/vlib/v/pref/pref.v +++ b/vlib/v/pref/pref.v @@ -266,6 +266,7 @@ pub mut: relaxed_gcc14 bool = true // turn on the generated pragmas, that make gcc versions > 14 a lot less pedantic. The default is to have those pragmas in the generated C output, so that gcc-14 can be used on Arch etc. // subsystem Subsystem // the type of the window app, that is going to be generated; has no effect on !windows + icon_path string // Windows executable icon file (.ico or .png) is_vls bool json_errors bool // -json-errors, for VLS and other tools new_transform bool // temporary for the new transformer @@ -348,6 +349,23 @@ fn run_code_in_tmp_vfile_and_exit(args []string, mut res Preferences, option_nam exit(tmp_result) } +fn inline_icon_option_value(arg string) ?string { + for prefix in ['-icon=', '--icon=', '-seticon=', '--seticon='] { + if arg.starts_with(prefix) { + return arg[prefix.len..] + } + } + return none +} + +fn set_icon_path(mut res Preferences, raw_path string, option_name string) { + if raw_path == '' { + eprintln_exit('missing value for `${option_name}`') + } + res.icon_path = os.real_path(raw_path) + res.build_options << '-icon "${res.icon_path}"' +} + pub fn parse_args_and_show_errors(known_external_commands []string, args []string, show_output bool) (&Preferences, string) { mut res := &Preferences{} use_v2_requested := '-v2' in args @@ -371,6 +389,10 @@ pub fn parse_args_and_show_errors(known_external_commands []string, args []strin mut command, mut command_idx := '', 0 for i := 0; i < args.len; i++ { arg := args[i] + if inline_icon_path := inline_icon_option_value(arg) { + set_icon_path(mut res, inline_icon_path, arg.all_before('=')) + continue + } match arg { '--' { break @@ -504,6 +526,10 @@ pub fn parse_args_and_show_errors(known_external_commands []string, args []strin } i++ } + '-icon', '--icon', '-seticon', '--seticon' { + set_icon_path(mut res, cmdline.option(args[i..], arg, ''), arg) + i++ + } '-gc' { gc_mode := cmdline.option(args[i..], '-gc', '') match gc_mode { -- 2.39.5