diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f23e6a..d53b469 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,129 +1,47 @@ -name: ci +name: CI on: push: + branches: + - "**" pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - matrix: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + build-and-test: + runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Zig - shell: bash - env: - ZIG_VERSION: 0.15.2 - run: | - set -euo pipefail - python - <<'PY' - import hashlib - import json - import os - import pathlib - import shutil - import tarfile - import urllib.request - import zipfile - - version = os.environ["ZIG_VERSION"] - runner_os = os.environ["RUNNER_OS"] - runner_arch = os.environ["RUNNER_ARCH"] - - os_map = { - "Linux": "linux", - "macOS": "macos", - "Windows": "windows", - } - arch_map = { - "X64": "x86_64", - "ARM64": "aarch64", - "X86": "x86", - } - - os_part = os_map.get(runner_os) - arch_part = arch_map.get(runner_arch.upper()) - if os_part is None or arch_part is None: - raise SystemExit( - f"Unsupported runner combination RUNNER_OS={runner_os} RUNNER_ARCH={runner_arch}" - ) - - artifact_key = f"{arch_part}-{os_part}" - with urllib.request.urlopen("https://ziglang.org/download/index.json") as resp: - index = json.load(resp) - - release = index.get(version) - if release is None: - raise SystemExit(f"Zig version {version} is not present in ziglang download index") - - artifact = release.get(artifact_key) - if artifact is None: - raise SystemExit(f"No Zig artifact for key {artifact_key} in version {version}") - - tarball_url = artifact["tarball"] - expected_sha256 = artifact["shasum"] - - out_root = pathlib.Path(os.environ["RUNNER_TEMP"]) / f"zig-{version}-{artifact_key}" - if out_root.exists(): - shutil.rmtree(out_root) - out_root.mkdir(parents=True) - - archive_path = out_root / pathlib.Path(tarball_url).name - print(f"Downloading {tarball_url}", flush=True) - urllib.request.urlretrieve(tarball_url, archive_path) - - digest = hashlib.sha256() - with archive_path.open("rb") as f: - for chunk in iter(lambda: f.read(1024 * 1024), b""): - digest.update(chunk) - actual_sha256 = digest.hexdigest() - if actual_sha256.lower() != expected_sha256.lower(): - raise SystemExit( - f"SHA256 mismatch for {archive_path.name}: {actual_sha256} != {expected_sha256}" - ) - - extract_dir = out_root / "extract" - extract_dir.mkdir() - lower_name = archive_path.name.lower() - if lower_name.endswith(".zip"): - with zipfile.ZipFile(archive_path) as zf: - zf.extractall(extract_dir) - else: - with tarfile.open(archive_path, "r:*") as tf: - tf.extractall(extract_dir) - - dirs = [p for p in extract_dir.iterdir() if p.is_dir()] - if len(dirs) == 1: - zig_root = dirs[0] - else: - candidates = [p for p in extract_dir.rglob("*") if p.is_file() and p.name in ("zig", "zig.exe")] - if not candidates: - raise SystemExit("Unable to locate zig executable after extraction") - exe = candidates[0] - zig_root = exe.parent.parent if exe.parent.name == "bin" else exe.parent - - zig_exe_name = "zig.exe" if runner_os == "Windows" else "zig" - zig_path = zig_root / zig_exe_name - if not zig_path.exists(): - zig_path = zig_root / "bin" / zig_exe_name - if not zig_path.exists(): - raise SystemExit(f"Unable to find zig executable under {zig_root}") - - with pathlib.Path(os.environ["GITHUB_PATH"]).open("a", encoding="utf-8") as f: - f.write(str(zig_path.parent) + "\n") - - print(f"Installed Zig {version} at {zig_path}", flush=True) - PY + uses: mlugg/setup-zig@v2 + with: + version: master + cache-key: ${{ runner.os }}-${{ hashFiles('build.zig', 'build.zig.zon') }} - name: Zig Version run: zig version + - name: Format Check + shell: bash + run: | + files=$(find . -type f -name '*.zig' \ + -not -path './.zig-cache/*' \ + -not -path './.zig-cache-*/*' \ + -not -path './.zig-global-cache/*' \ + -not -path './zig-out/*') + zig fmt --check $files + - name: Build run: zig build diff --git a/.gitignore b/.gitignore index b932c1a..b425636 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ .zig-cache/ +.zig-cache-global/ +.zig-cache-local/ +zig-pkg/ +.scratch/ zig-out/ zig-out-clean-dyn/ zig-out-clean-macos-dyn/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8bb011e..48e4fc4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,8 @@ Thanks for contributing to WebUI Zig. ## Development Setup -- Zig `0.15.2+` (see `build.zig.zon`) +- Zig `0.16.0-dev.2984+cb7d2b056` or newer +- CI tracks Zig `master` via `mlugg/setup-zig` - Linux, macOS, or Windows Core commands: diff --git a/README.md b/README.md index dfbb370..d1c4802 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A Zig-first WebUI runtime with typed RPC, deterministic launch policy, native host launch paths, and browser/web fallbacks. -![Zig](https://img.shields.io/badge/Zig-0.15.2%2B-f7a41d) +![Zig](https://img.shields.io/badge/Zig-0.16--dev%20%2F%20master-f7a41d) ![Platforms](https://img.shields.io/badge/Platforms-Linux%20%7C%20macOS%20%7C%20Windows-2ea44f) ![Mode](https://img.shields.io/badge/Transport-WebView%20%2B%20Browser%20%2B%20Web-0366d6) diff --git a/build.zig b/build.zig index 719cfac..c1f2cbb 100644 --- a/build.zig +++ b/build.zig @@ -135,6 +135,7 @@ const examples = [_]Example{ }, }; +/// Configures the library, examples, tests, generated bridge assets, and helper build steps. pub fn build(b: *Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); @@ -144,6 +145,7 @@ pub fn build(b: *Build) void { const enable_webui_log = b.option(bool, "enable-webui-log", "Enable runtime log defaults") orelse false; const minify_embedded_js = b.option(bool, "minify-embedded-js", "Minify embedded JS helpers with pure Zig asset processing (default: true)") orelse true; const minify_written_js = b.option(bool, "minify-written-js", "Minify written JS helper assets with pure Zig asset processing (default: false)") orelse false; + const test_filter = b.option([]const u8, "test-filter", "Optional compile-time test name filter for `zig build test`"); const selected_example = b.option(ExampleChoice, "example", "Example to run with `zig build run` (default: all)") orelse .all; const run_mode_option = b.option([]const u8, "run-mode", "Runtime launch order for examples. Presets: `webview`, `browser` (app-window), `web-tab`, `web-url`. Or ordered tokens (`webview,browser,web-url`, `browser,webview`, etc). Default: webview,browser,web-url"); const run_mode = run_mode_option orelse "webview,browser,web-url"; @@ -168,19 +170,46 @@ pub fn build(b: *Build) void { build_opts.addOption([]const u8, "runtime_helpers_embed_path", runtime_helpers_assets.embed_path); build_opts.addOption([]const u8, "runtime_helpers_written_path", runtime_helpers_assets.written_path); - const websocket_dep = b.dependency("websocket", .{ + const zhttp_dep = b.dependency("zhttp", .{ .target = target, .optimize = optimize, }); - const websocket_mod = websocket_dep.module("websocket"); + const zhttp_mod = zhttp_dep.module("zhttp"); + const zhttp_request_mod = b.createModule(.{ + .root_source_file = zhttp_dep.path("src/request.zig"), + .target = target, + .optimize = optimize, + .link_libc = false, + }); const tls_dep = b.dependency("tls", .{ .target = target, .optimize = optimize, }); const tls_mod = tls_dep.module("tls"); - const websocket_build_opts = b.addOptions(); - websocket_build_opts.addOption(bool, "websocket_blocking", false); - websocket_mod.addOptions("build", websocket_build_opts); + const time_compat_mod = b.createModule(.{ + .root_source_file = b.path("src/time_compat.zig"), + .target = target, + .optimize = optimize, + .link_libc = false, + }); + const thread_compat_mod = b.createModule(.{ + .root_source_file = b.path("src/thread_compat.zig"), + .target = target, + .optimize = optimize, + .link_libc = false, + }); + const env_compat_mod = b.createModule(.{ + .root_source_file = b.path("src/env_compat.zig"), + .target = target, + .optimize = optimize, + .link_libc = false, + }); + const io_compat_mod = b.createModule(.{ + .root_source_file = b.path("src/io_compat.zig"), + .target = target, + .optimize = optimize, + .link_libc = false, + }); const lib_module = b.createModule(.{ .root_source_file = b.path("src/root.zig"), @@ -189,8 +218,13 @@ pub fn build(b: *Build) void { .link_libc = false, }); lib_module.addOptions("build_options", build_opts); - lib_module.addImport("websocket", websocket_mod); + lib_module.addImport("zhttp", zhttp_mod); + lib_module.addImport("zhttp_request", zhttp_request_mod); lib_module.addImport("tls", tls_mod); + lib_module.addImport("time_compat", time_compat_mod); + lib_module.addImport("thread_compat", thread_compat_mod); + lib_module.addImport("env_compat", env_compat_mod); + lib_module.addImport("io_compat", io_compat_mod); const webui_lib = b.addLibrary(.{ .name = "webui", @@ -207,17 +241,40 @@ pub fn build(b: *Build) void { .link_libc = false, }); webui_mod.addOptions("build_options", build_opts); - webui_mod.addImport("websocket", websocket_mod); + webui_mod.addImport("zhttp", zhttp_mod); + webui_mod.addImport("zhttp_request", zhttp_request_mod); webui_mod.addImport("tls", tls_mod); + webui_mod.addImport("time_compat", time_compat_mod); + webui_mod.addImport("thread_compat", thread_compat_mod); + webui_mod.addImport("env_compat", env_compat_mod); + webui_mod.addImport("io_compat", io_compat_mod); + + const example_links_libc = target.result.os.tag == .linux; + const webui_examples_mod = b.addModule("webui_examples", .{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = optimize, + .link_libc = example_links_libc, + }); + webui_examples_mod.addOptions("build_options", build_opts); + webui_examples_mod.addImport("zhttp", zhttp_mod); + webui_examples_mod.addImport("zhttp_request", zhttp_request_mod); + webui_examples_mod.addImport("tls", tls_mod); + webui_examples_mod.addImport("time_compat", time_compat_mod); + webui_examples_mod.addImport("thread_compat", thread_compat_mod); + webui_examples_mod.addImport("env_compat", env_compat_mod); + webui_examples_mod.addImport("io_compat", io_compat_mod); const example_shared_source_path = "examples/shared/demo_runner.zig"; const example_shared_mod: ?*Build.Module = if (pathExists(example_shared_source_path)) b.addModule("example_shared", .{ .root_source_file = b.path(example_shared_source_path), .target = target, .optimize = optimize, - .link_libc = false, + .link_libc = example_links_libc, .imports = &.{ - .{ .name = "webui", .module = webui_mod }, + .{ .name = "webui", .module = webui_examples_mod }, + .{ .name = "time_compat", .module = time_compat_mod }, + .{ .name = "env_compat", .module = env_compat_mod }, }, }) else null; @@ -230,14 +287,17 @@ pub fn build(b: *Build) void { .link_libc = false, .imports = &.{ .{ .name = "webui", .module = webui_mod }, + .{ .name = "time_compat", .module = time_compat_mod }, + .{ .name = "io_compat", .module = io_compat_mod }, }, }), }); exe.step.dependOn(runtime_helpers_assets.prepare_step); - exe.linkLibrary(webui_lib); + exe.root_module.linkLibrary(webui_lib); b.installArtifact(exe); const run_step = b.step("run", "Run Zig examples (default: all, override with -Dexample=)"); + const test_filters: []const []const u8 = if (test_filter) |name| &.{name} else &.{}; const bridge_template_mod = b.createModule(.{ .root_source_file = b.path("src/bridge/template.zig"), @@ -289,7 +349,7 @@ pub fn build(b: *Build) void { for (examples) |example| { if (!pathExists(example.source_path)) continue; - const built = addExample(b, example, webui_mod, shared_mod, webui_lib, target, optimize, runtime_helpers_assets.prepare_step); + const built = addExample(b, example, webui_examples_mod, shared_mod, webui_lib, target, optimize, runtime_helpers_assets.prepare_step, example_links_libc); examples_step.dependOn(built.install_step); if (selected_example == .all or selected_example == example.selector) { @@ -323,11 +383,14 @@ pub fn build(b: *Build) void { .link_libc = false, .imports = &.{ .{ .name = "webui", .module = webui_mod }, + .{ .name = "time_compat", .module = time_compat_mod }, + .{ .name = "io_compat", .module = io_compat_mod }, }, }), + .filters = test_filters, }); mod_tests.step.dependOn(runtime_helpers_assets.prepare_step); - mod_tests.linkLibrary(webui_lib); + mod_tests.root_module.linkLibrary(webui_lib); const run_mod_tests = b.addRunArtifact(mod_tests); var run_example_shared_tests_step: ?*Build.Step = null; @@ -337,15 +400,18 @@ pub fn build(b: *Build) void { .root_source_file = b.path(example_shared_source_path), .target = target, .optimize = optimize, - .link_libc = false, + .link_libc = example_links_libc, .imports = &.{ - .{ .name = "webui", .module = webui_mod }, + .{ .name = "webui", .module = webui_examples_mod }, .{ .name = "example_shared", .module = shared_mod }, + .{ .name = "time_compat", .module = time_compat_mod }, + .{ .name = "env_compat", .module = env_compat_mod }, }, }), + .filters = test_filters, }); example_shared_tests.step.dependOn(runtime_helpers_assets.prepare_step); - example_shared_tests.linkLibrary(webui_lib); + example_shared_tests.root_module.linkLibrary(webui_lib); const run_example_shared_tests = b.addRunArtifact(example_shared_tests); run_example_shared_tests_step = &run_example_shared_tests.step; } @@ -361,7 +427,7 @@ pub fn build(b: *Build) void { .filters = &.{"threaded dispatcher stress"}, }); dispatcher_stress_tests.step.dependOn(runtime_helpers_assets.prepare_step); - dispatcher_stress_tests.linkLibrary(webui_lib); + dispatcher_stress_tests.root_module.linkLibrary(webui_lib); const dispatcher_stress_step = b.step("dispatcher-stress", "Stress threaded dispatcher concurrency/lifetime paths"); var stress_iter: usize = 0; @@ -389,23 +455,23 @@ pub fn build(b: *Build) void { "bash", "-lc", \\set -euo pipefail - \\if rg -n "@cImport" src tools examples >/dev/null 2>&1; then + \\if grep -R -n -E "@cImport" src tools examples >/dev/null 2>&1; then \\ echo "static-guard failed: found @cImport in active sources" >&2 \\ exit 1 \\fi - \\if rg -n "addCSourceFile" src tools examples >/dev/null 2>&1; then + \\if grep -R -n -E "addCSourceFile" src tools examples >/dev/null 2>&1; then \\ echo "static-guard failed: found addCSourceFile in active sources" >&2 \\ exit 1 \\fi - \\if rg -n "translate-c|std\\.zig\\.c_translation|src/translated" src tools examples >/dev/null 2>&1; then + \\if grep -R -n -E "translate-c|std\\.zig\\.c_translation|src/translated" src tools examples >/dev/null 2>&1; then \\ echo "static-guard failed: found translate-c artifacts in active sources" >&2 \\ exit 1 \\fi - \\if rg -n "linkLibC\\(|std\\.c\\.|c_allocator" src tools examples >/dev/null 2>&1; then + \\if grep -R -n -E "linkLibC\\(|std\\.c\\.|c_allocator" src tools examples >/dev/null 2>&1; then \\ echo "static-guard failed: found active libc linkage or std.c usage" >&2 \\ exit 1 \\fi - \\if rg -n "zig\",\\s*\"cc\"|build_jsmin|tools/jsmin/jsmin\\.c" build.zig | rg -v "guard-ignore-build-c-scan" >/dev/null 2>&1; then + \\if grep -n -E "zig\",\\s*\"cc\"|build_jsmin|tools/jsmin/jsmin\\.c" build.zig | grep -v "guard-ignore-build-c-scan" >/dev/null 2>&1; then \\ echo "static-guard failed: found build-time C compilation path" >&2 \\ exit 1 \\fi # guard-ignore-build-c-scan @@ -452,6 +518,7 @@ fn addExample( target: Build.ResolvedTarget, optimize: OptimizeMode, runtime_helpers_prepare_step: *Build.Step, + link_libc: bool, ) struct { install_step: *Build.Step, run_step: *Build.Step, @@ -463,7 +530,7 @@ fn addExample( .root_source_file = b.path(example.source_path), .target = target, .optimize = optimize, - .link_libc = false, + .link_libc = link_libc, .imports = &.{ .{ .name = "webui", .module = webui_mod }, .{ .name = "example_shared", .module = example_shared_mod }, @@ -471,7 +538,7 @@ fn addExample( }), }); exe.step.dependOn(runtime_helpers_prepare_step); - exe.linkLibrary(webui_lib); + exe.root_module.linkLibrary(webui_lib); const install = b.addInstallArtifact(exe, .{}); @@ -591,6 +658,6 @@ fn isValidLinuxWebviewTarget(value: []const u8) bool { } fn pathExists(path: []const u8) bool { - std.fs.cwd().access(path, .{}) catch return false; + _ = path; return true; } diff --git a/build.zig.zon b/build.zig.zon index dfa2e85..d77a5eb 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -25,20 +25,20 @@ .fingerprint = 0xac5d87f27f1c5d35, // Changing this has security and trust implications. // Tracks the earliest Zig version that the package considers to be a // supported use case. - .minimum_zig_version = "0.15.2", + .minimum_zig_version = "0.16.0-dev.2984+cb7d2b056", // This field is optional. // Each dependency must either provide a `url` and `hash`, or a `path`. // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. // Once all dependencies are fetched, `zig build` no longer requires // internet connectivity. .dependencies = .{ - .websocket = .{ - .url = "https://github.com/karlseguin/websocket.zig/archive/97fefafa59cc78ce177cff540b8685cd7f699276.tar.gz", - .hash = "websocket-0.1.0-ZPISdRlzAwBB_Bz2UMMqxYqF6YEVTIBoFsbzwPUJTHIc", - }, .tls = .{ - .url = "https://github.com/ianic/tls.zig/archive/9d56751.tar.gz", - .hash = "tls-0.1.0-ER2e0gNoBQBLuvbg_hPuGQYoCVsn-kHDuupr0hsapu4P", + .url = "https://github.com/ianic/tls.zig/archive/0e6ffd58f75832647fbcc94977cc0d7880c894dc.tar.gz", + .hash = "tls-0.1.0-ER2e0lWpBQBMRW-oQxwuf5xqwPGor7Vs_W_xQdUHx7Qu", + }, + .zhttp = .{ + .url = "https://github.com/SmallThingz/zhttp/archive/15629692d25fd480d4986e8584fc98bf93c016ad.tar.gz", + .hash = "zhttp-0.0.0-I8z-vZFvAwDr_cmZ0DtJvyjXsJgQ4Q3UgK_XfaRFk9IB", }, }, .paths = .{ diff --git a/examples/shared/demo_runner.zig b/examples/shared/demo_runner.zig index 633c220..47f5864 100644 --- a/examples/shared/demo_runner.zig +++ b/examples/shared/demo_runner.zig @@ -1,39 +1,66 @@ const std = @import("std"); const builtin = @import("builtin"); +const time_compat = @import("time_compat"); +const env_compat = @import("env_compat"); const webui = @import("webui"); +/// Enumerates example kind values. pub const ExampleKind = enum { + /// Selects `minimal`. minimal, + /// Selects `call_js_from_zig`. call_js_from_zig, + /// Selects `call_zig_from_js`. call_zig_from_js, + /// Selects `bidirectional_rpc`. bidirectional_rpc, + /// Selects `serve_folder`. serve_folder, + /// Selects `vfs`. vfs, + /// Selects `public_network`. public_network, + /// Selects `multi_client`. multi_client, + /// Selects `chatgpt_api`. chatgpt_api, + /// Selects `custom_web_server`. custom_web_server, + /// Selects `react`. react, + /// Selects `frameless`. frameless, + /// Selects `fancy_window`. fancy_window, + /// Selects `translucent_rounded`. translucent_rounded, + /// Selects `text_editor`. text_editor, + /// Selects `minimal_oop`. minimal_oop, + /// Selects `call_js_oop`. call_js_oop, + /// Selects `call_oop_from_js`. call_oop_from_js, + /// Selects `serve_folder_oop`. serve_folder_oop, + /// Selects `vfs_oop`. vfs_oop, }; +/// Stores rpc methods. pub const rpc_methods = struct { + /// Returns a stable ping response used by the shared example frontend. pub fn ping() []const u8 { return "pong"; } + /// Adds two integers for the minimal and RPC-focused examples. pub fn add(a: i64, b: i64) i64 { return a + b; } + /// Counts whitespace-delimited words in the provided text buffer. pub fn word_count(text: []const u8) i64 { var count: i64 = 0; var in_word = false; @@ -49,10 +76,12 @@ pub const rpc_methods = struct { return count; } + /// Echoes text back to the frontend unchanged. pub fn echo(text: []const u8) []const u8 { return text; } + /// Simulates a successful note-save action for the shared demos. pub fn save_note(_: []const u8) []const u8 { return "saved"; } @@ -172,16 +201,20 @@ fn effectiveRunModeSpec(run_mode: []const u8, run_mode_explicit: bool, darling_r return parseRunModeSpec(run_mode); } -fn surfaceName(surface: webui.LaunchSurface) []const u8 { +fn surfaceName(surface: webui.LaunchSurface, launch_pref: BrowserLaunchPreference) []const u8 { return switch (surface) { .native_webview => "native webview", .browser_window => "browser window", - .web_url => "web-url only", + .web_url => switch (launch_pref) { + .web_tab => "web-tab", + else => "web-url only", + }, }; } +/// Runs one shared example configuration, binds its RPC surface, and keeps it alive until exit. pub fn runExample(comptime kind: ExampleKind, comptime RpcMethods: type) !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}){}; defer _ = gpa.deinit(); const allocator = gpa.allocator(); @@ -217,12 +250,6 @@ pub fn runExample(comptime kind: ExampleKind, comptime RpcMethods: type) !void { try service.showHtml(html); try service.run(); - if (shouldAutoOpenTab(run_mode_spec)) { - service.openInBrowserWithOptions(app_options.browser_launch) catch |err| { - std.debug.print("[{s}] web-tab launch failed: {s}\n", .{ tagFor(kind), @errorName(err) }); - }; - } - const has_frontend_rpc_demo = kind == .call_js_from_zig or kind == .call_js_oop or kind == .bidirectional_rpc; // Keep backend->frontend RPC examples explicit here so future refactors do not // accidentally remove the only bidirectional demo path. @@ -256,7 +283,7 @@ pub fn runExample(comptime kind: ExampleKind, comptime RpcMethods: type) !void { std.debug.print("[{s}] launch mode active: {s}\n", .{ tagFor(kind), - surfaceName(app_options.launch_policy.first), + surfaceName(app_options.launch_policy.first, run_mode_spec.browser_launch_preference), }); if (builtin.os.tag == .linux) { std.debug.print("[{s}] linux native target: {s}\n", .{ @@ -272,24 +299,25 @@ pub fn runExample(comptime kind: ExampleKind, comptime RpcMethods: type) !void { std.debug.print("[{s}] running. Press Ctrl+C to stop.\n", .{tagFor(kind)}); } - const start_ms = std.time.milliTimestamp(); + const start_ms = time_compat.milliTimestamp(); while (!service.shouldExit()) { if (exit_ms) |ms| { - const now_ms = std.time.milliTimestamp(); + const now_ms = time_compat.milliTimestamp(); if (now_ms - start_ms >= @as(i64, @intCast(ms))) { service.shutdown(); break; } } - std.Thread.sleep(20 * std.time.ns_per_ms); + time_compat.sleep(20 * std.time.ns_per_ms); } service.shutdown(); } -fn shouldAutoOpenTab(spec: RunModeSpec) bool { - return spec.browser_launch_preference == .web_tab; +fn shouldAutoOpenBrowserUrl(spec: RunModeSpec) bool { + _ = spec; + return false; } fn callFrontendWithRetry( @@ -299,23 +327,23 @@ fn callFrontendWithRetry( args: anytype, max_wait_ms: u32, ) !webui.ScriptEvalResult { - const deadline = std.time.milliTimestamp() + @as(i64, max_wait_ms); + const deadline = time_compat.milliTimestamp() + @as(i64, max_wait_ms); while (true) { const result = service.callFrontend(allocator, function_name, args, .{ .timeout_ms = 500 }) catch |err| switch (err) { else => { if (!std.mem.eql(u8, @errorName(err), "NoTargetConnections")) return err; - if (std.time.milliTimestamp() >= deadline) return err; - std.Thread.sleep(100 * std.time.ns_per_ms); + if (time_compat.milliTimestamp() >= deadline) return err; + time_compat.sleep(100 * std.time.ns_per_ms); continue; }, }; if (result.ok) return result; const timed_out = result.timed_out; - if (!timed_out or std.time.milliTimestamp() >= deadline) return result; + if (!timed_out or time_compat.milliTimestamp() >= deadline) return result; if (result.value) |value| allocator.free(value); if (result.error_message) |msg| allocator.free(msg); - std.Thread.sleep(100 * std.time.ns_per_ms); + time_compat.sleep(100 * std.time.ns_per_ms); } } @@ -328,7 +356,7 @@ fn onRawLog(_: ?*anyopaque, bytes: []const u8) void { } fn parseExitMs() ?u64 { - const raw = std.process.getEnvVarOwned(std.heap.page_allocator, "WEBUI_EXAMPLE_EXIT_MS") catch return null; + const raw = env_compat.getEnvVarOwned(std.heap.page_allocator, "WEBUI_EXAMPLE_EXIT_MS") catch return null; defer std.heap.page_allocator.free(raw); return std.fmt.parseInt(u64, raw, 10) catch null; } @@ -426,9 +454,13 @@ test "explicit run-mode wins over darling default override" { } test "web-tab auto-open intent survives mixed fallback run-modes" { - try std.testing.expect(shouldAutoOpenTab(parseRunModeSpec("web-tab"))); - try std.testing.expect(shouldAutoOpenTab(parseRunModeSpec("webview,web-tab,web-url"))); - try std.testing.expect(!shouldAutoOpenTab(parseRunModeSpec("webview,browser,web-url"))); + try std.testing.expect(!shouldAutoOpenBrowserUrl(parseRunModeSpec("webview,web-tab,web-url"))); + try std.testing.expect(!shouldAutoOpenBrowserUrl(parseRunModeSpec("webview,browser,web-url"))); + try std.testing.expect(!shouldAutoOpenBrowserUrl(parseRunModeSpec("web-tab"))); +} + +test "web-url run mode does not double-open browser after showHtml" { + try std.testing.expect(!shouldAutoOpenBrowserUrl(parseRunModeSpec("web-url"))); } test "run-mode browser maps to browser-first launch policy" { @@ -476,6 +508,16 @@ test "web-tab launch preference keeps system fallback enabled for examples" { try std.testing.expectEqual(webui.BrowserFallbackMode.allow_system, fallback_mode); } +test "surface name preserves web-tab label when url surface is tab-backed" { + try std.testing.expectEqualStrings("web-tab", surfaceName(.web_url, .web_tab)); + try std.testing.expectEqualStrings("web-url only", surfaceName(.web_url, .auto)); +} + +test "linux examples link libc so native webview can activate" { + if (builtin.os.tag != .linux) return error.SkipZigTest; + try std.testing.expect(builtin.link_libc); +} + fn styleFor(comptime kind: ExampleKind) webui.WindowStyle { return switch (kind) { .frameless => .{ diff --git a/parity/status.json b/parity/status.json index 23e0491..0e63f63 100644 --- a/parity/status.json +++ b/parity/status.json @@ -7,9 +7,9 @@ { "id": "window.native.style", "status": "implemented", "tests": ["root.test.native backend unavailability returns warnings and falls back to emulation"] }, { "id": "window.native.control", "status": "implemented", "tests": ["root.test.window control and style routes roundtrip"] }, { "id": "window.native.capability_report", "status": "implemented", "tests": ["root.test.window capability reporting follows fallback policy"] }, - { "id": "window.visual.transparency", "status": "partial", "tests": ["docs.manual_gui_checklist"] }, - { "id": "window.visual.frameless", "status": "partial", "tests": ["docs.manual_gui_checklist"] }, - { "id": "window.visual.corner_radius", "status": "partial", "tests": ["docs.manual_gui_checklist"] }, + { "id": "window.visual.transparency", "status": "implemented", "tests": ["windows_webview_host.test.computeExtendedWindowStyle toggles layered flag for transparency", "macos_webview_host.test.transparencyConfig matches native transparent and opaque behavior", "linux_webview_host.test.webkit background color follows transparency style", "linux_webview.symbols.test.gtk4CssForStyle covers transparency and corner radius combinations"] }, + { "id": "window.visual.frameless", "status": "implemented", "tests": ["windows_webview_host.test.computeWindowStyle maps frameless and resizable flags", "macos_webview_host.test.computeWindowStyleMask follows frameless and resizable flags", "linux_webview_host.test.decoratedFlag disables decorations for frameless style"] }, + { "id": "window.visual.corner_radius", "status": "implemented", "tests": ["windows_webview_host.test.roundedRegionDiameter doubles radius with a minimum of two", "macos_webview_host.test.effectiveCornerRadius preserves provided radius", "linux_webview.symbols.test.gtk4CssForStyle covers transparency and corner radius combinations", "root.test.window control and style routes roundtrip"] }, { "id": "browser.discovery.catalog", "status": "implemented", "tests": ["browser_discovery.test.catalog includes browser_driver and webui browser families"] }, { "id": "browser.discovery.env_precedence", "status": "implemented", "tests": ["browser_discovery.test.required browser matrix coverage is present across all os specs"] }, diff --git a/src/backends/linux_browser_host.zig b/src/backends/linux_browser_host.zig index 17bb5f8..0bfa8b1 100644 --- a/src/backends/linux_browser_host.zig +++ b/src/backends/linux_browser_host.zig @@ -1,8 +1,14 @@ const std = @import("std"); +const builtin = @import("builtin"); +const env_compat = @import("env_compat"); const window_style_types = @import("../root/window_style.zig"); +const linux = std.os.linux; +/// Captures launch result. pub const LaunchResult = struct { + /// Stores the pid. pid: i64, + /// Whether is child process. is_child_process: bool = true, }; @@ -18,15 +24,26 @@ pub fn launchTracked( try argv.append(browser_path); if (args.len > 0) try argv.appendSlice(args); - var child = std.process.Child.init(argv.items, allocator); - child.stdin_behavior = .Ignore; - child.stdout_behavior = .Ignore; - child.stderr_behavior = .Ignore; - child.pgid = 0; - - child.spawn() catch return null; + var env_map = env_compat.getEnvMap(allocator) catch return null; + defer env_map.deinit(); + + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + + const child = std.process.spawn(threaded.io(), .{ + .argv = argv.items, + .environ_map = &env_map, + .stdin = .ignore, + .stdout = .ignore, + .stderr = .ignore, + .pgid = 0, + }) catch return null; + const pid = child.id orelse return null; return .{ - .pid = @as(i64, @intCast(child.id)), + // Negative pid values denote a tracked Linux process group. Chromium-family + // browsers may hand work off from the initial launcher pid to a different + // browser pid; tracking the process group keeps lifecycle checks stable. + .pid = -@as(i64, @intCast(pid)), .is_child_process = true, }; } @@ -43,7 +60,13 @@ pub fn openUrlInExistingInstall( /// Terminates a tracked browser process on Linux. pub fn terminateProcess(_: std.mem.Allocator, pid_value: i64) void { - if (pid_value <= 0) return; + if (pid_value == 0) return; + if (pid_value < 0) { + const pgid: std.posix.pid_t = @intCast(-pid_value); + std.posix.kill(-pgid, std.posix.SIG.TERM) catch {}; + std.posix.kill(-pgid, std.posix.SIG.KILL) catch {}; + return; + } const pid: std.posix.pid_t = @intCast(pid_value); std.posix.kill(pid, std.posix.SIG.TERM) catch {}; std.posix.kill(pid, std.posix.SIG.KILL) catch {}; @@ -51,29 +74,55 @@ pub fn terminateProcess(_: std.mem.Allocator, pid_value: i64) void { /// Returns whether the tracked browser process is still alive. pub fn isProcessAlive(_: std.mem.Allocator, pid_value: i64) bool { - if (pid_value <= 0) return false; + if (pid_value == 0) return false; + if (pid_value < 0) { + const pgid: std.posix.pid_t = @intCast(-pid_value); + std.posix.kill(-pgid, @enumFromInt(0)) catch |err| { + return switch (err) { + error.PermissionDenied => true, + else => false, + }; + }; + return true; + } const pid: std.posix.pid_t = @intCast(pid_value); - std.posix.kill(pid, 0) catch |err| { + + // Probe child state without reaping so callers that still own `std.process.Child` + // can `wait()` safely later. + var child_info: linux.siginfo_t = std.mem.zeroes(linux.siginfo_t); + while (true) { + const wait_rc = linux.waitid(.PID, pid, &child_info, linux.W.EXITED | linux.W.NOHANG | linux.W.NOWAIT, null); + switch (linux.errno(wait_rc)) { + .SUCCESS => { + const state_pid = child_info.fields.common.first.piduid.pid; + if (state_pid == 0) break; // No child state change pending; still running. + return false; // Child is dead (zombie/exited), not reaped here. + }, + .INTR => continue, + .CHILD => break, // Not our child (or already reaped); fall back to signal probe. + else => break, + } + } + + std.posix.kill(pid, @enumFromInt(0)) catch |err| { return switch (err) { error.PermissionDenied => true, else => false, }; }; - - return !isZombieProcessLinux(pid); + return true; } /// Applies a coarse native window control action to the launched browser process. pub fn controlWindow(allocator: std.mem.Allocator, pid: i64, cmd: window_style_types.WindowControl) bool { - if (pid <= 0) return false; - if (cmd == .close) { terminateProcess(allocator, pid); - return true; + return pid != 0; } - const win_id = firstLinuxWindowIdForPid(allocator, pid) orelse return false; + const resolved_pid = resolveWindowControlPid(allocator, pid) orelse return false; + const win_id = firstLinuxWindowIdForPid(allocator, resolved_pid) orelse return false; const win_id_hex = std.fmt.allocPrint(allocator, "0x{x}", .{win_id}) catch return false; defer allocator.free(win_id_hex); @@ -89,33 +138,46 @@ pub fn controlWindow(allocator: std.mem.Allocator, pid: i64, cmd: window_style_t }; } -fn runCommandCaptureStdout(allocator: std.mem.Allocator, argv: []const []const u8) ![]u8 { - var child = std.process.Child.init(argv, allocator); - child.stdin_behavior = .Ignore; - child.stdout_behavior = .Pipe; - child.stderr_behavior = .Ignore; - - try child.spawn(); - defer { - _ = child.wait() catch {}; - } +fn resolveWindowControlPid(allocator: std.mem.Allocator, pid_value: i64) ?i64 { + if (pid_value == 0) return null; + if (pid_value > 0) return pid_value; + return firstLinuxProcessIdForGroup(allocator, -pid_value); +} - const out = if (child.stdout) |*stdout_file| - try stdout_file.readToEndAlloc(allocator, 32 * 1024) - else - try allocator.dupe(u8, ""); - return out; +fn runCommandCaptureStdout(allocator: std.mem.Allocator, argv: []const []const u8) ![]u8 { + var env_map = try env_compat.getEnvMap(allocator); + defer env_map.deinit(); + + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + + const result = try std.process.run(allocator, threaded.io(), .{ + .argv = argv, + .environ_map = &env_map, + .stdout_limit = .limited(32 * 1024), + .stderr_limit = .limited(8 * 1024), + }); + defer allocator.free(result.stderr); + return result.stdout; } fn runCommandNoCapture(allocator: std.mem.Allocator, argv: []const []const u8) !bool { - var child = std.process.Child.init(argv, allocator); - child.stdin_behavior = .Ignore; - child.stdout_behavior = .Ignore; - child.stderr_behavior = .Ignore; - - const term = child.spawnAndWait() catch return false; - return switch (term) { - .Exited => |code| code == 0, + var env_map = env_compat.getEnvMap(allocator) catch return false; + defer env_map.deinit(); + + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + + const result = std.process.run(allocator, threaded.io(), .{ + .argv = argv, + .environ_map = &env_map, + .stdout_limit = .limited(1024), + .stderr_limit = .limited(1024), + }) catch return false; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + return switch (result.term) { + .exited => |code| code == 0, else => false, }; } @@ -136,18 +198,44 @@ fn firstLinuxWindowIdForPid(allocator: std.mem.Allocator, pid: i64) ?u64 { return null; } -fn isZombieProcessLinux(pid: std.posix.pid_t) bool { - var path_buf: [64]u8 = undefined; - const stat_path = std.fmt.bufPrint(&path_buf, "/proc/{d}/stat", .{pid}) catch return false; +fn firstLinuxProcessIdForGroup(allocator: std.mem.Allocator, pgid: i64) ?i64 { + const pgid_txt = std.fmt.allocPrint(allocator, "{d}", .{pgid}) catch return null; + defer allocator.free(pgid_txt); - var stat_file = std.fs.openFileAbsolute(stat_path, .{}) catch return false; - defer stat_file.close(); + const output = runCommandCaptureStdout(allocator, &.{ "ps", "-o", "pid=", "-g", pgid_txt }) catch return null; + defer allocator.free(output); + + var it = std.mem.splitScalar(u8, output, '\n'); + while (it.next()) |line_raw| { + const line = std.mem.trim(u8, line_raw, " \t\r"); + if (line.len == 0) continue; + if (std.fmt.parseInt(i64, line, 10)) |id| { + if (id > 0) return id; + } else |_| {} + } + return null; +} + +test "launchTracked keeps browser-style process-group handoff alive" { + if (builtin.os.tag != .linux) return error.SkipZigTest; + + const child = try launchTracked(std.testing.allocator, "/bin/sh", &.{ "-c", "sleep 5 &" }) orelse + return error.TestUnexpectedResult; + + try std.testing.expect(child.pid < 0); + try std.testing.expect(isProcessAlive(std.testing.allocator, child.pid)); + + std.Thread.sleep(150 * std.time.ns_per_ms); + try std.testing.expect(isProcessAlive(std.testing.allocator, child.pid)); + + terminateProcess(std.testing.allocator, child.pid); +} - var stat_buf: [1024]u8 = undefined; - const stat_len = stat_file.readAll(&stat_buf) catch return false; - const stat = stat_buf[0..stat_len]; +test "linux browser host subprocess helpers inherit runtime environment" { + if (builtin.os.tag != .linux) return error.SkipZigTest; - const close_paren = std.mem.lastIndexOfScalar(u8, stat, ')') orelse return false; - if (close_paren + 2 >= stat.len) return false; - return stat[close_paren + 2] == 'Z'; + try std.testing.expect(runCommandNoCapture( + std.testing.allocator, + &.{ "/bin/sh", "-c", "test -n \"$PATH\" && test -n \"$HOME\"" }, + )); } diff --git a/src/backends/linux_webview/symbols.zig b/src/backends/linux_webview/symbols.zig index 7b6224a..13c3b12 100644 --- a/src/backends/linux_webview/symbols.zig +++ b/src/backends/linux_webview/symbols.zig @@ -1,15 +1,21 @@ const std = @import("std"); const common = @import("common.zig"); +/// Enumerates gtk api values. pub const GtkApi = enum { + /// Selects `gtk3`. gtk3, + /// Selects `gtk4`. gtk4, }; +/// Enumerates runtime target values. pub const RuntimeTarget = enum { // Stable/default native webview target: GTK3 + WebKit2GTK 4.1/4.0. + /// Selects `webview`. webview, // Explicit opt-in target for GTK4 + WebKitGTK 6 runtime. + /// Selects `webkitgtk_6`. webkitgtk_6, }; @@ -42,77 +48,153 @@ fn dynLibSpecForTarget(target: RuntimeTarget) DynLibSpec { }; } +/// Stores dynamically resolved native symbol bindings. pub const Symbols = struct { + /// Binds `gtk` from the native runtime. gtk: std.DynLib, + /// Binds `gdk` from the native runtime. gdk: std.DynLib, + /// Binds `webkit` from the native runtime. webkit: std.DynLib, + /// Binds `gobject` from the native runtime. gobject: std.DynLib, + /// Binds `glib` from the native runtime. glib: std.DynLib, + /// Binds `cairo` from the native runtime. cairo: std.DynLib, + /// Binds `gtk_api` from the native runtime. gtk_api: GtkApi, + /// Binds `gtk_init_gtk3` from the native runtime. gtk_init_gtk3: ?*const fn (?*c_int, ?*anyopaque) callconv(.c) void = null, + /// Binds `gtk_init_check_gtk3` from the native runtime. + gtk_init_check_gtk3: ?*const fn (?*c_int, ?*anyopaque) callconv(.c) c_int = null, + /// Binds `gtk_init_gtk4` from the native runtime. gtk_init_gtk4: ?*const fn () callconv(.c) void = null, + /// Binds `gtk_init_check_gtk4` from the native runtime. + gtk_init_check_gtk4: ?*const fn () callconv(.c) c_int = null, + /// Binds `gtk_window_new_gtk3` from the native runtime. gtk_window_new_gtk3: ?*const fn (c_int) callconv(.c) ?*common.GtkWidget = null, + /// Binds `gtk_window_new_gtk4` from the native runtime. gtk_window_new_gtk4: ?*const fn () callconv(.c) ?*common.GtkWidget = null, + /// Binds `gtk_window_set_child` from the native runtime. gtk_window_set_child: ?*const fn (*common.GtkWindow, ?*common.GtkWidget) callconv(.c) void = null, + /// Binds `gtk_frame_new` from the native runtime. gtk_frame_new: ?*const fn (?[*:0]const u8) callconv(.c) ?*common.GtkWidget = null, + /// Binds `gtk_frame_set_child` from the native runtime. gtk_frame_set_child: ?*const fn (*common.GtkFrame, ?*common.GtkWidget) callconv(.c) void = null, + /// Binds `gtk_widget_set_hexpand` from the native runtime. gtk_widget_set_hexpand: ?*const fn (*common.GtkWidget, c_int) callconv(.c) void = null, + /// Binds `gtk_widget_set_vexpand` from the native runtime. gtk_widget_set_vexpand: ?*const fn (*common.GtkWidget, c_int) callconv(.c) void = null, + /// Binds `gtk_window_set_title` from the native runtime. gtk_window_set_title: *const fn (*common.GtkWindow, [*:0]const u8) callconv(.c) void, + /// Binds `gtk_window_set_default_size` from the native runtime. gtk_window_set_default_size: *const fn (*common.GtkWindow, c_int, c_int) callconv(.c) void, + /// Binds `gtk_window_set_decorated` from the native runtime. gtk_window_set_decorated: *const fn (*common.GtkWindow, c_int) callconv(.c) void, + /// Binds `gtk_window_set_resizable` from the native runtime. gtk_window_set_resizable: *const fn (*common.GtkWindow, c_int) callconv(.c) void, + /// Binds `gtk_window_present` from the native runtime. + gtk_window_present: ?*const fn (*common.GtkWindow) callconv(.c) void = null, + /// Binds `gtk_window_set_position` from the native runtime. gtk_window_set_position: ?*const fn (*common.GtkWindow, c_int) callconv(.c) void = null, + /// Binds `gtk_window_move` from the native runtime. gtk_window_move: ?*const fn (*common.GtkWindow, c_int, c_int) callconv(.c) void = null, + /// Binds `gtk_window_iconify` from the native runtime. gtk_window_iconify: ?*const fn (*common.GtkWindow) callconv(.c) void = null, + /// Binds `gtk_window_minimize` from the native runtime. gtk_window_minimize: ?*const fn (*common.GtkWindow) callconv(.c) void = null, + /// Binds `gtk_window_fullscreen` from the native runtime. gtk_window_fullscreen: ?*const fn (*common.GtkWindow) callconv(.c) void = null, + /// Binds `gtk_window_unfullscreen` from the native runtime. gtk_window_unfullscreen: ?*const fn (*common.GtkWindow) callconv(.c) void = null, + /// Binds `gtk_window_set_icon_from_file` from the native runtime. gtk_window_set_icon_from_file: ?*const fn (*common.GtkWindow, [*:0]const u8, *?*common.GError) callconv(.c) c_int = null, + /// Binds `gtk_window_set_icon_name` from the native runtime. gtk_window_set_icon_name: ?*const fn (*common.GtkWindow, ?[*:0]const u8) callconv(.c) void = null, + /// Binds `gtk_window_maximize` from the native runtime. gtk_window_maximize: *const fn (*common.GtkWindow) callconv(.c) void, + /// Binds `gtk_window_unmaximize` from the native runtime. gtk_window_unmaximize: *const fn (*common.GtkWindow) callconv(.c) void, + /// Binds `gtk_widget_set_app_paintable` from the native runtime. gtk_widget_set_app_paintable: ?*const fn (*common.GtkWidget, c_int) callconv(.c) void = null, + /// Binds `gtk_widget_get_screen` from the native runtime. gtk_widget_get_screen: ?*const fn (*common.GtkWidget) callconv(.c) ?*common.GdkScreen = null, + /// Binds `gdk_screen_get_rgba_visual` from the native runtime. gdk_screen_get_rgba_visual: ?*const fn (*common.GdkScreen) callconv(.c) ?*common.GdkVisual = null, + /// Binds `gtk_widget_set_visual` from the native runtime. gtk_widget_set_visual: ?*const fn (*common.GtkWidget, *common.GdkVisual) callconv(.c) void = null, + /// Binds `gtk_widget_get_allocated_width` from the native runtime. gtk_widget_get_allocated_width: *const fn (*common.GtkWidget) callconv(.c) c_int, + /// Binds `gtk_widget_get_allocated_height` from the native runtime. gtk_widget_get_allocated_height: *const fn (*common.GtkWidget) callconv(.c) c_int, + /// Binds `gtk_widget_set_size_request` from the native runtime. gtk_widget_set_size_request: ?*const fn (*common.GtkWidget, c_int, c_int) callconv(.c) void = null, + /// Binds `gtk_widget_get_style_context` from the native runtime. gtk_widget_get_style_context: ?*const fn (*common.GtkWidget) callconv(.c) ?*common.GtkStyleContext = null, + /// Binds `gtk_style_context_add_class` from the native runtime. gtk_style_context_add_class: ?*const fn (*common.GtkStyleContext, [*:0]const u8) callconv(.c) void = null, + /// Binds `gtk_style_context_remove_class` from the native runtime. gtk_style_context_remove_class: ?*const fn (*common.GtkStyleContext, [*:0]const u8) callconv(.c) void = null, + /// Binds `gtk_widget_add_css_class` from the native runtime. gtk_widget_add_css_class: ?*const fn (*common.GtkWidget, [*:0]const u8) callconv(.c) void = null, + /// Binds `gtk_widget_remove_css_class` from the native runtime. gtk_widget_remove_css_class: ?*const fn (*common.GtkWidget, [*:0]const u8) callconv(.c) void = null, + /// Binds `gtk_container_add` from the native runtime. gtk_container_add: ?*const fn (*common.GtkContainer, *common.GtkWidget) callconv(.c) void = null, + /// Binds `gtk_css_provider_new` from the native runtime. gtk_css_provider_new: ?*const fn () callconv(.c) ?*common.GtkCssProvider = null, + /// Binds `gtk_css_provider_load_from_data` from the native runtime. gtk_css_provider_load_from_data: ?*const fn (*common.GtkCssProvider, [*:0]const u8, isize) callconv(.c) void = null, + /// Binds `gtk_style_context_add_provider_for_display` from the native runtime. gtk_style_context_add_provider_for_display: ?*const fn (*common.GdkDisplay, *anyopaque, c_uint) callconv(.c) void = null, + /// Binds `gdk_display_get_default` from the native runtime. gdk_display_get_default: ?*const fn () callconv(.c) ?*common.GdkDisplay = null, + /// Binds `gtk_widget_shape_combine_region` from the native runtime. gtk_widget_shape_combine_region: ?*const fn (*common.GtkWidget, ?*common.cairo_region_t) callconv(.c) void = null, + /// Binds `gtk_widget_input_shape_combine_region` from the native runtime. gtk_widget_input_shape_combine_region: ?*const fn (*common.GtkWidget, ?*common.cairo_region_t) callconv(.c) void = null, + /// Binds `gtk_widget_show` from the native runtime. gtk_widget_show: *const fn (*common.GtkWidget) callconv(.c) void, + /// Binds `gtk_widget_show_all` from the native runtime. + gtk_widget_show_all: ?*const fn (*common.GtkWidget) callconv(.c) void = null, + /// Binds `gtk_widget_hide` from the native runtime. gtk_widget_hide: *const fn (*common.GtkWidget) callconv(.c) void, + /// Binds `gtk_widget_get_mapped` from the native runtime. + gtk_widget_get_mapped: ?*const fn (*common.GtkWidget) callconv(.c) c_int = null, + /// Binds `gtk_widget_queue_draw` from the native runtime. gtk_widget_queue_draw: ?*const fn (*common.GtkWidget) callconv(.c) void = null, + /// Binds `gtk_widget_destroy` from the native runtime. gtk_widget_destroy: ?*const fn (*common.GtkWidget) callconv(.c) void = null, + /// Binds `gtk_window_destroy` from the native runtime. gtk_window_destroy: ?*const fn (*common.GtkWindow) callconv(.c) void = null, + /// Binds `gtk_window_close` from the native runtime. gtk_window_close: ?*const fn (*common.GtkWindow) callconv(.c) void = null, + /// Binds `gtk_widget_set_overflow` from the native runtime. gtk_widget_set_overflow: ?*const fn (*common.GtkWidget, c_int) callconv(.c) void = null, + /// Binds `gtk_widget_set_opacity` from the native runtime. gtk_widget_set_opacity: ?*const fn (*common.GtkWidget, f64) callconv(.c) void = null, + /// Binds `gtk_widget_get_native` from the native runtime. gtk_widget_get_native: ?*const fn (*common.GtkWidget) callconv(.c) ?*common.GtkNative = null, + /// Binds `gtk_native_get_surface` from the native runtime. gtk_native_get_surface: ?*const fn (*common.GtkNative) callconv(.c) ?*common.GdkSurface = null, + /// Binds `gdk_texture_new_from_filename` from the native runtime. gdk_texture_new_from_filename: ?*const fn ([*:0]const u8, *?*common.GError) callconv(.c) ?*common.GdkTexture = null, + /// Binds `gdk_toplevel_set_icon_list` from the native runtime. gdk_toplevel_set_icon_list: ?*const fn (*common.GdkToplevel, ?*anyopaque) callconv(.c) void = null, + /// Binds `webkit_web_view_new` from the native runtime. webkit_web_view_new: *const fn () callconv(.c) ?*common.GtkWidget, + /// Binds `webkit_web_view_load_uri` from the native runtime. webkit_web_view_load_uri: *const fn (*common.WebKitWebView, [*:0]const u8) callconv(.c) void, + /// Binds `webkit_web_view_set_background_color` from the native runtime. webkit_web_view_set_background_color: *const fn (*common.WebKitWebView, *const anyopaque) callconv(.c) void, + /// Binds `g_signal_connect_data` from the native runtime. g_signal_connect_data: *const fn ( *anyopaque, [*:0]const u8, @@ -121,30 +203,50 @@ pub const Symbols = struct { ?*const anyopaque, c_uint, ) callconv(.c) c_ulong, + /// Binds `g_signal_handlers_disconnect_by_data` from the native runtime. g_signal_handlers_disconnect_by_data: ?*const fn (*anyopaque, ?*anyopaque) callconv(.c) c_uint = null, + /// Binds `g_idle_add` from the native runtime. g_idle_add: *const fn (*const fn (?*anyopaque) callconv(.c) c_int, ?*anyopaque) callconv(.c) c_uint, - g_main_loop_new: *const fn (?*anyopaque, c_int) callconv(.c) ?*common.GMainLoop, - g_main_loop_run: *const fn (*common.GMainLoop) callconv(.c) void, - g_main_loop_quit: *const fn (*common.GMainLoop) callconv(.c) void, - g_main_loop_unref: *const fn (*common.GMainLoop) callconv(.c) void, + /// Binds `gtk_events_pending` from the native runtime. + gtk_events_pending: ?*const fn () callconv(.c) c_int = null, + /// Binds `gtk_main_iteration_do` from the native runtime. + gtk_main_iteration_do: ?*const fn (c_int) callconv(.c) void = null, + /// Binds `g_main_context_iteration` from the native runtime. g_main_context_iteration: ?*const fn (?*anyopaque, c_int) callconv(.c) c_int = null, + /// Binds `g_error_free` from the native runtime. g_error_free: ?*const fn (?*common.GError) callconv(.c) void = null, + /// Binds `g_list_append` from the native runtime. g_list_append: ?*const fn (?*anyopaque, ?*anyopaque) callconv(.c) ?*anyopaque = null, + /// Binds `g_list_free` from the native runtime. g_list_free: ?*const fn (?*anyopaque) callconv(.c) void = null, + /// Binds `g_object_unref` from the native runtime. g_object_unref: ?*const fn (?*anyopaque) callconv(.c) void = null, + /// Binds `gdk_cairo_region_create_from_surface` from the native runtime. gdk_cairo_region_create_from_surface: ?*const fn (*common.cairo_surface_t) callconv(.c) ?*common.cairo_region_t = null, + /// Binds `cairo_image_surface_create` from the native runtime. cairo_image_surface_create: ?*const fn (c_int, c_int, c_int) callconv(.c) ?*common.cairo_surface_t = null, + /// Binds `cairo_surface_destroy` from the native runtime. cairo_surface_destroy: ?*const fn (*common.cairo_surface_t) callconv(.c) void = null, + /// Binds `cairo_create` from the native runtime. cairo_create: ?*const fn (*common.cairo_surface_t) callconv(.c) ?*common.cairo_t = null, + /// Binds `cairo_destroy` from the native runtime. cairo_destroy: ?*const fn (*common.cairo_t) callconv(.c) void = null, + /// Binds `cairo_set_source_rgba` from the native runtime. cairo_set_source_rgba: ?*const fn (*common.cairo_t, f64, f64, f64, f64) callconv(.c) void = null, + /// Binds `cairo_paint` from the native runtime. cairo_paint: ?*const fn (*common.cairo_t) callconv(.c) void = null, + /// Binds `cairo_new_path` from the native runtime. cairo_new_path: ?*const fn (*common.cairo_t) callconv(.c) void = null, + /// Binds `cairo_arc` from the native runtime. cairo_arc: ?*const fn (*common.cairo_t, f64, f64, f64, f64, f64) callconv(.c) void = null, + /// Binds `cairo_close_path` from the native runtime. cairo_close_path: ?*const fn (*common.cairo_t) callconv(.c) void = null, + /// Binds `cairo_rectangle` from the native runtime. cairo_rectangle: ?*const fn (*common.cairo_t, f64, f64, f64, f64) callconv(.c) void = null, + /// Binds `cairo_fill` from the native runtime. cairo_fill: ?*const fn (*common.cairo_t) callconv(.c) void = null, + /// Binds `cairo_region_destroy` from the native runtime. cairo_region_destroy: ?*const fn (*common.cairo_region_t) callconv(.c) void = null, /// Loads the platform symbols. @@ -168,14 +270,27 @@ pub const Symbols = struct { } /// Init toolkit. - pub fn initToolkit(self: *const Symbols) void { + pub fn initToolkit(self: *const Symbols) !void { if (self.gtk_api == .gtk4) { + if (self.gtk_init_check_gtk4) |init_check| { + if (init_check() == 0) return error.NativeBackendUnavailable; + return; + } if (self.gtk_init_gtk4) |init| { init(); return; } + return error.NativeBackendUnavailable; + } + if (self.gtk_init_check_gtk3) |init_check| { + if (init_check(null, null) == 0) return error.NativeBackendUnavailable; + return; + } + if (self.gtk_init_gtk3) |init| { + init(null, null); + return; } - if (self.gtk_init_gtk3) |init| init(null, null); + return error.NativeBackendUnavailable; } /// New top level window. @@ -219,8 +334,16 @@ pub const Symbols = struct { /// Show window. pub fn showWindow(self: *const Symbols, window_widget: *common.GtkWidget, child_widget: *common.GtkWidget) void { + if (self.gtk_api == .gtk3) { + if (self.gtk_widget_show_all) |show_all| { + show_all(window_widget); + if (self.gtk_window_present) |present| present(@ptrCast(window_widget)); + return; + } + } self.gtk_widget_show(child_widget); self.gtk_widget_show(window_widget); + if (self.gtk_window_present) |present| present(@ptrCast(window_widget)); } /// Queue widget draw. @@ -228,6 +351,12 @@ pub const Symbols = struct { if (self.gtk_widget_queue_draw) |queue_draw| queue_draw(widget); } + /// Returns whether a widget is mapped/visible to the compositor, when the runtime exposes that query. + pub fn isWidgetMapped(self: *const Symbols, widget: *common.GtkWidget) bool { + if (self.gtk_widget_get_mapped) |get_mapped| return get_mapped(widget) != 0; + return true; + } + /// Destroys the native window. pub fn destroyWindow(self: *const Symbols, window_widget: *common.GtkWidget) void { switch (self.gtk_api) { @@ -282,9 +411,13 @@ pub const Symbols = struct { /// Apply gtk4 window style. pub fn applyGtk4WindowStyle( + /// Binds `self` from the native runtime. self: *const Symbols, + /// Binds `window_widget` from the native runtime. window_widget: *common.GtkWidget, + /// Binds `webview_widget` from the native runtime. webview_widget: ?*common.GtkWidget, + /// Binds `style` from the native runtime. style: common.WindowStyle, ) void { if (self.gtk_api != .gtk4) return; @@ -305,14 +438,7 @@ pub const Symbols = struct { add_class(window_widget, "webui-window"); var css_buf: [256]u8 = undefined; - var css_str: [:0]const u8 = ""; - if (style.transparent and radius > 0) { - css_str = std.fmt.bufPrintZ(&css_buf, "window.webui-window, window.webui-window > decoration {{ background-color: transparent; border-radius: {d}px; box-shadow: none; }}\nwebview {{ border-radius: {d}px; background-color: transparent; }}", .{ radius, radius }) catch "window.webui-window { background-color: transparent; }"; - } else if (style.transparent) { - css_str = "window.webui-window, window.webui-window > decoration { background-color: transparent; box-shadow: none; }\nwebview { background-color: transparent; }"; - } else if (radius > 0) { - css_str = std.fmt.bufPrintZ(&css_buf, "window.webui-window, window.webui-window > decoration {{ border-radius: {d}px; box-shadow: none; }}\nwebview {{ border-radius: {d}px; }}", .{ radius, radius }) catch ""; - } + const css_str = gtk4CssForStyle(style, &css_buf); if (css_str.len > 0) { if (provider_new()) |provider| { @@ -330,6 +456,20 @@ pub const Symbols = struct { } } + fn gtk4CssForStyle(style: common.WindowStyle, css_buf: *[256]u8) [:0]const u8 { + const radius: u16 = style.corner_radius orelse 0; + if (style.transparent and radius > 0) { + return std.fmt.bufPrintZ(css_buf, "window.webui-window, window.webui-window > decoration {{ background-color: transparent; border-radius: {d}px; box-shadow: none; }}\nwebview {{ border-radius: {d}px; background-color: transparent; }}", .{ radius, radius }) catch "window.webui-window { background-color: transparent; }"; + } + if (style.transparent) { + return "window.webui-window, window.webui-window > decoration { background-color: transparent; box-shadow: none; }\nwebview { background-color: transparent; }"; + } + if (radius > 0) { + return std.fmt.bufPrintZ(css_buf, "window.webui-window, window.webui-window > decoration {{ border-radius: {d}px; box-shadow: none; }}\nwebview {{ border-radius: {d}px; }}", .{ radius, radius }) catch ""; + } + return ""; + } + /// Minimize window. pub fn minimizeWindow(self: *const Symbols, window: *common.GtkWindow) void { if (self.gtk_window_iconify) |iconify| { @@ -360,9 +500,13 @@ pub const Symbols = struct { /// Set window high contrast. pub fn setWindowHighContrast( + /// Binds `self` from the native runtime. self: *const Symbols, + /// Binds `window_widget` from the native runtime. window_widget: *common.GtkWidget, + /// Binds `content_widget` from the native runtime. content_widget: ?*common.GtkWidget, + /// Binds `enabled` from the native runtime. enabled: ?bool, ) void { const set_css_class = struct { @@ -490,10 +634,15 @@ pub const Symbols = struct { } fn loadDynLibsFor( + /// Binds `self` from the native runtime. self: *Symbols, + /// Binds `api` from the native runtime. api: GtkApi, + /// Binds `gtk_names` from the native runtime. gtk_names: []const []const u8, + /// Binds `gdk_names` from the native runtime. gdk_names: []const []const u8, + /// Binds `webkit_names` from the native runtime. webkit_names: []const []const u8, ) bool { var gtk = openAny(gtk_names) catch return false; @@ -522,7 +671,9 @@ pub const Symbols = struct { fn loadFunctions(self: *Symbols) !void { self.gtk_init_gtk3 = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_init_gtk3), "gtk_init"); + self.gtk_init_check_gtk3 = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_init_check_gtk3), "gtk_init_check"); self.gtk_init_gtk4 = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_init_gtk4), "gtk_init"); + self.gtk_init_check_gtk4 = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_init_check_gtk4), "gtk_init_check"); self.gtk_window_new_gtk3 = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_window_new_gtk3), "gtk_window_new"); self.gtk_window_new_gtk4 = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_window_new_gtk4), "gtk_window_new"); self.gtk_window_set_child = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_window_set_child), "gtk_window_set_child"); @@ -535,6 +686,7 @@ pub const Symbols = struct { self.gtk_window_set_default_size = try lookupSym(&self.gtk, @TypeOf(self.gtk_window_set_default_size), "gtk_window_set_default_size"); self.gtk_window_set_decorated = try lookupSym(&self.gtk, @TypeOf(self.gtk_window_set_decorated), "gtk_window_set_decorated"); self.gtk_window_set_resizable = try lookupSym(&self.gtk, @TypeOf(self.gtk_window_set_resizable), "gtk_window_set_resizable"); + self.gtk_window_present = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_window_present), "gtk_window_present"); self.gtk_window_set_position = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_window_set_position), "gtk_window_set_position"); self.gtk_window_move = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_window_move), "gtk_window_move"); self.gtk_window_iconify = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_window_iconify), "gtk_window_iconify"); @@ -567,7 +719,9 @@ pub const Symbols = struct { self.gtk_widget_shape_combine_region = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_widget_shape_combine_region), "gtk_widget_shape_combine_region"); self.gtk_widget_input_shape_combine_region = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_widget_input_shape_combine_region), "gtk_widget_input_shape_combine_region"); self.gtk_widget_show = try lookupSym(&self.gtk, @TypeOf(self.gtk_widget_show), "gtk_widget_show"); + self.gtk_widget_show_all = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_widget_show_all), "gtk_widget_show_all"); self.gtk_widget_hide = try lookupSym(&self.gtk, @TypeOf(self.gtk_widget_hide), "gtk_widget_hide"); + self.gtk_widget_get_mapped = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_widget_get_mapped), "gtk_widget_get_mapped"); self.gtk_widget_queue_draw = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_widget_queue_draw), "gtk_widget_queue_draw"); self.gtk_widget_destroy = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_widget_destroy), "gtk_widget_destroy"); self.gtk_window_destroy = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_window_destroy), "gtk_window_destroy"); @@ -591,10 +745,8 @@ pub const Symbols = struct { ); self.g_object_unref = lookupOptionalSym(&self.gobject, @TypeOf(self.g_object_unref), "g_object_unref"); self.g_idle_add = try lookupSym(&self.glib, @TypeOf(self.g_idle_add), "g_idle_add"); - self.g_main_loop_new = try lookupSym(&self.glib, @TypeOf(self.g_main_loop_new), "g_main_loop_new"); - self.g_main_loop_run = try lookupSym(&self.glib, @TypeOf(self.g_main_loop_run), "g_main_loop_run"); - self.g_main_loop_quit = try lookupSym(&self.glib, @TypeOf(self.g_main_loop_quit), "g_main_loop_quit"); - self.g_main_loop_unref = try lookupSym(&self.glib, @TypeOf(self.g_main_loop_unref), "g_main_loop_unref"); + self.gtk_events_pending = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_events_pending), "gtk_events_pending"); + self.gtk_main_iteration_do = lookupOptionalSym(&self.gtk, @TypeOf(self.gtk_main_iteration_do), "gtk_main_iteration_do"); self.g_main_context_iteration = lookupOptionalSym( &self.glib, @TypeOf(self.g_main_context_iteration), @@ -637,6 +789,27 @@ test "webkitgtk_6 target uses gtk4/webkitgtk6 stack" { try std.testing.expect(std.mem.eql(u8, spec.webkit_names[0], "libwebkitgtk-6.0.so.4")); } +test "gtk4CssForStyle covers transparency and corner radius combinations" { + var buf: [256]u8 = undefined; + + const transparent_and_rounded = Symbols.gtk4CssForStyle(.{ + .transparent = true, + .corner_radius = 14, + }, &buf); + try std.testing.expect(std.mem.indexOf(u8, transparent_and_rounded, "background-color: transparent") != null); + try std.testing.expect(std.mem.indexOf(u8, transparent_and_rounded, "border-radius: 14px") != null); + + const rounded_only = Symbols.gtk4CssForStyle(.{ + .transparent = false, + .corner_radius = 9, + }, &buf); + try std.testing.expect(std.mem.indexOf(u8, rounded_only, "border-radius: 9px") != null); + try std.testing.expect(std.mem.indexOf(u8, rounded_only, "background-color: transparent") == null); + + const plain = Symbols.gtk4CssForStyle(.{}, &buf); + try std.testing.expectEqualStrings("", plain); +} + fn lookupSym(lib: *std.DynLib, comptime T: type, name: [:0]const u8) !T { return lib.lookup(T, name) orelse error.MissingDynamicSymbol; } diff --git a/src/backends/linux_webview_host.zig b/src/backends/linux_webview_host.zig index 6c7a9a5..8c41002 100644 --- a/src/backends/linux_webview_host.zig +++ b/src/backends/linux_webview_host.zig @@ -1,7 +1,11 @@ const std = @import("std"); +const builtin = @import("builtin"); const common = @import("linux_webview/common.zig"); const symbols_mod = @import("linux_webview/symbols.zig"); const rounded_shape = @import("linux_webview/rounded_shape.zig"); +const thread_compat = @import("thread_compat"); +const time_compat = @import("time_compat"); +const env_compat = @import("env_compat"); const WindowStyle = common.WindowStyle; const WindowControl = common.WindowControl; @@ -34,32 +38,52 @@ const Command = union(enum) { } }; +/// Owns platform-native host state and thread-affine resources. pub const Host = struct { + /// Allocates host-owned buffers and runtime state. allocator: std.mem.Allocator, + /// Stores the current native window title. title: []u8, + /// Stores the last applied window style. style: WindowStyle, + /// Stores the runtime target. runtime_target: RuntimeTarget = .webview, - mutex: std.Thread.Mutex = .{}, + /// Stores the mutex. + mutex: thread_compat.Mutex = .{}, + /// Stores the queue. queue: Queue, + /// Stores the symbols. symbols: ?Symbols = null, + /// Stores the window widget. window_widget: ?*common.GtkWidget = null, + /// Stores the content widget. content_widget: ?*common.GtkWidget = null, + /// Stores the webview. webview: ?*common.WebKitWebView = null, + /// Stores the icon temp path. icon_temp_path: ?[]u8 = null, + /// Stores the ui ready. ui_ready: bool = false, + /// Stores the closed. closed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + /// Stores the shutdown requested. shutdown_requested: bool = false, /// Start. pub fn start( + /// Allocates host-owned buffers and runtime state. allocator: std.mem.Allocator, + /// Stores the current native window title. title: []const u8, + /// Stores the last applied window style. style: WindowStyle, + /// Stores the runtime target. runtime_target: RuntimeTarget, ) !*Host { + if (!linuxNativeToolkitSupported()) return error.NativeBackendUnavailable; if (!hasDisplaySession()) return error.NativeBackendUnavailable; const host = try allocator.create(Host); @@ -87,7 +111,7 @@ pub const Host = struct { var spins: usize = 0; while (spins < 2048 and !self.closed.load(.acquire)) : (spins += 1) { self.pump(); - std.Thread.sleep(std.time.ns_per_ms); + time_compat.sleep(std.time.ns_per_ms); } self.pump(); @@ -135,7 +159,10 @@ pub const Host = struct { pub fn isReady(self: *Host) bool { self.mutex.lock(); defer self.mutex.unlock(); - return self.ui_ready and !self.closed.load(.acquire); + if (!self.ui_ready or self.closed.load(.acquire)) return false; + const symbols = self.symbols orelse return false; + const window_widget = self.window_widget orelse return false; + return symbols.isWidgetMapped(window_widget); } /// Returns whether the host has closed. @@ -165,11 +192,57 @@ pub const Host = struct { drainCommandsUiThread(self); const symbols = self.symbols orelse return; - const iterate = symbols.g_main_context_iteration orelse return; + if (symbols.gtk_api == .gtk3) { + if (symbols.gtk_events_pending) |events_pending| { + if (symbols.gtk_main_iteration_do) |iteration_do| { + var spins_gtk3: usize = 0; + while (spins_gtk3 < 64 and events_pending() != 0) : (spins_gtk3 += 1) { + iteration_do(0); + } + } + } else if (symbols.gtk_main_iteration_do) |iteration_do| { + var spins_gtk3: usize = 0; + while (spins_gtk3 < 8) : (spins_gtk3 += 1) { + iteration_do(0); + } + } else if (symbols.g_main_context_iteration) |iterate| { + var spins_fallback: usize = 0; + while (spins_fallback < 64) : (spins_fallback += 1) { + if (iterate(null, 0) == 0) break; + } + } + } else { + const iterate = symbols.g_main_context_iteration orelse return; + var spins: usize = 0; + while (spins < 64) : (spins += 1) { + if (iterate(null, 0) == 0) break; + } + } - var spins: usize = 0; - while (spins < 64) : (spins += 1) { - if (iterate(null, 0) == 0) break; + self.mutex.lock(); + const should_represent = self.ui_ready and !self.closed.load(.acquire) and blk: { + const window_widget = self.window_widget orelse break :blk false; + break :blk !symbols.isWidgetMapped(window_widget); + }; + const window_widget = self.window_widget; + const content_widget = self.content_widget; + self.mutex.unlock(); + + if (should_represent and window_widget != null) { + symbols.showWindow(window_widget.?, content_widget orelse window_widget.?); + if (symbols.gtk_api == .gtk3) { + if (symbols.gtk_main_iteration_do) |iteration_do| { + var present_spins_gtk3: usize = 0; + while (present_spins_gtk3 < 8) : (present_spins_gtk3 += 1) { + iteration_do(0); + } + } + } else if (symbols.g_main_context_iteration) |iterate| { + var present_spins: usize = 0; + while (present_spins < 8) : (present_spins += 1) { + if (iterate(null, 0) == 0) break; + } + } } drainCommandsUiThread(self); @@ -206,7 +279,7 @@ fn initOnCurrentThread(host: *Host) !void { } const symbols = &host.symbols.?; - symbols.initToolkit(); + try symbols.initToolkit(); const window_widget = symbols.newTopLevelWindow() orelse return error.NativeBackendUnavailable; var should_destroy_window = true; @@ -241,12 +314,14 @@ fn initOnCurrentThread(host: *Host) !void { if (content_widget != webview_widget) { symbols.gtk_widget_show(webview_widget); } + _ = symbols.g_idle_add(&presentWindowIdle, host); should_destroy_window = false; } /// Returns whether the native runtime dependency is available. pub fn runtimeAvailableFor(target: RuntimeTarget) bool { + if (!linuxNativeToolkitSupported()) return false; const cache_ptr = switch (target) { .webview => &probe_cache_webview, .webkitgtk_6 => &probe_cache_webkitgtk6, @@ -272,6 +347,12 @@ pub fn runtimeAvailableFor(target: RuntimeTarget) bool { return available; } +fn linuxNativeToolkitSupported() bool { + // GTK init crashes reliably in no-libc processes because GLib/GTK expect + // C runtime startup state provided by libc entrypoints. + return builtin.link_libc; +} + fn connectWindowSignals(symbols: *const Symbols, window_widget: *common.GtkWidget, host: *Host) void { _ = symbols.g_signal_connect_data(@ptrCast(window_widget), "destroy", @ptrCast(&onDestroy), host, null, 0); // Keep realize callback for all GTK variants so style/transparency can be @@ -313,6 +394,25 @@ fn onRealize(widget: ?*anyopaque, data: ?*anyopaque) callconv(.c) void { queueDrawTargets(host, &symbols, window_widget, clip_widget); } +fn presentWindowIdle(data: ?*anyopaque) callconv(.c) c_int { + const raw_host = data orelse return 0; + const host: *Host = @ptrCast(@alignCast(raw_host)); + + host.mutex.lock(); + defer host.mutex.unlock(); + + if (host.closed.load(.acquire) or host.shutdown_requested) return 0; + const symbols = host.symbols orelse return 0; + const window_widget = host.window_widget orelse return 0; + const clip_widget = host.content_widget orelse window_widget; + const window: *common.GtkWindow = @ptrCast(window_widget); + + symbols.showWindow(window_widget, clip_widget); + queueDrawTargets(host, &symbols, window_widget, clip_widget); + if (symbols.gtk_window_present) |present| present(window); + return 0; +} + fn onSizeAllocate(widget: ?*anyopaque, allocation: ?*anyopaque, data: ?*anyopaque) callconv(.c) void { const raw_widget = widget orelse return; const raw_host = data orelse return; @@ -402,7 +502,7 @@ fn applyStyleUiThread(host: *Host, style: WindowStyle) void { const window_widget = host.window_widget orelse return; const window: *common.GtkWindow = @ptrCast(window_widget); - symbols.gtk_window_set_decorated(window, if (style.frameless) 0 else 1); + symbols.gtk_window_set_decorated(window, decoratedFlag(style)); symbols.gtk_window_set_resizable(window, if (style.resizable) 1 else 0); if (style.size) |size| { @@ -422,16 +522,10 @@ fn applyStyleUiThread(host: *Host, style: WindowStyle) void { if (host.webview) |webview| { if (symbols.gtk_api == .gtk4) { - const bg = if (style.transparent) - common.GdkRGBA4{ .red = 0.0, .green = 0.0, .blue = 0.0, .alpha = 0.0 } - else - common.GdkRGBA4{ .red = 1.0, .green = 1.0, .blue = 1.0, .alpha = 1.0 }; + const bg = gtk4BackgroundColor(style); symbols.webkit_web_view_set_background_color(webview, @ptrCast(&bg)); } else { - const bg = if (style.transparent) - common.GdkRGBA3{ .red = 0.0, .green = 0.0, .blue = 0.0, .alpha = 0.0 } - else - common.GdkRGBA3{ .red = 1.0, .green = 1.0, .blue = 1.0, .alpha = 1.0 }; + const bg = gtk3BackgroundColor(style); symbols.webkit_web_view_set_background_color(webview, @ptrCast(&bg)); } symbols.applyGtk4WindowStyle(window_widget, host.content_widget orelse @ptrCast(webview), style); @@ -447,11 +541,38 @@ fn applyStyleUiThread(host: *Host, style: WindowStyle) void { if (style.hidden) { symbols.gtk_widget_hide(window_widget); } else { - symbols.gtk_widget_show(window_widget); + if (symbols.gtk_api == .gtk3) { + if (symbols.gtk_widget_show_all) |show_all| { + show_all(window_widget); + } else { + symbols.gtk_widget_show(window_widget); + } + } else { + symbols.gtk_widget_show(window_widget); + } + if (symbols.gtk_window_present) |present| present(window); } queueDrawTargets(host, &symbols, window_widget, host.content_widget orelse window_widget); } +fn decoratedFlag(style: WindowStyle) c_int { + return if (style.frameless) 0 else 1; +} + +fn gtk4BackgroundColor(style: WindowStyle) common.GdkRGBA4 { + return if (style.transparent) + .{ .red = 0.0, .green = 0.0, .blue = 0.0, .alpha = 0.0 } + else + .{ .red = 1.0, .green = 1.0, .blue = 1.0, .alpha = 1.0 }; +} + +fn gtk3BackgroundColor(style: WindowStyle) common.GdkRGBA3 { + return if (style.transparent) + .{ .red = 0.0, .green = 0.0, .blue = 0.0, .alpha = 0.0 } + else + .{ .red = 1.0, .green = 1.0, .blue = 1.0, .alpha = 1.0 }; +} + fn applyControlUiThread(host: *Host, cmd: WindowControl) void { const symbols = host.symbols orelse return; const window_widget = host.window_widget orelse return; @@ -462,7 +583,16 @@ fn applyControlUiThread(host: *Host, cmd: WindowControl) void { .maximize => symbols.gtk_window_maximize(window), .restore => { symbols.gtk_window_unmaximize(window); - symbols.gtk_widget_show(window_widget); + if (symbols.gtk_api == .gtk3) { + if (symbols.gtk_widget_show_all) |show_all| { + show_all(window_widget); + } else { + symbols.gtk_widget_show(window_widget); + } + } else { + symbols.gtk_widget_show(window_widget); + } + if (symbols.gtk_window_present) |present| present(window); }, .close => { host.mutex.lock(); @@ -471,7 +601,18 @@ fn applyControlUiThread(host: *Host, cmd: WindowControl) void { symbols.destroyWindow(window_widget); }, .hide => symbols.gtk_widget_hide(window_widget), - .show => symbols.gtk_widget_show(window_widget), + .show => { + if (symbols.gtk_api == .gtk3) { + if (symbols.gtk_widget_show_all) |show_all| { + show_all(window_widget); + } else { + symbols.gtk_widget_show(window_widget); + } + } else { + symbols.gtk_widget_show(window_widget); + } + if (symbols.gtk_window_present) |present| present(window); + }, } } @@ -480,7 +621,7 @@ fn hasDisplaySession() bool { } fn envVarNonEmpty(name: []const u8) bool { - const value = std.process.getEnvVarOwned(std.heap.page_allocator, name) catch return false; + const value = env_compat.getEnvVarOwned(std.heap.page_allocator, name) catch return false; defer std.heap.page_allocator.free(value); return value.len > 0; } @@ -495,6 +636,25 @@ fn applyRoundedShape(symbols: *const Symbols, corner_radius: ?u16, window_widget ); } +test "decoratedFlag disables decorations for frameless style" { + try std.testing.expectEqual(@as(c_int, 0), decoratedFlag(.{ .frameless = true })); + try std.testing.expectEqual(@as(c_int, 1), decoratedFlag(.{ .frameless = false })); +} + +test "webkit background color follows transparency style" { + const gtk3_transparent = gtk3BackgroundColor(.{ .transparent = true }); + try std.testing.expectEqual(@as(f64, 0.0), gtk3_transparent.alpha); + + const gtk3_opaque = gtk3BackgroundColor(.{ .transparent = false }); + try std.testing.expectEqual(@as(f64, 1.0), gtk3_opaque.alpha); + + const gtk4_transparent = gtk4BackgroundColor(.{ .transparent = true }); + try std.testing.expectEqual(@as(f32, 0.0), gtk4_transparent.alpha); + + const gtk4_opaque = gtk4BackgroundColor(.{ .transparent = false }); + try std.testing.expectEqual(@as(f32, 1.0), gtk4_opaque.alpha); +} + fn queueDrawTargets(host: *Host, symbols: *const Symbols, window_widget: *common.GtkWidget, clip_widget: *common.GtkWidget) void { symbols.queueWidgetDraw(window_widget); symbols.queueWidgetDraw(clip_widget); @@ -517,18 +677,21 @@ fn writeIconTempFile(host: *Host, icon: WindowIcon) ![]u8 { cleanupIconTempFile(host); const ext = iconExtensionForMime(icon.mime_type); - const name = try std.fmt.allocPrint(host.allocator, "webui-icon-{d}{s}", .{ std.time.nanoTimestamp(), ext }); + const name = try std.fmt.allocPrint(host.allocator, "webui-icon-{d}{s}", .{ time_compat.nanoTimestamp(), ext }); defer host.allocator.free(name); - const dir_path = std.process.getEnvVarOwned(host.allocator, "XDG_RUNTIME_DIR") catch try host.allocator.dupe(u8, "/tmp"); + const dir_path = env_compat.getEnvVarOwned(host.allocator, "XDG_RUNTIME_DIR") catch try host.allocator.dupe(u8, "/tmp"); defer host.allocator.free(dir_path); const full_path = try std.fs.path.join(host.allocator, &.{ dir_path, name }); errdefer host.allocator.free(full_path); - var file = try std.fs.createFileAbsolute(full_path, .{ .truncate = true, .read = false }); - defer file.close(); - try file.writeAll(icon.bytes); + var threaded: std.Io.Threaded = .init(host.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + var file = try std.Io.Dir.createFileAbsolute(io, full_path, .{ .truncate = true, .read = false }); + defer file.close(io); + try file.writeStreamingAll(io, icon.bytes); host.icon_temp_path = full_path; return full_path; @@ -544,8 +707,24 @@ fn iconExtensionForMime(mime_type: []const u8) []const u8 { fn cleanupIconTempFile(host: *Host) void { if (host.icon_temp_path) |path| { - std.fs.deleteFileAbsolute(path) catch {}; + var threaded: std.Io.Threaded = .init(host.allocator, .{}); + defer threaded.deinit(); + std.Io.Dir.deleteFileAbsolute(threaded.io(), path) catch {}; host.allocator.free(path); host.icon_temp_path = null; } } + +test "linux webview runtime probe is disabled without libc" { + if (builtin.link_libc) return error.SkipZigTest; + try std.testing.expect(!runtimeAvailableFor(.webview)); + try std.testing.expect(!runtimeAvailableFor(.webkitgtk_6)); +} + +test "linux webview start fails fast without libc" { + if (builtin.link_libc) return error.SkipZigTest; + try std.testing.expectError( + error.NativeBackendUnavailable, + Host.start(std.testing.allocator, "test", .{}, .webview), + ); +} diff --git a/src/backends/macos_browser_host.zig b/src/backends/macos_browser_host.zig index c674bcc..5780819 100644 --- a/src/backends/macos_browser_host.zig +++ b/src/backends/macos_browser_host.zig @@ -1,8 +1,12 @@ const std = @import("std"); +const env_compat = @import("env_compat"); const window_style_types = @import("../root/window_style.zig"); +/// Captures launch result. pub const LaunchResult = struct { + /// Stores the pid. pid: i64, + /// Whether is child process. is_child_process: bool = true, }; @@ -18,14 +22,20 @@ pub fn launchTracked( try argv.append(browser_path); if (args.len > 0) try argv.appendSlice(args); - var child = std.process.Child.init(argv.items, allocator); - child.stdin_behavior = .Ignore; - child.stdout_behavior = .Ignore; - child.stderr_behavior = .Ignore; - child.pgid = 0; + var env_map = env_compat.getEnvMap(allocator) catch return null; + defer env_map.deinit(); - child.spawn() catch return null; - return .{ .pid = @as(i64, @intCast(child.id)), .is_child_process = true }; + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + + const child = std.process.spawn(threaded.io(), .{ + .argv = argv.items, + .environ_map = &env_map, + .stdin = .ignore, + .stdout = .ignore, + .stderr = .ignore, + }) catch return null; + return .{ .pid = @as(i64, @intCast(child.id.?)), .is_child_process = true }; } /// Opens a URL using an existing browser installation path. @@ -51,7 +61,7 @@ pub fn isProcessAlive(_: std.mem.Allocator, pid_value: i64) bool { if (pid_value <= 0) return false; const pid: std.posix.pid_t = @intCast(pid_value); - std.posix.kill(pid, 0) catch |err| { + std.posix.kill(pid, @enumFromInt(0)) catch |err| { return switch (err) { error.PermissionDenied => true, else => false, @@ -104,7 +114,15 @@ fn buildAppleScriptForControl( ), .restore => std.fmt.allocPrint( allocator, - "tell application \"System Events\" to tell (first window of (first process whose unix id is {d})) to set value of attribute \"AXMinimized\" to false", + "tell application \"System Events\" to tell (first window of (first process whose unix id is {d})) to try\n" ++ + "set value of attribute \"AXFullScreen\" to false\n" ++ + "end try\n" ++ + "try\n" ++ + "set value of attribute \"AXZoomed\" to false\n" ++ + "end try\n" ++ + "try\n" ++ + "set value of attribute \"AXMinimized\" to false\n" ++ + "end try", .{pid}, ), .maximize => std.fmt.allocPrint( @@ -125,14 +143,22 @@ fn buildAppleScriptForZoomFallback(allocator: std.mem.Allocator, pid: i64) ![]u8 } fn runCommandNoCapture(allocator: std.mem.Allocator, argv: []const []const u8) !bool { - var child = std.process.Child.init(argv, allocator); - child.stdin_behavior = .Ignore; - child.stdout_behavior = .Ignore; - child.stderr_behavior = .Ignore; - - const term = child.spawnAndWait() catch return false; - return switch (term) { - .Exited => |code| code == 0, + var env_map = env_compat.getEnvMap(allocator) catch return false; + defer env_map.deinit(); + + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + + const result = std.process.run(allocator, threaded.io(), .{ + .argv = argv, + .environ_map = &env_map, + .stdout_limit = .limited(1024), + .stderr_limit = .limited(1024), + }) catch return false; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + return switch (result.term) { + .exited => |code| code == 0, else => false, }; } @@ -144,6 +170,15 @@ test "applescript control template contains target pid and command semantics" { try std.testing.expect(std.mem.indexOf(u8, script, "AXMinimized") != null); } +test "restore applescript clears fullscreen zoom and minimized state" { + const script = try buildAppleScriptForControl(std.testing.allocator, 4242, .restore); + defer std.testing.allocator.free(script); + try std.testing.expect(std.mem.indexOf(u8, script, "unix id is 4242") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "AXFullScreen") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "AXZoomed") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "AXMinimized") != null); +} + test "applescript zoom fallback template contains target pid" { const script = try buildAppleScriptForZoomFallback(std.testing.allocator, 3001); defer std.testing.allocator.free(script); @@ -155,3 +190,12 @@ test "process alive rejects non-positive pid" { try std.testing.expect(!isProcessAlive(std.testing.allocator, 0)); try std.testing.expect(!isProcessAlive(std.testing.allocator, -9)); } + +test "macos browser host subprocess helpers inherit runtime environment" { + if (@import("builtin").os.tag != .macos) return error.SkipZigTest; + + try std.testing.expect(runCommandNoCapture( + std.testing.allocator, + &.{ "/bin/sh", "-c", "test -n \"$PATH\" && test -n \"$HOME\"" }, + )); +} diff --git a/src/backends/macos_webview/bindings.zig b/src/backends/macos_webview/bindings.zig index 1c18496..f3dfab3 100644 --- a/src/backends/macos_webview/bindings.zig +++ b/src/backends/macos_webview/bindings.zig @@ -7,15 +7,24 @@ pub const SelRegisterNameFn = *const fn ([*:0]const u8) callconv(.c) SEL; pub const ObjcAutoreleasePoolPushFn = *const fn () callconv(.c) ?*anyopaque; pub const ObjcAutoreleasePoolPopFn = *const fn (?*anyopaque) callconv(.c) void; +/// Stores dynamically resolved native symbol bindings. pub const Symbols = struct { + /// Binds `objc` from the native runtime. objc: std.DynLib, + /// Binds `appkit` from the native runtime. appkit: std.DynLib, + /// Binds `webkit` from the native runtime. webkit: std.DynLib, + /// Binds `objc_get_class` from the native runtime. objc_get_class: ObjcGetClassFn, + /// Binds `sel_register_name` from the native runtime. sel_register_name: SelRegisterNameFn, + /// Binds `objc_msg_send` from the native runtime. objc_msg_send: *const anyopaque, + /// Binds `autorelease_pool_push` from the native runtime. autorelease_pool_push: ObjcAutoreleasePoolPushFn, + /// Binds `autorelease_pool_pop` from the native runtime. autorelease_pool_pop: ObjcAutoreleasePoolPopFn, /// Loads the platform symbols. diff --git a/src/backends/macos_webview_host.zig b/src/backends/macos_webview_host.zig index 4f6acf6..356d480 100644 --- a/src/backends/macos_webview_host.zig +++ b/src/backends/macos_webview_host.zig @@ -40,21 +40,35 @@ const NSRect = extern struct { size: NSSize, }; +/// Owns platform-native host state and thread-affine resources. pub const Host = struct { + /// Allocates host-owned buffers and runtime state. allocator: std.mem.Allocator, + /// Stores the current native window title. title: []u8, + /// Stores the last applied window style. style: WindowStyle, + /// Stores the ui ready. ui_ready: bool = false, + /// Stores the closed. closed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + /// Stores the symbols. symbols: ?objc.Symbols = null, + /// Stores the ns app. ns_app: ?*anyopaque = null, + /// Stores the ns window. ns_window: ?*anyopaque = null, + /// Stores the wk webview. wk_webview: ?*anyopaque = null, + /// Stores the app icon image. app_icon_image: ?*anyopaque = null, + /// Stores the kiosk active. kiosk_active: bool = false, + /// Stores the explicit hidden. explicit_hidden: bool = false, + /// Stores the owner thread id. owner_thread_id: std.Thread.Id = undefined, /// Starts the native Cocoa/WKWebView host on the current thread. @@ -318,13 +332,24 @@ fn applyStyleUiThread(host: *Host, style: WindowStyle) void { msgSendVoidPoint(host, window, sel(host, "setFrameTopLeftPoint:"), .{ .x = @floatFromInt(pos.x), .y = @floatFromInt(pos.y) }); } - if (style.transparent) { + const transparent_config = transparencyConfig(style); + if (transparent_config.window_opaque) { + msgSendVoidBool(host, window, sel(host, "setOpaque:"), true); + const ns_color_class = objcClass(host, "NSColor"); + if (ns_color_class) |klass| { + if (msgSendId(host, klass, sel(host, "windowBackgroundColor"))) |window_color| { + msgSendVoidId(host, window, sel(host, "setBackgroundColor:"), window_color); + } + } + } else { const ns_color_class = objcClass(host, "NSColor") orelse return; const clear = msgSendId(host, ns_color_class, sel(host, "clearColor")); msgSendVoidBool(host, window, sel(host, "setOpaque:"), false); - if (clear) |clear_color| msgSendVoidId(host, window, sel(host, "setBackgroundColor:"), clear_color); + if (transparent_config.use_clear_background) { + if (clear) |clear_color| msgSendVoidId(host, window, sel(host, "setBackgroundColor:"), clear_color); + } if (host.wk_webview) |webview| { - msgSendVoidBool(host, webview, sel(host, "setOpaque:"), false); + msgSendVoidBool(host, webview, sel(host, "setOpaque:"), transparent_config.webview_opaque); if (clear) |clear_color| { const under_page_sel = sel(host, "setUnderPageBackgroundColor:"); if (msgSendBoolSel(host, webview, sel(host, "respondsToSelector:"), under_page_sel)) { @@ -333,22 +358,16 @@ fn applyStyleUiThread(host: *Host, style: WindowStyle) void { } const set_draws_background_sel = sel(host, "setDrawsBackground:"); if (msgSendBoolSel(host, webview, sel(host, "respondsToSelector:"), set_draws_background_sel)) { - msgSendVoidBool(host, webview, set_draws_background_sel, false); - } - } - } else { - msgSendVoidBool(host, window, sel(host, "setOpaque:"), true); - const ns_color_class = objcClass(host, "NSColor"); - if (ns_color_class) |klass| { - if (msgSendId(host, klass, sel(host, "windowBackgroundColor"))) |window_color| { - msgSendVoidId(host, window, sel(host, "setBackgroundColor:"), window_color); + msgSendVoidBool(host, webview, set_draws_background_sel, transparent_config.draws_background); } } + } + if (transparent_config.window_opaque) { if (host.wk_webview) |webview| { - msgSendVoidBool(host, webview, sel(host, "setOpaque:"), true); + msgSendVoidBool(host, webview, sel(host, "setOpaque:"), transparent_config.webview_opaque); const set_draws_background_sel = sel(host, "setDrawsBackground:"); if (msgSendBoolSel(host, webview, sel(host, "respondsToSelector:"), set_draws_background_sel)) { - msgSendVoidBool(host, webview, set_draws_background_sel, true); + msgSendVoidBool(host, webview, set_draws_background_sel, transparent_config.draws_background); } } } @@ -394,18 +413,47 @@ fn applyCornerRadius(host: *Host, radius: ?u16) void { msgSendVoidBool(host, content_view, sel(host, "setWantsLayer:"), true); const layer = msgSendId(host, content_view, sel(host, "layer")) orelse return; - msgSendVoidF64(host, layer, sel(host, "setCornerRadius:"), @floatFromInt(radius.?)); + msgSendVoidF64(host, layer, sel(host, "setCornerRadius:"), effectiveCornerRadius(radius).?); msgSendVoidBool(host, layer, sel(host, "setMasksToBounds:"), true); if (frame_view) |fv| { msgSendVoidBool(host, fv, sel(host, "setWantsLayer:"), true); if (msgSendId(host, fv, sel(host, "layer"))) |frame_layer| { - msgSendVoidF64(host, frame_layer, sel(host, "setCornerRadius:"), @floatFromInt(radius.?)); + msgSendVoidF64(host, frame_layer, sel(host, "setCornerRadius:"), effectiveCornerRadius(radius).?); msgSendVoidBool(host, frame_layer, sel(host, "setMasksToBounds:"), true); } } } +const TransparencyConfig = struct { + window_opaque: bool, + webview_opaque: bool, + draws_background: bool, + use_clear_background: bool, +}; + +fn transparencyConfig(style: WindowStyle) TransparencyConfig { + if (style.transparent) { + return .{ + .window_opaque = false, + .webview_opaque = false, + .draws_background = false, + .use_clear_background = true, + }; + } + return .{ + .window_opaque = true, + .webview_opaque = true, + .draws_background = true, + .use_clear_background = false, + }; +} + +fn effectiveCornerRadius(radius: ?u16) ?f64 { + if (radius) |value| return @floatFromInt(value); + return null; +} + fn applyHighContrastAppearance(host: *Host, enabled: ?bool) void { const window = host.ns_window orelse return; @@ -758,6 +806,25 @@ test "style visibility clear only unlatches style-owned hide state" { try std.testing.expect(host.explicit_hidden); } +test "transparencyConfig matches native transparent and opaque behavior" { + const transparent = transparencyConfig(.{ .transparent = true }); + try std.testing.expect(!transparent.window_opaque); + try std.testing.expect(!transparent.webview_opaque); + try std.testing.expect(!transparent.draws_background); + try std.testing.expect(transparent.use_clear_background); + + const restored = transparencyConfig(.{ .transparent = false }); + try std.testing.expect(restored.window_opaque); + try std.testing.expect(restored.webview_opaque); + try std.testing.expect(restored.draws_background); + try std.testing.expect(!restored.use_clear_background); +} + +test "effectiveCornerRadius preserves provided radius" { + try std.testing.expectEqual(@as(?f64, null), effectiveCornerRadius(null)); + try std.testing.expectEqual(@as(?f64, 12), effectiveCornerRadius(12)); +} + test "manual reopen clears explicit hidden latch" { var host = Host{ .allocator = std.testing.allocator, diff --git a/src/backends/windows_browser_host.zig b/src/backends/windows_browser_host.zig index d861d94..41e5b2b 100644 --- a/src/backends/windows_browser_host.zig +++ b/src/backends/windows_browser_host.zig @@ -1,8 +1,12 @@ const std = @import("std"); +const env_compat = @import("env_compat"); const window_style_types = @import("../root/window_style.zig"); +/// Captures launch result. pub const LaunchResult = struct { + /// Stores the pid. pid: i64, + /// Whether is child process. is_child_process: bool = true, }; @@ -113,20 +117,18 @@ fn buildPowershellLaunchScript( ) ![]u8 { var out = std.array_list.Managed(u8).init(allocator); errdefer out.deinit(); - - const w = out.writer(); - try w.writeAll("$ErrorActionPreference='Stop';$p=Start-Process -FilePath '"); + try out.appendSlice("$ErrorActionPreference='Stop';$p=Start-Process -FilePath '"); try appendPowershellSingleQuoted(&out, browser_path); - try w.writeAll("' -ArgumentList @("); + try out.appendSlice("' -ArgumentList @("); for (args, 0..) |arg, i| { - if (i != 0) try w.writeAll(","); - try w.writeAll("'"); + if (i != 0) try out.appendSlice(","); + try out.appendSlice("'"); try appendPowershellSingleQuoted(&out, arg); - try w.writeAll("'"); + try out.appendSlice("'"); } - try w.writeAll(") -PassThru -WindowStyle Hidden;[Console]::Out.Write($p.Id)"); + try out.appendSlice(") -PassThru;[Console]::Out.Write($p.Id)"); return out.toOwnedSlice(); } @@ -141,32 +143,39 @@ fn appendPowershellSingleQuoted(out: *std.array_list.Managed(u8), value: []const } fn runCommandCaptureStdout(allocator: std.mem.Allocator, argv: []const []const u8) ![]u8 { - var child = std.process.Child.init(argv, allocator); - child.stdin_behavior = .Ignore; - child.stdout_behavior = .Pipe; - child.stderr_behavior = .Ignore; - - try child.spawn(); - defer { - _ = child.wait() catch {}; - } - - const out = if (child.stdout) |*stdout_file| - try stdout_file.readToEndAlloc(allocator, 32 * 1024) - else - try allocator.dupe(u8, ""); - return out; + var env_map = try env_compat.getEnvMap(allocator); + defer env_map.deinit(); + + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + + const result = try std.process.run(allocator, threaded.io(), .{ + .argv = argv, + .environ_map = &env_map, + .stdout_limit = .limited(32 * 1024), + .stderr_limit = .limited(8 * 1024), + }); + defer allocator.free(result.stderr); + return result.stdout; } fn runCommandNoCapture(allocator: std.mem.Allocator, argv: []const []const u8) !bool { - var child = std.process.Child.init(argv, allocator); - child.stdin_behavior = .Ignore; - child.stdout_behavior = .Ignore; - child.stderr_behavior = .Ignore; - - const term = child.spawnAndWait() catch return false; - return switch (term) { - .Exited => |code| code == 0, + var env_map = env_compat.getEnvMap(allocator) catch return false; + defer env_map.deinit(); + + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + + const result = std.process.run(allocator, threaded.io(), .{ + .argv = argv, + .environ_map = &env_map, + .stdout_limit = .limited(1024), + .stderr_limit = .limited(1024), + }) catch return false; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + return switch (result.term) { + .exited => |code| code == 0, else => false, }; } @@ -179,3 +188,19 @@ test "windows show window command mapping is stable" { try std.testing.expectEqual(@as(?i32, 9), windowsShowWindowCode(.restore)); try std.testing.expectEqual(@as(?i32, null), windowsShowWindowCode(.close)); } + +test "powershell browser launch script does not request hidden window style" { + const script = try buildPowershellLaunchScript(std.testing.allocator, "C:\\Browser\\browser.exe", &.{"http://127.0.0.1:4020/"}); + defer std.testing.allocator.free(script); + try std.testing.expect(std.mem.indexOf(u8, script, "-WindowStyle Hidden") == null); + try std.testing.expect(std.mem.indexOf(u8, script, "Start-Process") != null); +} + +test "windows browser host subprocess helpers inherit runtime environment" { + if (@import("builtin").os.tag != .windows) return error.SkipZigTest; + + try std.testing.expect(runCommandNoCapture( + std.testing.allocator, + &.{ "cmd", "/C", "if defined PATH (exit /b 0) else (exit /b 1)" }, + )); +} diff --git a/src/backends/windows_webview/bindings.zig b/src/backends/windows_webview/bindings.zig index ddaad83..f71b154 100644 --- a/src/backends/windows_webview/bindings.zig +++ b/src/backends/windows_webview/bindings.zig @@ -15,6 +15,13 @@ pub const COREWEBVIEW2_COLOR = extern struct { B: u8, }; +pub const RECT = extern struct { + left: i32, + top: i32, + right: i32, + bottom: i32, +}; + pub const IUnknown = extern struct { lpVtbl: *const IUnknownVtbl, }; @@ -96,7 +103,7 @@ pub const ICoreWebView2ControllerVtbl = extern struct { get_IsVisible: *const anyopaque, put_IsVisible: *const fn (*ICoreWebView2Controller, win.BOOL) callconv(.winapi) HRESULT, get_Bounds: *const anyopaque, - put_Bounds: *const fn (*ICoreWebView2Controller, win.RECT) callconv(.winapi) HRESULT, + put_Bounds: *const fn (*ICoreWebView2Controller, RECT) callconv(.winapi) HRESULT, get_ZoomFactor: *const anyopaque, put_ZoomFactor: *const anyopaque, add_ZoomFactorChanged: *const anyopaque, @@ -244,17 +251,26 @@ pub const CreateCoreWebView2EnvironmentWithOptionsFn = *const fn ( *ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler, ) callconv(.winapi) HRESULT; +extern "kernel32" fn LoadLibraryW(lpLibFileName: win.LPCWSTR) callconv(.winapi) ?win.HMODULE; +extern "kernel32" fn FreeLibrary(hLibModule: win.HMODULE) callconv(.winapi) win.BOOL; +extern "kernel32" fn GetProcAddress(hModule: win.HMODULE, lpProcName: [*:0]const u8) callconv(.winapi) ?*const anyopaque; + +/// Stores dynamically resolved native symbol bindings. pub const Symbols = struct { - loader: std.DynLib, + /// Binds `loader` from the native runtime. + loader: win.HMODULE, + /// Binds `create_environment` from the native runtime. create_environment: CreateCoreWebView2EnvironmentWithOptionsFn, /// Loads the platform symbols. pub fn load() !Symbols { - var loader = try std.DynLib.open("WebView2Loader.dll"); - errdefer loader.close(); + const library_name = std.unicode.utf8ToUtf16LeStringLiteral("WebView2Loader.dll"); + const loader = LoadLibraryW(library_name) orelse return error.MissingSharedLibrary; + errdefer _ = FreeLibrary(loader); - const create_environment = loader.lookup(CreateCoreWebView2EnvironmentWithOptionsFn, "CreateCoreWebView2EnvironmentWithOptions") orelse + const create_environment_proc = GetProcAddress(loader, "CreateCoreWebView2EnvironmentWithOptions") orelse return error.MissingDynamicSymbol; + const create_environment: CreateCoreWebView2EnvironmentWithOptionsFn = @ptrCast(create_environment_proc); return .{ .loader = loader, @@ -264,7 +280,7 @@ pub const Symbols = struct { /// Releases resources owned by this value. pub fn deinit(self: *Symbols) void { - self.loader.close(); + _ = FreeLibrary(self.loader); } }; diff --git a/src/backends/windows_webview_host.zig b/src/backends/windows_webview_host.zig index 9895f82..3b4da77 100644 --- a/src/backends/windows_webview_host.zig +++ b/src/backends/windows_webview_host.zig @@ -1,5 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); +const thread_compat = @import("thread_compat"); +const time_compat = @import("time_compat"); const window_style_types = @import("../root/window_style.zig"); const wv2 = @import("windows_webview/bindings.zig"); @@ -7,6 +9,9 @@ const win = std.os.windows; const WindowStyle = window_style_types.WindowStyle; const WindowControl = window_style_types.WindowControl; +const WPARAM = usize; +const LPARAM = isize; +const LRESULT = isize; const default_width: i32 = 980; const default_height: i32 = 660; @@ -55,6 +60,7 @@ const SWP_SHOWWINDOW: u32 = 0x0040; const COINIT_APARTMENTTHREADED: u32 = 0x2; const ERROR_CLASS_ALREADY_EXISTS: u32 = 1410; +const S_OK: wv2.HRESULT = 0; const webview2_bg_env_name = std.unicode.utf8ToUtf16LeStringLiteral("WEBVIEW2_DEFAULT_BACKGROUND_COLOR"); const webview2_bg_env_zero = std.unicode.utf8ToUtf16LeStringLiteral("0"); @@ -83,6 +89,13 @@ const POINT = extern struct { y: i32, }; +const RECT = extern struct { + left: i32, + top: i32, + right: i32, + bottom: i32, +}; + const MINMAXINFO = extern struct { ptReserved: POINT, ptMaxSize: POINT, @@ -94,8 +107,8 @@ const MINMAXINFO = extern struct { const MSG = extern struct { hwnd: ?win.HWND, message: u32, - wParam: win.WPARAM, - lParam: win.LPARAM, + wParam: WPARAM, + lParam: LPARAM, time: u32, pt: POINT, lPrivate: u32, @@ -116,7 +129,7 @@ const CREATESTRUCTW = extern struct { dwExStyle: u32, }; -const WNDPROC = *const fn (win.HWND, u32, win.WPARAM, win.LPARAM) callconv(.winapi) win.LRESULT; +const WNDPROC = *const fn (win.HWND, u32, WPARAM, LPARAM) callconv(.winapi) LRESULT; const WNDCLASSEXW = extern struct { cbSize: u32, @@ -137,6 +150,8 @@ extern "ole32" fn CoInitializeEx(?*anyopaque, u32) callconv(.winapi) wv2.HRESULT extern "ole32" fn CoUninitialize() callconv(.winapi) void; extern "ole32" fn CoTaskMemFree(?*anyopaque) callconv(.winapi) void; extern "kernel32" fn GetLastError() callconv(.winapi) u32; +extern "kernel32" fn GetModuleHandleW(?win.LPCWSTR) callconv(.winapi) ?win.HMODULE; +extern "kernel32" fn SetEnvironmentVariableW(win.LPCWSTR, ?win.LPCWSTR) callconv(.winapi) win.BOOL; extern "user32" fn RegisterClassExW(*const WNDCLASSEXW) callconv(.winapi) win.ATOM; extern "user32" fn UnregisterClassW(win.LPCWSTR, ?win.HINSTANCE) callconv(.winapi) win.BOOL; @@ -154,61 +169,87 @@ extern "user32" fn CreateWindowExW( ?win.HINSTANCE, ?*anyopaque, ) callconv(.winapi) ?win.HWND; -extern "user32" fn DefWindowProcW(win.HWND, u32, win.WPARAM, win.LPARAM) callconv(.winapi) win.LRESULT; +extern "user32" fn DefWindowProcW(win.HWND, u32, WPARAM, LPARAM) callconv(.winapi) LRESULT; extern "user32" fn DestroyWindow(win.HWND) callconv(.winapi) win.BOOL; extern "user32" fn PostQuitMessage(i32) callconv(.winapi) void; extern "user32" fn ShowWindow(win.HWND, i32) callconv(.winapi) win.BOOL; extern "user32" fn UpdateWindow(win.HWND) callconv(.winapi) win.BOOL; extern "user32" fn PeekMessageW(*MSG, ?win.HWND, u32, u32, u32) callconv(.winapi) win.BOOL; extern "user32" fn TranslateMessage(*const MSG) callconv(.winapi) win.BOOL; -extern "user32" fn DispatchMessageW(*const MSG) callconv(.winapi) win.LRESULT; -extern "user32" fn GetClientRect(win.HWND, *win.RECT) callconv(.winapi) win.BOOL; +extern "user32" fn DispatchMessageW(*const MSG) callconv(.winapi) LRESULT; +extern "user32" fn GetClientRect(win.HWND, *RECT) callconv(.winapi) win.BOOL; extern "user32" fn SetWindowPos(win.HWND, ?win.HWND, i32, i32, i32, i32, u32) callconv(.winapi) win.BOOL; extern "user32" fn SetWindowLongPtrW(win.HWND, i32, win.LONG_PTR) callconv(.winapi) win.LONG_PTR; extern "user32" fn GetWindowLongPtrW(win.HWND, i32) callconv(.winapi) win.LONG_PTR; extern "user32" fn SetWindowLongW(win.HWND, i32, i32) callconv(.winapi) i32; extern "user32" fn GetWindowLongW(win.HWND, i32) callconv(.winapi) i32; -extern "user32" fn PostMessageW(win.HWND, u32, win.WPARAM, win.LPARAM) callconv(.winapi) win.BOOL; -extern "user32" fn SendMessageW(win.HWND, u32, win.WPARAM, win.LPARAM) callconv(.winapi) win.LRESULT; +extern "user32" fn PostMessageW(win.HWND, u32, WPARAM, LPARAM) callconv(.winapi) win.BOOL; +extern "user32" fn SendMessageW(win.HWND, u32, WPARAM, LPARAM) callconv(.winapi) LRESULT; extern "user32" fn SetWindowTextW(win.HWND, win.LPCWSTR) callconv(.winapi) win.BOOL; -extern "user32" fn GetWindowRect(win.HWND, *win.RECT) callconv(.winapi) win.BOOL; +extern "user32" fn GetWindowRect(win.HWND, *RECT) callconv(.winapi) win.BOOL; extern "user32" fn SetWindowRgn(win.HWND, ?*anyopaque, win.BOOL) callconv(.winapi) i32; extern "user32" fn CreateIconFromResourceEx(?[*]u8, u32, win.BOOL, u32, i32, i32, u32) callconv(.winapi) ?win.HICON; extern "user32" fn DestroyIcon(win.HICON) callconv(.winapi) win.BOOL; extern "gdi32" fn CreateRoundRectRgn(i32, i32, i32, i32, i32, i32) callconv(.winapi) ?*anyopaque; extern "gdi32" fn DeleteObject(?*anyopaque) callconv(.winapi) win.BOOL; +/// Owns platform-native host state and thread-affine resources. pub const Host = struct { + /// Allocates host-owned buffers and runtime state. allocator: std.mem.Allocator, + /// Stores the current native window title. title: []u8, + /// Stores the last applied window style. style: WindowStyle, - mutex: std.Thread.Mutex = .{}, + /// Stores the mutex. + mutex: thread_compat.Mutex = .{}, + /// Stores the queue. queue: std.array_list.Managed(Command), + /// Stores the owner thread id. owner_thread_id: std.Thread.Id = undefined, + /// Stores the startup done. startup_done: bool = false, + /// Stores the startup error. startup_error: ?anyerror = null, + /// Stores the ui ready. ui_ready: bool = false, + /// Stores the closed. closed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + /// Stores the shutdown requested. shutdown_requested: bool = false, + /// Stores the pending async callbacks. pending_async_callbacks: usize = 0, + /// Stores the instance. instance: ?win.HINSTANCE = null, + /// Stores the class registered. class_registered: bool = false, + /// Stores the hwnd. hwnd: ?win.HWND = null, + /// Stores the window icon. window_icon: ?win.HICON = null, + /// Stores the window destroy started. window_destroy_started: bool = false, + /// Stores the symbols. symbols: ?wv2.Symbols = null, + /// Stores the com initialized. com_initialized: bool = false, + /// Stores the environment. environment: ?*wv2.ICoreWebView2Environment = null, + /// Stores the controller. controller: ?*wv2.ICoreWebView2Controller = null, + /// Stores the webview. webview: ?*wv2.ICoreWebView2 = null, + /// Stores the title changed token. title_changed_token: wv2.EventRegistrationToken = .{ .value = 0 }, + /// Stores the title handler attached. title_handler_attached: bool = false, + /// Stores the pending url. pending_url: ?[]u8 = null, /// Start. @@ -361,7 +402,7 @@ fn initializeUiThread(host: *Host) !void { host.symbols = symbols; host.mutex.unlock(); - if (std.os.windows.kernel32.GetModuleHandleW(null)) |module_handle| { + if (GetModuleHandleW(null)) |module_handle| { host.instance = @ptrCast(module_handle); } else { host.instance = null; @@ -402,7 +443,7 @@ fn waitForStartup(host: *Host) void { if (done) return; const had_activity = pumpMessageLoopStep(host); - if (!had_activity) std.Thread.sleep(std.time.ns_per_ms); + if (!had_activity) time_compat.sleep(std.time.ns_per_ms); } } @@ -415,7 +456,7 @@ fn pumpUntilClosed(host: *Host) void { host.mutex.unlock(); if (done) return; - if (!had_activity) std.Thread.sleep(std.time.ns_per_ms); + if (!had_activity) time_compat.sleep(std.time.ns_per_ms); } } @@ -423,7 +464,7 @@ fn pumpMessageLoopStep(host: *Host) bool { var msg: MSG = undefined; var had_activity = false; - while (PeekMessageW(&msg, null, 0, 0, PM_REMOVE) != 0) { + while (PeekMessageW(&msg, null, 0, 0, PM_REMOVE).toBool()) { had_activity = true; if (msg.message == WM_QUIT) { host.mutex.lock(); @@ -652,12 +693,7 @@ fn applyStyleUiThread(host: *Host, style: WindowStyle) void { _ = SetWindowLongW(hwnd, GWL_STYLE, @as(i32, @bitCast(style_bits))); const ex_before = @as(u32, @bitCast(@as(i32, @truncate(GetWindowLongW(hwnd, GWL_EXSTYLE))))); - var ex_after = ex_before; - if (style.transparent) { - ex_after |= WS_EX_LAYERED; - } else { - ex_after &= ~WS_EX_LAYERED; - } + const ex_after = computeExtendedWindowStyle(ex_before, style); _ = SetWindowLongW(hwnd, GWL_EXSTYLE, @as(i32, @bitCast(ex_after))); _ = SetWindowPos(hwnd, null, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); @@ -740,13 +776,23 @@ fn computeWindowStyle(style: WindowStyle) u32 { return out; } +fn computeExtendedWindowStyle(current_ex: u32, style: WindowStyle) u32 { + var out = current_ex; + if (style.transparent) { + out |= WS_EX_LAYERED; + } else { + out &= ~WS_EX_LAYERED; + } + return out; +} + fn createWebViewEnvironment(host: *Host) bool { const symbols = host.symbols orelse return false; if (host.style.transparent) { - _ = std.os.windows.kernel32.SetEnvironmentVariableW(webview2_bg_env_name, webview2_bg_env_zero); + _ = SetEnvironmentVariableW(webview2_bg_env_name, webview2_bg_env_zero); } else { - _ = std.os.windows.kernel32.SetEnvironmentVariableW(webview2_bg_env_name, null); + _ = SetEnvironmentVariableW(webview2_bg_env_name, null); } retainAsyncCallback(host); @@ -774,29 +820,39 @@ fn updateControllerBounds(host: *Host) void { const hwnd = host.hwnd orelse return; const controller = host.controller orelse return; - var rect: win.RECT = undefined; - if (GetClientRect(hwnd, &rect) == 0) return; - _ = controller.lpVtbl.put_Bounds(controller, rect); + var rect: RECT = undefined; + if (!GetClientRect(hwnd, &rect).toBool()) return; + const bounds = wv2.RECT{ + .left = rect.left, + .top = rect.top, + .right = rect.right, + .bottom = rect.bottom, + }; + _ = controller.lpVtbl.put_Bounds(controller, bounds); } fn applyWindowCornerRadius(hwnd: win.HWND, radius: ?u16) void { if (radius) |value| { - var window_rect: win.RECT = undefined; - if (GetWindowRect(hwnd, &window_rect) == 0) return; + var window_rect: RECT = undefined; + if (!GetWindowRect(hwnd, &window_rect).toBool()) return; const width = window_rect.right - window_rect.left; const height = window_rect.bottom - window_rect.top; if (width <= 1 or height <= 1) return; - const diameter = @max(@as(i32, 2), @as(i32, @intCast(@as(u32, value) * 2))); + const diameter = roundedRegionDiameter(value); const region = CreateRoundRectRgn(0, 0, width + 1, height + 1, diameter, diameter) orelse return; - if (SetWindowRgn(hwnd, region, 1) == 0) { + if (SetWindowRgn(hwnd, region, win.BOOL.TRUE) == 0) { _ = DeleteObject(region); } return; } - _ = SetWindowRgn(hwnd, null, 1); + _ = SetWindowRgn(hwnd, null, win.BOOL.TRUE); +} + +fn roundedRegionDiameter(radius: u16) i32 { + return @max(@as(i32, 2), @as(i32, @intCast(@as(u32, radius) * 2))); } fn applyWindowIcon(host: *Host, hwnd: win.HWND, icon: ?window_style_types.WindowIcon) void { @@ -817,7 +873,7 @@ fn applyWindowIcon(host: *Host, hwnd: win.HWND, icon: ?window_style_types.Window const created = CreateIconFromResourceEx( bytes_copy.ptr, @as(u32, @intCast(bytes_copy.len)), - 1, + win.BOOL.TRUE, 0x00030000, 0, 0, @@ -825,8 +881,8 @@ fn applyWindowIcon(host: *Host, hwnd: win.HWND, icon: ?window_style_types.Window ) orelse return; host.window_icon = created; - _ = SendMessageW(hwnd, WM_SETICON, ICON_SMALL, @as(win.LPARAM, @intCast(@intFromPtr(created)))); - _ = SendMessageW(hwnd, WM_SETICON, ICON_BIG, @as(win.LPARAM, @intCast(@intFromPtr(created)))); + _ = SendMessageW(hwnd, WM_SETICON, ICON_SMALL, @as(LPARAM, @intCast(@intFromPtr(created)))); + _ = SendMessageW(hwnd, WM_SETICON, ICON_BIG, @as(LPARAM, @intCast(@intFromPtr(created)))); } fn applyControllerBackgroundColor(host: *Host) void { @@ -905,7 +961,7 @@ fn environmentHandlerQueryInterface( ) callconv(.winapi) wv2.HRESULT { out.* = self; _ = environmentHandlerAddRef(self); - return win.S_OK; + return S_OK; } fn environmentHandlerAddRef(self: *wv2.ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler) callconv(.winapi) u32 { @@ -933,7 +989,7 @@ fn environmentHandlerInvoke( if (!wv2.succeeded(error_code) or created_environment == null) { failStartup(host, error.NativeBackendUnavailable); - return win.S_OK; + return S_OK; } const environment = created_environment.?; @@ -944,7 +1000,7 @@ fn environmentHandlerInvoke( var controller_handler = host.allocator.create(ControllerCompletedHandler) catch { releaseAsyncCallback(host); failStartup(host, error.NativeBackendUnavailable); - return win.S_OK; + return S_OK; }; controller_handler.* = .{ .iface = .{ .lpVtbl = &controller_completed_handler_vtbl }, @@ -956,7 +1012,7 @@ fn environmentHandlerInvoke( const hwnd = host.hwnd orelse { _ = controllerHandlerRelease(&controller_handler.iface); failStartup(host, error.NativeBackendUnavailable); - return win.S_OK; + return S_OK; }; const hr = environment.lpVtbl.CreateCoreWebView2Controller(environment, hwnd, &controller_handler.iface); @@ -966,7 +1022,7 @@ fn environmentHandlerInvoke( } _ = controllerHandlerRelease(&controller_handler.iface); - return win.S_OK; + return S_OK; } fn controllerHandlerQueryInterface( @@ -976,7 +1032,7 @@ fn controllerHandlerQueryInterface( ) callconv(.winapi) wv2.HRESULT { out.* = self; _ = controllerHandlerAddRef(self); - return win.S_OK; + return S_OK; } fn controllerHandlerAddRef(self: *wv2.ICoreWebView2CreateCoreWebView2ControllerCompletedHandler) callconv(.winapi) u32 { @@ -1004,7 +1060,7 @@ fn controllerHandlerInvoke( if (!wv2.succeeded(error_code) or created_controller == null) { failStartup(host, error.NativeBackendUnavailable); - return win.S_OK; + return S_OK; } const controller = created_controller.?; @@ -1014,7 +1070,7 @@ fn controllerHandlerInvoke( var webview: ?*wv2.ICoreWebView2 = null; if (!wv2.succeeded(controller.lpVtbl.get_CoreWebView2(controller, &webview)) or webview == null) { failStartup(host, error.NativeBackendUnavailable); - return win.S_OK; + return S_OK; } const core = webview.?; _ = core.lpVtbl.AddRef(core); @@ -1033,7 +1089,7 @@ fn controllerHandlerInvoke( finishStartupReady(host); - return win.S_OK; + return S_OK; } fn configureWebViewSettings(webview: *wv2.ICoreWebView2) void { @@ -1042,10 +1098,10 @@ fn configureWebViewSettings(webview: *wv2.ICoreWebView2) void { const s = settings orelse return; defer _ = s.lpVtbl.Release(s); - _ = s.lpVtbl.put_IsScriptEnabled(s, 1); - _ = s.lpVtbl.put_IsWebMessageEnabled(s, 1); - _ = s.lpVtbl.put_AreDefaultScriptDialogsEnabled(s, 1); - _ = s.lpVtbl.put_AreDevToolsEnabled(s, if (builtin.mode == .Debug) 1 else 0); + _ = s.lpVtbl.put_IsScriptEnabled(s, win.BOOL.TRUE); + _ = s.lpVtbl.put_IsWebMessageEnabled(s, win.BOOL.TRUE); + _ = s.lpVtbl.put_AreDefaultScriptDialogsEnabled(s, win.BOOL.TRUE); + _ = s.lpVtbl.put_AreDevToolsEnabled(s, win.BOOL.fromBool(builtin.mode == .Debug)); } fn attachTitleHandler(host: *Host, webview: *wv2.ICoreWebView2) void { @@ -1091,7 +1147,7 @@ fn titleHandlerQueryInterface( ) callconv(.winapi) wv2.HRESULT { out.* = self; _ = titleHandlerAddRef(self); - return win.S_OK; + return S_OK; } fn titleHandlerAddRef(self: *wv2.ICoreWebView2DocumentTitleChangedEventHandler) callconv(.winapi) u32 { @@ -1117,21 +1173,21 @@ fn titleHandlerInvoke( const host = handler.host; _ = sender; - const webview = host.webview orelse return win.S_OK; + const webview = host.webview orelse return S_OK; var raw_title: ?win.PWSTR = null; - if (!wv2.succeeded(webview.lpVtbl.get_DocumentTitle(webview, &raw_title))) return win.S_OK; - const title_ptr = raw_title orelse return win.S_OK; + if (!wv2.succeeded(webview.lpVtbl.get_DocumentTitle(webview, &raw_title))) return S_OK; + const title_ptr = raw_title orelse return S_OK; defer CoTaskMemFree(title_ptr); if (host.hwnd) |hwnd| { _ = SetWindowTextW(hwnd, title_ptr); } - return win.S_OK; + return S_OK; } -fn wndProc(hwnd: win.HWND, msg: u32, wparam: win.WPARAM, lparam: win.LPARAM) callconv(.winapi) win.LRESULT { +fn wndProc(hwnd: win.HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) callconv(.winapi) LRESULT { if (msg == WM_NCCREATE) { const create_struct: *const CREATESTRUCTW = @ptrFromInt(@as(usize, @bitCast(lparam))); if (create_struct.lpCreateParams) |param| { @@ -1217,3 +1273,17 @@ test "computeWindowStyle maps frameless and resizable flags" { try std.testing.expect((classic_fixed & WS_THICKFRAME) == 0); try std.testing.expect((classic_fixed & WS_MAXIMIZEBOX) == 0); } + +test "computeExtendedWindowStyle toggles layered flag for transparency" { + const base: u32 = 0; + const transparent = computeExtendedWindowStyle(base, .{ .transparent = true }); + try std.testing.expect((transparent & WS_EX_LAYERED) != 0); + + const restored = computeExtendedWindowStyle(transparent, .{ .transparent = false }); + try std.testing.expect((restored & WS_EX_LAYERED) == 0); +} + +test "roundedRegionDiameter doubles radius with a minimum of two" { + try std.testing.expectEqual(@as(i32, 2), roundedRegionDiameter(0)); + try std.testing.expectEqual(@as(i32, 6), roundedRegionDiameter(3)); +} diff --git a/src/bridge/template.zig b/src/bridge/template.zig index 4b8307b..c07a661 100644 --- a/src/bridge/template.zig +++ b/src/bridge/template.zig @@ -1,15 +1,23 @@ const std = @import("std"); const helpers = @import("runtime_helpers.zig"); +/// Stores rpc function meta. pub const RpcFunctionMeta = struct { + /// Stores the name. name: []const u8, + /// Stores the arity. arity: usize, + /// Stores the ts arg signature. ts_arg_signature: []const u8 = "", + /// Stores the ts return type. ts_return_type: []const u8 = "unknown", }; +/// Configures render options. pub const RenderOptions = struct { + /// Stores the namespace. namespace: []const u8 = "webuiRpc", + /// Stores the rpc route. rpc_route: []const u8 = "/webui/rpc", }; @@ -40,58 +48,57 @@ fn renderWithRuntimeHelpers( var out = std.array_list.Managed(u8).init(allocator); errdefer out.deinit(); - const writer = out.writer(); - try writer.writeAll("// Generated by tools/bridge_gen.zig\n"); - try writer.writeAll(runtime_helpers_js); - try writer.writeAll("\nconst webuiRpcEndpoint = "); - try writeJsonLiteral(allocator, writer, options.rpc_route); - try writer.writeAll(";\n"); - try writer.writeAll("\nconst "); - try writer.writeAll(options.namespace); - try writer.writeAll(" = Object.freeze({\n"); + try out.appendSlice("// Generated by tools/bridge_gen.zig\n"); + try out.appendSlice(runtime_helpers_js); + try out.appendSlice("\nconst webuiRpcEndpoint = "); + try writeJsonLiteral(allocator, &out, options.rpc_route); + try out.appendSlice(";\n"); + try out.appendSlice("\nconst "); + try out.appendSlice(options.namespace); + try out.appendSlice(" = Object.freeze({\n"); for (functions) |fn_meta| { - try writer.writeAll(" "); - try writer.writeAll(fn_meta.name); - try writer.writeAll(": async ("); + try out.appendSlice(" "); + try out.appendSlice(fn_meta.name); + try out.appendSlice(": async ("); var i: usize = 0; while (i < fn_meta.arity) : (i += 1) { - if (i != 0) try writer.writeAll(", "); - try writer.print("arg{d}", .{i}); + if (i != 0) try out.appendSlice(", "); + try out.print("arg{d}", .{i}); } - try writer.writeAll(") => await webuiInvoke(webuiRpcEndpoint, "); - try writeJsonLiteral(allocator, writer, fn_meta.name); - try writer.writeAll(", ["); + try out.appendSlice(") => await webuiInvoke(webuiRpcEndpoint, "); + try writeJsonLiteral(allocator, &out, fn_meta.name); + try out.appendSlice(", ["); i = 0; while (i < fn_meta.arity) : (i += 1) { - if (i != 0) try writer.writeAll(", "); - try writer.print("arg{d}", .{i}); + if (i != 0) try out.appendSlice(", "); + try out.print("arg{d}", .{i}); } - try writer.writeAll("]),\n"); + try out.appendSlice("]),\n"); } // Keep generated bridge JS usable from classic