chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# Browser Example
|
||||
|
||||
A no-build vanilla HTML/CSS/JS example that uses the layered WebViews API for isolated page content.
|
||||
|
||||
Run it from this directory:
|
||||
|
||||
```sh
|
||||
zig build run
|
||||
```
|
||||
|
||||
Or from the repository root:
|
||||
|
||||
```sh
|
||||
zig build run-browser
|
||||
```
|
||||
|
||||
The root command uses this example's system-WebView default unless you explicitly pass `-Dweb-engine=chromium`.
|
||||
|
||||
The frontend lives in `frontend/` and is served directly as `zero://app/index.html`.
|
||||
|
||||
The page content runs in an isolated child WebView. Page WebViews do not receive `window.zero`; all navigation and sizing is controlled by the browser chrome through the handle returned by `window.zero.webviews.create()`.
|
||||
|
||||
This example allows all navigation origins so it can browse arbitrary pages. Treat that as demo-only policy, not a production template. The chrome page keeps a strict CSP; arbitrary page navigation is controlled by the native navigation policy. Chromium/CEF builds are currently macOS-only; use the system WebView backend on Linux and Windows for this example. `main` WebView layering is best effort and may be ignored by a backend. Windows WebView support is still in progress and does not yet support the `main` WebView resizing this example uses.
|
||||
@@ -0,0 +1,23 @@
|
||||
.{
|
||||
.id = "dev.native_sdk.browser",
|
||||
.name = "browser",
|
||||
.display_name = "Browser Example",
|
||||
.version = "0.1.0",
|
||||
.platforms = .{ "macos", "linux" },
|
||||
.permissions = .{ "window" },
|
||||
.capabilities = .{ "webview", "js_bridge" },
|
||||
.security = .{
|
||||
.navigation = .{
|
||||
.allowed_origins = .{ "*" },
|
||||
.external_links = .{ .action = "deny" },
|
||||
},
|
||||
},
|
||||
.web_engine = "system",
|
||||
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
|
||||
.shortcuts = .{
|
||||
.{ .id = "zoom-in", .key = "=", .modifiers = .{ "primary" } },
|
||||
.{ .id = "zoom-out", .key = "-", .modifiers = .{ "primary" } },
|
||||
.{ .id = "zoom-reset", .key = "0", .modifiers = .{ "primary" } },
|
||||
.{ .id = "reload", .key = "r", .modifiers = .{ "primary" } },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// This example owns its build: it hand-wires the framework modules, platform hosts, and the -Dweb-engine/-Dcef-dir engine link flags that the generated app graph does not expose.
|
||||
const std = @import("std");
|
||||
|
||||
const PlatformOption = enum {
|
||||
auto,
|
||||
null,
|
||||
macos,
|
||||
linux,
|
||||
windows,
|
||||
};
|
||||
|
||||
const TraceOption = enum {
|
||||
off,
|
||||
events,
|
||||
runtime,
|
||||
all,
|
||||
};
|
||||
|
||||
const WebEngineOption = enum {
|
||||
system,
|
||||
chromium,
|
||||
};
|
||||
|
||||
const default_native_sdk_path = "../../";
|
||||
const app_exe_name = "browser";
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const target = nativeSdkTarget(b);
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
const platform_option = b.option(PlatformOption, "platform", "Desktop backend: auto, null, macos, linux, windows") orelse .auto;
|
||||
const trace_option = b.option(TraceOption, "trace", "Trace output: off, events, runtime, all") orelse .events;
|
||||
const automation_enabled = b.option(bool, "automation", "Enable Native SDK automation artifacts") orelse false;
|
||||
const web_engine_override = b.option(WebEngineOption, "web-engine", "Override app.zon web engine: system, chromium");
|
||||
const cef_dir_override = b.option([]const u8, "cef-dir", "Override CEF root directory for Chromium builds");
|
||||
const cef_auto_install_override = b.option(bool, "cef-auto-install", "Override app.zon CEF auto-install setting");
|
||||
const native_sdk_path = b.option([]const u8, "native-sdk-path", "Path to the Native SDK framework checkout") orelse default_native_sdk_path;
|
||||
const selected_platform: PlatformOption = switch (platform_option) {
|
||||
.auto => if (target.result.os.tag == .macos) .macos else if (target.result.os.tag == .linux) .linux else if (target.result.os.tag == .windows) .windows else .null,
|
||||
else => platform_option,
|
||||
};
|
||||
if (selected_platform == .macos and target.result.os.tag != .macos) @panic("-Dplatform=macos requires a macOS target");
|
||||
if (selected_platform == .linux and target.result.os.tag != .linux) @panic("-Dplatform=linux requires a Linux target");
|
||||
if (selected_platform == .windows and target.result.os.tag != .windows) @panic("-Dplatform=windows requires a Windows target");
|
||||
|
||||
const web_engine = web_engine_override orelse WebEngineOption.system;
|
||||
const cef_dir = cef_dir_override orelse defaultCefDir(selected_platform, "third_party/cef/macos");
|
||||
const cef_auto_install = cef_auto_install_override orelse false;
|
||||
if (web_engine == .chromium and selected_platform != .macos) {
|
||||
@panic("-Dweb-engine=chromium currently requires -Dplatform=macos");
|
||||
}
|
||||
|
||||
const native_sdk_mod = nativeSdkModule(b, target, optimize, native_sdk_path);
|
||||
const options = b.addOptions();
|
||||
options.addOption([]const u8, "platform", switch (selected_platform) {
|
||||
.auto => unreachable,
|
||||
.null => "null",
|
||||
.macos => "macos",
|
||||
.linux => "linux",
|
||||
.windows => "windows",
|
||||
});
|
||||
options.addOption([]const u8, "trace", @tagName(trace_option));
|
||||
options.addOption([]const u8, "web_engine", @tagName(web_engine));
|
||||
options.addOption(bool, "automation", automation_enabled);
|
||||
const options_mod = options.createModule();
|
||||
|
||||
const runner_mod = localModule(b, target, optimize, "src/runner.zig");
|
||||
runner_mod.addImport("native_sdk", native_sdk_mod);
|
||||
runner_mod.addImport("build_options", options_mod);
|
||||
runner_mod.addImport("app_manifest_zon", b.createModule(.{ .root_source_file = b.path("app.zon") }));
|
||||
|
||||
const app_mod = localModule(b, target, optimize, "src/main.zig");
|
||||
app_mod.addImport("native_sdk", native_sdk_mod);
|
||||
app_mod.addImport("runner", runner_mod);
|
||||
const exe = b.addExecutable(.{
|
||||
.name = app_exe_name,
|
||||
.root_module = app_mod,
|
||||
});
|
||||
linkPlatform(b, target, app_mod, exe, selected_platform, web_engine, native_sdk_path, cef_dir, cef_auto_install);
|
||||
b.installArtifact(exe);
|
||||
|
||||
const run = b.addRunArtifact(exe);
|
||||
addCefRuntimeRunFiles(b, target, run, exe, web_engine, cef_dir);
|
||||
addWebView2RuntimeRunFiles(b, target, run, web_engine, native_sdk_path);
|
||||
const run_step = b.step("run", "Run the browser example");
|
||||
run_step.dependOn(&run.step);
|
||||
|
||||
const tests = b.addTest(.{ .root_module = app_mod });
|
||||
const test_step = b.step("test", "Run tests");
|
||||
test_step.dependOn(&b.addRunArtifact(tests).step);
|
||||
}
|
||||
|
||||
fn nativeSdkTarget(b: *std.Build) std.Build.ResolvedTarget {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
if (target.result.os.tag != .macos) return target;
|
||||
if (b.sysroot == null) b.sysroot = macosSdkPath(b) orelse b.sysroot;
|
||||
var query = target.query;
|
||||
query.os_tag = .macos;
|
||||
query.os_version_min = .{ .semver = .{ .major = 11, .minor = 0, .patch = 0 } };
|
||||
return b.resolveTargetQuery(query);
|
||||
}
|
||||
|
||||
fn macosSdkPath(b: *std.Build) ?[]const u8 {
|
||||
if (b.graph.environ_map.get("SDKROOT")) |sdkroot| {
|
||||
if (sdkroot.len > 0) return sdkroot;
|
||||
}
|
||||
const result = std.process.run(b.allocator, b.graph.io, .{
|
||||
.argv = &.{ "xcrun", "--sdk", "macosx", "--show-sdk-path" },
|
||||
.stdout_limit = .limited(4096),
|
||||
.stderr_limit = .limited(4096),
|
||||
}) catch return null;
|
||||
defer b.allocator.free(result.stderr);
|
||||
if (result.term != .exited or result.term.exited != 0) {
|
||||
b.allocator.free(result.stdout);
|
||||
return null;
|
||||
}
|
||||
return std.mem.trimEnd(u8, result.stdout, "\r\n");
|
||||
}
|
||||
|
||||
fn localModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, path: []const u8) *std.Build.Module {
|
||||
return b.createModule(.{
|
||||
.root_source_file = b.path(path),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
}
|
||||
|
||||
fn nativeSdkPath(b: *std.Build, native_sdk_path: []const u8, sub_path: []const u8) std.Build.LazyPath {
|
||||
return .{ .cwd_relative = b.pathJoin(&.{ native_sdk_path, sub_path }) };
|
||||
}
|
||||
|
||||
fn nativeSdkModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, native_sdk_path: []const u8) *std.Build.Module {
|
||||
const geometry_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/geometry/root.zig");
|
||||
const assets_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/assets/root.zig");
|
||||
const app_dirs_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/app_dirs/root.zig");
|
||||
const trace_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/trace/root.zig");
|
||||
const app_manifest_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/app_manifest/root.zig");
|
||||
const diagnostics_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/diagnostics/root.zig");
|
||||
const platform_info_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/platform_info/root.zig");
|
||||
const json_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/json/root.zig");
|
||||
const canvas_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/canvas/root.zig");
|
||||
canvas_mod.addImport("geometry", geometry_mod);
|
||||
canvas_mod.addImport("json", json_mod);
|
||||
const debug_mod = externalModule(b, target, optimize, native_sdk_path, "src/debug/root.zig");
|
||||
debug_mod.addImport("app_dirs", app_dirs_mod);
|
||||
debug_mod.addImport("trace", trace_mod);
|
||||
|
||||
const native_sdk_mod = externalModule(b, target, optimize, native_sdk_path, "src/root.zig");
|
||||
native_sdk_mod.addImport("geometry", geometry_mod);
|
||||
native_sdk_mod.addImport("assets", assets_mod);
|
||||
native_sdk_mod.addImport("app_dirs", app_dirs_mod);
|
||||
native_sdk_mod.addImport("trace", trace_mod);
|
||||
native_sdk_mod.addImport("app_manifest", app_manifest_mod);
|
||||
native_sdk_mod.addImport("diagnostics", diagnostics_mod);
|
||||
native_sdk_mod.addImport("platform_info", platform_info_mod);
|
||||
native_sdk_mod.addImport("json", json_mod);
|
||||
native_sdk_mod.addImport("canvas", canvas_mod);
|
||||
return native_sdk_mod;
|
||||
}
|
||||
|
||||
fn externalModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, native_sdk_path: []const u8, path: []const u8) *std.Build.Module {
|
||||
return b.createModule(.{
|
||||
.root_source_file = nativeSdkPath(b, native_sdk_path, path),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
}
|
||||
|
||||
fn linkPlatform(b: *std.Build, target: std.Build.ResolvedTarget, app_mod: *std.Build.Module, exe: *std.Build.Step.Compile, platform: PlatformOption, web_engine: WebEngineOption, native_sdk_path: []const u8, cef_dir: []const u8, cef_auto_install: bool) void {
|
||||
if (platform == .macos) {
|
||||
switch (web_engine) {
|
||||
.system => {
|
||||
const sdk_include = if (b.sysroot) |sysroot| b.fmt("-I{s}/usr/include", .{sysroot}) else "";
|
||||
const flags: []const []const u8 = if (b.sysroot) |sysroot| &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0", "-isysroot", sysroot, sdk_include } else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0" };
|
||||
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/macos/appkit_host.m"), .flags = flags });
|
||||
app_mod.linkFramework("WebKit", .{});
|
||||
},
|
||||
.chromium => {
|
||||
const cef_check = addCefCheck(b, target, cef_dir);
|
||||
if (cef_auto_install) {
|
||||
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
|
||||
cef_check.step.dependOn(&cef_auto.step);
|
||||
}
|
||||
exe.step.dependOn(&cef_check.step);
|
||||
const include_arg = b.fmt("-I{s}", .{cef_dir});
|
||||
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
|
||||
// The SDK's usr/include must stay a system include dir (searched after zig's
|
||||
// bundled libc++/libc headers). A plain -I shadows libc++'s <string.h>/<math.h>
|
||||
// wrappers in ObjC++ and surfaces SDK nullability gaps as a diagnostic flood.
|
||||
const sdk_include = if (b.sysroot) |sysroot| b.fmt("-isystem{s}/usr/include", .{sysroot}) else "";
|
||||
const flags: []const []const u8 = if (b.sysroot) |sysroot| &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC++", "-std=c++17", "-stdlib=libc++", "-mmacosx-version-min=11.0", "-isysroot", sysroot, sdk_include, include_arg, define_arg } else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC++", "-std=c++17", "-stdlib=libc++", "-mmacosx-version-min=11.0", include_arg, define_arg };
|
||||
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/macos/cef_host.mm"), .flags = flags });
|
||||
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.a", .{cef_dir})));
|
||||
app_mod.addFrameworkPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
|
||||
app_mod.linkFramework("Chromium Embedded Framework", .{});
|
||||
app_mod.addRPath(.{ .cwd_relative = "@executable_path/Frameworks" });
|
||||
},
|
||||
}
|
||||
if (b.sysroot) |sysroot| app_mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ sysroot, "System/Library/Frameworks" }) });
|
||||
app_mod.linkFramework("AppKit", .{});
|
||||
// The audio playback service (the AppKit host's single AVPlayer).
|
||||
app_mod.linkFramework("AVFoundation", .{});
|
||||
// Spectrum analysis of the app's own playback: the MediaToolbox
|
||||
// audio tap hands the player's PCM to the host, and Accelerate
|
||||
// (vDSP) turns it into band magnitudes.
|
||||
app_mod.linkFramework("MediaToolbox", .{});
|
||||
app_mod.linkFramework("Accelerate", .{});
|
||||
app_mod.linkFramework("Foundation", .{});
|
||||
app_mod.linkFramework("CoreText", .{});
|
||||
app_mod.linkFramework("UniformTypeIdentifiers", .{});
|
||||
app_mod.linkFramework("Security", .{});
|
||||
app_mod.linkFramework("Metal", .{});
|
||||
app_mod.linkFramework("QuartzCore", .{});
|
||||
app_mod.linkSystemLibrary("c", .{});
|
||||
if (web_engine == .chromium) app_mod.linkSystemLibrary("c++", .{});
|
||||
} else if (platform == .linux) {
|
||||
switch (web_engine) {
|
||||
.system => {
|
||||
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/linux/gtk_host.c"), .flags = &.{} });
|
||||
app_mod.linkSystemLibrary("gtk4", .{});
|
||||
app_mod.linkSystemLibrary("webkitgtk-6.0", .{});
|
||||
app_mod.linkSystemLibrary("dl", .{});
|
||||
},
|
||||
.chromium => {
|
||||
const cef_check = addCefCheck(b, target, cef_dir);
|
||||
if (cef_auto_install) {
|
||||
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
|
||||
cef_check.step.dependOn(&cef_auto.step);
|
||||
}
|
||||
exe.step.dependOn(&cef_check.step);
|
||||
const include_arg = b.fmt("-I{s}", .{cef_dir});
|
||||
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
|
||||
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/linux/cef_host.cpp"), .flags = &.{ "-std=c++17", include_arg, define_arg } });
|
||||
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.a", .{cef_dir})));
|
||||
app_mod.addLibraryPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
|
||||
app_mod.linkSystemLibrary("cef", .{});
|
||||
app_mod.addRPath(.{ .cwd_relative = "$ORIGIN" });
|
||||
},
|
||||
}
|
||||
app_mod.linkSystemLibrary("c", .{});
|
||||
if (web_engine == .chromium) app_mod.linkSystemLibrary("stdc++", .{});
|
||||
} else if (platform == .windows) {
|
||||
switch (web_engine) {
|
||||
.system => {
|
||||
// The vendored WebView2 SDK header (third_party/webview2)
|
||||
// turns on the host's embedded-WebView layer; the host
|
||||
// fails the compile by design if it cannot be found.
|
||||
app_mod.addIncludePath(nativeSdkPath(b, native_sdk_path, "third_party/webview2/include"));
|
||||
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/windows/webview2_host.cpp"), .flags = &.{"-std=c++17"} });
|
||||
// WebView2Loader.dll rides next to the installed app
|
||||
// executable: the host loads it at runtime to discover
|
||||
// the machine's WebView2 runtime. Canvas apps never
|
||||
// touch it.
|
||||
const loader = b.addInstallBinFile(nativeSdkPath(b, native_sdk_path, webView2LoaderSubPath(target)), "WebView2Loader.dll");
|
||||
b.getInstallStep().dependOn(&loader.step);
|
||||
},
|
||||
.chromium => {
|
||||
const cef_check = addCefCheck(b, target, cef_dir);
|
||||
if (cef_auto_install) {
|
||||
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
|
||||
cef_check.step.dependOn(&cef_auto.step);
|
||||
}
|
||||
exe.step.dependOn(&cef_check.step);
|
||||
const include_arg = b.fmt("-I{s}", .{cef_dir});
|
||||
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
|
||||
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/windows/cef_host.cpp"), .flags = &.{ "-std=c++17", include_arg, define_arg } });
|
||||
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.lib", .{cef_dir})));
|
||||
app_mod.addLibraryPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
|
||||
},
|
||||
}
|
||||
app_mod.linkSystemLibrary("c", .{});
|
||||
app_mod.linkSystemLibrary("c++", .{});
|
||||
app_mod.linkSystemLibrary("user32", .{});
|
||||
app_mod.linkSystemLibrary("gdi32", .{});
|
||||
app_mod.linkSystemLibrary("imm32", .{});
|
||||
app_mod.linkSystemLibrary("comctl32", .{});
|
||||
app_mod.linkSystemLibrary("ole32", .{});
|
||||
app_mod.linkSystemLibrary("oleacc", .{});
|
||||
app_mod.linkSystemLibrary("shell32", .{});
|
||||
// The audio backend: Media Foundation (session + source resolver
|
||||
// + streaming audio renderer) and WinHTTP (the cache fill).
|
||||
app_mod.linkSystemLibrary("mf", .{});
|
||||
app_mod.linkSystemLibrary("mfplat", .{});
|
||||
app_mod.linkSystemLibrary("winhttp", .{});
|
||||
if (web_engine == .chromium) app_mod.linkSystemLibrary("libcef", .{});
|
||||
}
|
||||
}
|
||||
|
||||
/// The vendored WebView2Loader.dll for the target architecture, relative
|
||||
/// to the framework root.
|
||||
fn webView2LoaderSubPath(target: std.Build.ResolvedTarget) []const u8 {
|
||||
return if (target.result.cpu.arch == .aarch64)
|
||||
"third_party/webview2/arm64/WebView2Loader.dll"
|
||||
else
|
||||
"third_party/webview2/x64/WebView2Loader.dll";
|
||||
}
|
||||
|
||||
/// `zig build run` executes the cached artifact, which has no installed
|
||||
/// WebView2Loader.dll beside it; the vendored loader's directory goes on
|
||||
/// the run step's PATH so the host's LoadLibrary resolves it in dev runs.
|
||||
fn addWebView2RuntimeRunFiles(b: *std.Build, target: std.Build.ResolvedTarget, run: *std.Build.Step.Run, web_engine: WebEngineOption, native_sdk_path: []const u8) void {
|
||||
if (web_engine != .system) return;
|
||||
if (target.result.os.tag != .windows) return;
|
||||
const loader_dir = std.fs.path.dirname(webView2LoaderSubPath(target)).?;
|
||||
run.addPathDir(b.pathFromRoot(b.pathJoin(&.{ native_sdk_path, loader_dir })));
|
||||
}
|
||||
|
||||
fn addCefRuntimeRunFiles(b: *std.Build, target: std.Build.ResolvedTarget, run: *std.Build.Step.Run, exe: *std.Build.Step.Compile, web_engine: WebEngineOption, cef_dir: []const u8) void {
|
||||
if (web_engine != .chromium or target.result.os.tag != .macos) return;
|
||||
const copy = b.addSystemCommand(&.{
|
||||
"sh", "-c",
|
||||
b.fmt(
|
||||
\\set -e
|
||||
\\exe="$0"
|
||||
\\exe_dir="$(dirname "$exe")"
|
||||
\\mkdir -p "zig-out/Frameworks" "zig-out/bin/Frameworks" ".zig-cache/o/Frameworks" "$exe_dir"
|
||||
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" "zig-out/Frameworks/"
|
||||
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" "zig-out/bin/Frameworks/"
|
||||
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" ".zig-cache/o/Frameworks/"
|
||||
, .{ cef_dir, cef_dir, cef_dir }),
|
||||
});
|
||||
copy.addFileArg(exe.getEmittedBin());
|
||||
run.step.dependOn(©.step);
|
||||
}
|
||||
|
||||
fn addCefCheck(b: *std.Build, target: std.Build.ResolvedTarget, cef_dir: []const u8) *std.Build.Step.Run {
|
||||
const script = switch (target.result.os.tag) {
|
||||
.macos => b.fmt("test -f \"{s}/include/cef_app.h\" && test -d \"{s}/Release/Chromium Embedded Framework.framework\" && test -f \"{s}/libcef_dll_wrapper/libcef_dll_wrapper.a\" || {{ echo \"missing CEF dependency for -Dweb-engine=chromium\" >&2; echo \"Fix with: native cef install --dir {s}\" >&2; exit 1; }}", .{ cef_dir, cef_dir, cef_dir, cef_dir }),
|
||||
.linux => b.fmt("test -f \"{s}/include/cef_app.h\" && test -f \"{s}/Release/libcef.so\" && test -f \"{s}/libcef_dll_wrapper/libcef_dll_wrapper.a\" || {{ echo \"missing CEF dependency for -Dweb-engine=chromium\" >&2; echo \"Fix with: native cef install --dir {s}\" >&2; exit 1; }}", .{ cef_dir, cef_dir, cef_dir, cef_dir }),
|
||||
.windows => b.fmt("test -f \"{s}/include/cef_app.h\" && test -f \"{s}/Release/libcef.dll\" && test -f \"{s}/libcef_dll_wrapper/libcef_dll_wrapper.lib\" || {{ echo \"missing CEF dependency for -Dweb-engine=chromium\" >&2; echo \"Fix with: native cef install --dir {s}\" >&2; exit 1; }}", .{ cef_dir, cef_dir, cef_dir, cef_dir }),
|
||||
else => "echo unsupported CEF target >&2; exit 1",
|
||||
};
|
||||
return b.addSystemCommand(&.{ "sh", "-c", script });
|
||||
}
|
||||
|
||||
fn defaultCefDir(platform: PlatformOption, configured: []const u8) []const u8 {
|
||||
if (!std.mem.eql(u8, configured, "third_party/cef/macos")) return configured;
|
||||
return switch (platform) {
|
||||
.linux => "third_party/cef/linux",
|
||||
.windows => "third_party/cef/windows",
|
||||
else => configured,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
var PAGE_WEBVIEW_LABEL = "page";
|
||||
var PAGE_WEBVIEW_LAYER = 0;
|
||||
var CHROME_WEBVIEW_LAYER = 10;
|
||||
|
||||
var form = document.querySelector("#browser-form");
|
||||
var addressInput = document.querySelector("#url-input");
|
||||
var addressBar = document.querySelector("#address-bar");
|
||||
var addressIcon = document.querySelector("#address-icon");
|
||||
var suggestions = document.querySelector("#suggestions");
|
||||
var backButton = document.querySelector("#back");
|
||||
var forwardButton = document.querySelector("#forward");
|
||||
var reloadButton = document.querySelector("#reload");
|
||||
var statusText = document.querySelector("#status-text");
|
||||
var emptyState = document.querySelector("#empty-state");
|
||||
var errorState = document.querySelector("#error-state");
|
||||
var errorTitle = document.querySelector("#error-title");
|
||||
var errorDetail = document.querySelector("#error-detail");
|
||||
var errorRetry = document.querySelector("#error-retry");
|
||||
var toolbar = document.querySelector("#toolbar");
|
||||
|
||||
var pageWebView = null;
|
||||
var currentUrl = "";
|
||||
var navHistory = [];
|
||||
var historyIndex = -1;
|
||||
var visitedUrls = [];
|
||||
var resizeHandle = 0;
|
||||
var resizeInFlight = false;
|
||||
var resizeRequested = false;
|
||||
var viewportPollHandle = 0;
|
||||
var isLoading = false;
|
||||
var statusTimer = null;
|
||||
var zoomLevel = 1.0;
|
||||
var ZOOM_STEP = 0.1;
|
||||
var ZOOM_MIN = 0.25;
|
||||
var ZOOM_MAX = 5.0;
|
||||
var suggestActive = -1;
|
||||
var suggestFiltered = [];
|
||||
// The main WebView is later resized to chrome height, so keep the original
|
||||
// full-window viewport for positioning the child page WebView.
|
||||
var browserViewport = {
|
||||
width: Math.max(1, Math.floor(window.innerWidth)),
|
||||
height: Math.max(1, Math.floor(window.innerHeight)),
|
||||
};
|
||||
var browserViewportRevision = 0;
|
||||
var lastNativeResizeAt = 0;
|
||||
var nativeViewportInset = null;
|
||||
|
||||
function setStatus(message, autohide) {
|
||||
clearTimeout(statusTimer);
|
||||
statusText.textContent = message;
|
||||
if (autohide) {
|
||||
statusTimer = setTimeout(function () {
|
||||
statusText.textContent = "";
|
||||
}, autohide);
|
||||
}
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
isLoading = loading;
|
||||
if (loading) {
|
||||
addressBar.classList.remove("load-complete");
|
||||
addressBar.classList.add("loading");
|
||||
} else {
|
||||
addressBar.classList.remove("loading");
|
||||
addressBar.classList.add("load-complete");
|
||||
setTimeout(function () {
|
||||
addressBar.classList.remove("load-complete");
|
||||
}, 600);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSecurityIndicator(url) {
|
||||
if (url.startsWith("https://")) {
|
||||
addressBar.classList.add("secure");
|
||||
} else {
|
||||
addressBar.classList.remove("secure");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(value) {
|
||||
var trimmed = value.trim();
|
||||
if (!trimmed) return "https://example.com";
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed;
|
||||
if (/^[a-z0-9]([a-z0-9-]*\.)+[a-z]{2,}/i.test(trimmed)) return "https://" + trimmed;
|
||||
return "https://" + trimmed;
|
||||
}
|
||||
|
||||
function canonicalUrlKey(value) {
|
||||
var trimmed = String(value || "").trim();
|
||||
if (!trimmed) return "";
|
||||
try {
|
||||
return new URL(trimmed).href;
|
||||
} catch (_) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function toolbarHeight() {
|
||||
var toolbarRect = toolbar.getBoundingClientRect();
|
||||
return Math.ceil(toolbarRect.height);
|
||||
}
|
||||
|
||||
function chromeHeight() {
|
||||
var height = toolbarHeight();
|
||||
if (!suggestions.hidden) {
|
||||
var suggestionsRect = suggestions.getBoundingClientRect();
|
||||
height = Math.max(height, Math.ceil(suggestionsRect.bottom + 8));
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
function pageFrame() {
|
||||
var top = toolbarHeight();
|
||||
top = Math.min(top, Math.max(0, browserViewport.height - 1));
|
||||
return {
|
||||
x: 0,
|
||||
y: top,
|
||||
width: browserViewport.width,
|
||||
height: Math.max(1, Math.floor(browserViewport.height - top)),
|
||||
};
|
||||
}
|
||||
|
||||
function chromeFrame() {
|
||||
var height = chromeHeight();
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: browserViewport.width,
|
||||
height: Math.max(1, Math.min(height, browserViewport.height)),
|
||||
};
|
||||
}
|
||||
|
||||
function updateBrowserViewport(width, height) {
|
||||
var nextWidth = Math.max(1, Math.floor(width));
|
||||
var nextHeight = Math.max(1, Math.floor(height));
|
||||
if (browserViewport.width === nextWidth && browserViewport.height === nextHeight) return false;
|
||||
browserViewport = { width: nextWidth, height: nextHeight };
|
||||
browserViewportRevision++;
|
||||
return true;
|
||||
}
|
||||
|
||||
function mainWindowInfo(windows) {
|
||||
if (!Array.isArray(windows)) return null;
|
||||
for (var i = 0; i < windows.length; i++) {
|
||||
if (windows[i] && (windows[i].id === 1 || windows[i].label === "main")) return windows[i];
|
||||
}
|
||||
return windows[0] || null;
|
||||
}
|
||||
|
||||
async function syncBrowserViewport() {
|
||||
if (lastNativeResizeAt && Date.now() - lastNativeResizeAt < 1000) return false;
|
||||
var startedAtRevision = browserViewportRevision;
|
||||
try {
|
||||
var info = mainWindowInfo(await window.zero.windows.list());
|
||||
if (!info) return false;
|
||||
if (browserViewportRevision !== startedAtRevision) return false;
|
||||
var nativeWidth = Math.max(1, Math.floor(info.width));
|
||||
var nativeHeight = Math.max(1, Math.floor(info.height));
|
||||
if (!nativeViewportInset) {
|
||||
nativeViewportInset = {
|
||||
width: nativeWidth - browserViewport.width,
|
||||
height: nativeHeight - browserViewport.height,
|
||||
};
|
||||
}
|
||||
return updateBrowserViewport(
|
||||
nativeWidth - nativeViewportInset.width,
|
||||
nativeHeight - nativeViewportInset.height
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to sync browser viewport", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateChromeOverlay() {
|
||||
var frame = chromeFrame();
|
||||
await window.zero.webviews.setFrame({ label: "main", frame: frame });
|
||||
try {
|
||||
await window.zero.webviews.setLayer({ label: "main", layer: CHROME_WEBVIEW_LAYER });
|
||||
} catch (error) {
|
||||
console.warn("Main WebView layering is not supported on this backend", error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateHistoryButtons() {
|
||||
backButton.disabled = historyIndex <= 0;
|
||||
forwardButton.disabled = historyIndex < 0 || historyIndex >= navHistory.length - 1;
|
||||
}
|
||||
|
||||
function remember(url) {
|
||||
if (navHistory[historyIndex] === url) return;
|
||||
navHistory = navHistory.slice(0, historyIndex + 1);
|
||||
navHistory.push(url);
|
||||
historyIndex = navHistory.length - 1;
|
||||
updateHistoryButtons();
|
||||
}
|
||||
|
||||
function updateAddressFromPage(url, options) {
|
||||
options = options || {};
|
||||
if (!url || canonicalUrlKey(url) === canonicalUrlKey(currentUrl)) return;
|
||||
currentUrl = url;
|
||||
addressInput.value = url;
|
||||
updateSecurityIndicator(url);
|
||||
hideError();
|
||||
trackVisited(url);
|
||||
if (options.record !== false) remember(url);
|
||||
}
|
||||
|
||||
function trackVisited(url) {
|
||||
var key = canonicalUrlKey(url);
|
||||
if (!key) return;
|
||||
for (var i = 0; i < visitedUrls.length; i++) {
|
||||
if (canonicalUrlKey(visitedUrls[i]) === key) {
|
||||
visitedUrls.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
visitedUrls.unshift(url);
|
||||
if (visitedUrls.length > 200) visitedUrls.length = 200;
|
||||
}
|
||||
|
||||
function hostFromUrl(url) {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch (_) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Suggestions ──
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function highlightMatch(text, query) {
|
||||
if (!query) return escapeHtml(text);
|
||||
var lower = text.toLowerCase();
|
||||
var qLower = query.toLowerCase();
|
||||
var idx = lower.indexOf(qLower);
|
||||
if (idx === -1) return escapeHtml(text);
|
||||
var before = text.slice(0, idx);
|
||||
var match = text.slice(idx, idx + query.length);
|
||||
var after = text.slice(idx + query.length);
|
||||
return escapeHtml(before) + "<mark>" + escapeHtml(match) + "</mark>" + escapeHtml(after);
|
||||
}
|
||||
|
||||
function filterSuggestions(query) {
|
||||
if (!query) return [];
|
||||
var q = query.toLowerCase();
|
||||
var results = [];
|
||||
var seen = Object.create(null);
|
||||
for (var i = 0; i < visitedUrls.length; i++) {
|
||||
var url = visitedUrls[i];
|
||||
var key = canonicalUrlKey(url);
|
||||
var matches = url.toLowerCase().indexOf(q) !== -1 || key.toLowerCase().indexOf(q) !== -1;
|
||||
if (seen[key] || !matches) continue;
|
||||
seen[key] = true;
|
||||
results.push(url);
|
||||
if (results.length >= 8) break;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function renderSuggestions(query) {
|
||||
suggestFiltered = filterSuggestions(query);
|
||||
suggestActive = -1;
|
||||
if (suggestFiltered.length === 0) {
|
||||
suggestions.hidden = true;
|
||||
scheduleResize();
|
||||
return;
|
||||
}
|
||||
var html = "";
|
||||
for (var i = 0; i < suggestFiltered.length; i++) {
|
||||
var url = suggestFiltered[i];
|
||||
var host = hostFromUrl(url);
|
||||
html +=
|
||||
'<div class="suggestion" data-index="' + i + '">' +
|
||||
'<div class="suggestion-icon">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.1"/><path d="M5 5.5h4M5 7h4M5 8.5h2.5" stroke="currentColor" stroke-width="0.9" stroke-linecap="round"/></svg>' +
|
||||
'</div>' +
|
||||
'<span class="suggestion-url">' + highlightMatch(url, query) + '</span>' +
|
||||
'<span class="suggestion-host">' + escapeHtml(host) + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
suggestions.innerHTML = html;
|
||||
suggestions.hidden = false;
|
||||
scheduleResize();
|
||||
}
|
||||
|
||||
function hideSuggestions() {
|
||||
var wasVisible = !suggestions.hidden;
|
||||
suggestions.hidden = true;
|
||||
suggestActive = -1;
|
||||
suggestFiltered = [];
|
||||
if (wasVisible) scheduleResize();
|
||||
}
|
||||
|
||||
function setSuggestActive(index) {
|
||||
var items = suggestions.querySelectorAll(".suggestion");
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
items[i].classList.toggle("active", i === index);
|
||||
}
|
||||
suggestActive = index;
|
||||
if (index >= 0 && index < suggestFiltered.length) {
|
||||
addressInput.value = suggestFiltered[index];
|
||||
}
|
||||
}
|
||||
|
||||
suggestions.addEventListener("mousedown", function (event) {
|
||||
event.preventDefault();
|
||||
var item = event.target.closest(".suggestion");
|
||||
if (!item) return;
|
||||
var idx = parseInt(item.getAttribute("data-index"), 10);
|
||||
if (idx >= 0 && idx < suggestFiltered.length) {
|
||||
var url = suggestFiltered[idx];
|
||||
hideSuggestions();
|
||||
navigateTo(url);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Error state ──
|
||||
|
||||
function showError(url, message) {
|
||||
var host = hostFromUrl(url);
|
||||
errorTitle.textContent = "Can\u2019t connect to " + host;
|
||||
errorDetail.textContent = message || "The site could not be reached. Check the address or try again.";
|
||||
errorState.hidden = false;
|
||||
emptyState.hidden = true;
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
errorState.hidden = true;
|
||||
}
|
||||
|
||||
async function closePageWebView() {
|
||||
if (!pageWebView) return;
|
||||
try {
|
||||
await pageWebView.close();
|
||||
} catch (error) {
|
||||
console.error("Failed to close page WebView", error);
|
||||
setStatus(error && error.message ? error.message : "Failed to close page WebView", 3000);
|
||||
}
|
||||
pageWebView = null;
|
||||
}
|
||||
|
||||
// ── Navigation ──
|
||||
|
||||
async function ensurePageWebView(url) {
|
||||
await syncBrowserViewport();
|
||||
var frame = pageFrame();
|
||||
if (!pageWebView) {
|
||||
await updateChromeOverlay();
|
||||
pageWebView = await window.zero.webviews.create({
|
||||
label: PAGE_WEBVIEW_LABEL,
|
||||
url: url,
|
||||
frame: frame,
|
||||
layer: PAGE_WEBVIEW_LAYER,
|
||||
transparent: false,
|
||||
bridge: false,
|
||||
});
|
||||
await updateChromeOverlay();
|
||||
startViewportPolling();
|
||||
emptyState.hidden = true;
|
||||
} else {
|
||||
await pageWebView.setFrame(frame);
|
||||
await pageWebView.navigate(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateTo(url, options) {
|
||||
options = options || {};
|
||||
var target = normalizeUrl(url);
|
||||
addressInput.value = target;
|
||||
updateSecurityIndicator(target);
|
||||
hideError();
|
||||
setLoading(true);
|
||||
setStatus("Loading " + hostFromUrl(target) + "\u2026");
|
||||
try {
|
||||
await ensurePageWebView(target);
|
||||
currentUrl = target;
|
||||
trackVisited(target);
|
||||
if (options.record !== false) remember(target);
|
||||
setLoading(false);
|
||||
setStatus(hostFromUrl(target), 2000);
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
await closePageWebView();
|
||||
var msg = error && error.message ? error.message : "Navigation failed";
|
||||
showError(target, msg);
|
||||
setStatus("Failed to load " + hostFromUrl(target));
|
||||
}
|
||||
}
|
||||
|
||||
async function applyResize() {
|
||||
if (!pageWebView) return;
|
||||
await updateChromeOverlay();
|
||||
var frame = pageFrame();
|
||||
await pageWebView.setFrame(frame);
|
||||
}
|
||||
|
||||
async function flushResizeQueue() {
|
||||
if (resizeInFlight) {
|
||||
resizeRequested = true;
|
||||
return;
|
||||
}
|
||||
resizeInFlight = true;
|
||||
try {
|
||||
do {
|
||||
resizeRequested = false;
|
||||
await applyResize();
|
||||
} while (resizeRequested);
|
||||
} catch (error) {
|
||||
setStatus(error.message || "Failed to resize page WebView");
|
||||
} finally {
|
||||
resizeInFlight = false;
|
||||
if (resizeRequested) flushResizeQueue();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleResize() {
|
||||
resizeRequested = true;
|
||||
if (resizeHandle) cancelAnimationFrame(resizeHandle);
|
||||
resizeHandle = requestAnimationFrame(function () {
|
||||
resizeHandle = 0;
|
||||
flushResizeQueue();
|
||||
});
|
||||
}
|
||||
|
||||
function startViewportPolling() {
|
||||
if (viewportPollHandle) return;
|
||||
viewportPollHandle = setInterval(async function () {
|
||||
if (!pageWebView) return;
|
||||
if (await syncBrowserViewport()) scheduleResize();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
window.zero.on("resize", function (detail) {
|
||||
if (!detail) return;
|
||||
lastNativeResizeAt = Date.now();
|
||||
var changed = updateBrowserViewport(detail.width, detail.height);
|
||||
if (changed) scheduleResize();
|
||||
});
|
||||
|
||||
window.zero.on("webview:navigate", function (detail) {
|
||||
if (!detail || detail.label !== PAGE_WEBVIEW_LABEL) return;
|
||||
updateAddressFromPage(detail.url);
|
||||
});
|
||||
|
||||
// ── Event listeners ──
|
||||
|
||||
form.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
hideSuggestions();
|
||||
addressInput.blur();
|
||||
navigateTo(addressInput.value);
|
||||
});
|
||||
|
||||
addressInput.addEventListener("focus", function () {
|
||||
addressBar.classList.add("focused");
|
||||
addressInput.select();
|
||||
renderSuggestions(addressInput.value);
|
||||
});
|
||||
|
||||
addressInput.addEventListener("blur", function () {
|
||||
addressBar.classList.remove("focused");
|
||||
hideSuggestions();
|
||||
});
|
||||
|
||||
addressInput.addEventListener("input", function () {
|
||||
renderSuggestions(addressInput.value);
|
||||
});
|
||||
|
||||
addressInput.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Escape") {
|
||||
if (!suggestions.hidden) {
|
||||
hideSuggestions();
|
||||
addressInput.value = currentUrl || addressInput.value;
|
||||
return;
|
||||
}
|
||||
addressInput.value = currentUrl || addressInput.value;
|
||||
addressInput.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestions.hidden || suggestFiltered.length === 0) return;
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
var next = suggestActive + 1;
|
||||
if (next >= suggestFiltered.length) next = 0;
|
||||
setSuggestActive(next);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
var prev = suggestActive - 1;
|
||||
if (prev < 0) prev = suggestFiltered.length - 1;
|
||||
setSuggestActive(prev);
|
||||
} else if (event.key === "Enter" && suggestActive >= 0) {
|
||||
event.preventDefault();
|
||||
var url = suggestFiltered[suggestActive];
|
||||
hideSuggestions();
|
||||
navigateTo(url);
|
||||
}
|
||||
});
|
||||
|
||||
backButton.addEventListener("click", function () {
|
||||
if (historyIndex <= 0) return;
|
||||
historyIndex -= 1;
|
||||
updateHistoryButtons();
|
||||
navigateTo(navHistory[historyIndex], { record: false });
|
||||
});
|
||||
|
||||
forwardButton.addEventListener("click", function () {
|
||||
if (historyIndex >= navHistory.length - 1) return;
|
||||
historyIndex += 1;
|
||||
updateHistoryButtons();
|
||||
navigateTo(navHistory[historyIndex], { record: false });
|
||||
});
|
||||
|
||||
reloadButton.addEventListener("click", function () {
|
||||
if (isLoading) {
|
||||
setLoading(false);
|
||||
setStatus("Stopped", 2000);
|
||||
return;
|
||||
}
|
||||
reloadPage();
|
||||
});
|
||||
|
||||
errorRetry.addEventListener("click", function () {
|
||||
reloadPage();
|
||||
});
|
||||
|
||||
function reloadPage() {
|
||||
navigateTo(currentUrl || addressInput.value, { record: false });
|
||||
}
|
||||
|
||||
async function applyZoom(level) {
|
||||
zoomLevel = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(level * 100) / 100));
|
||||
if (!pageWebView) return;
|
||||
try {
|
||||
await pageWebView.setZoom(zoomLevel);
|
||||
var pct = Math.round(zoomLevel * 100);
|
||||
setStatus(pct + "%", 1200);
|
||||
} catch (error) {
|
||||
console.error("Failed to zoom page WebView", error);
|
||||
setStatus(error && error.message ? error.message : "Failed to zoom page WebView", 3000);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", function (event) {
|
||||
var isMod = event.metaKey || event.ctrlKey;
|
||||
if (!isMod) return;
|
||||
|
||||
if (event.key === "=" || event.key === "+") {
|
||||
event.preventDefault();
|
||||
applyZoom(zoomLevel + ZOOM_STEP);
|
||||
} else if (event.key === "-") {
|
||||
event.preventDefault();
|
||||
applyZoom(zoomLevel - ZOOM_STEP);
|
||||
} else if (event.key === "0") {
|
||||
event.preventDefault();
|
||||
applyZoom(1.0);
|
||||
}
|
||||
});
|
||||
|
||||
window.zero.on("shortcut", function (detail) {
|
||||
if (!detail) return;
|
||||
if (detail.command === "zoom-in") {
|
||||
applyZoom(zoomLevel + ZOOM_STEP);
|
||||
} else if (detail.command === "zoom-out") {
|
||||
applyZoom(zoomLevel - ZOOM_STEP);
|
||||
} else if (detail.command === "zoom-reset") {
|
||||
applyZoom(1.0);
|
||||
} else if (detail.command === "reload") {
|
||||
reloadPage();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
scheduleResize();
|
||||
});
|
||||
window.addEventListener("DOMContentLoaded", function () {
|
||||
navigateTo(addressInput.value);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'">
|
||||
<title>Zero Browser</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="browser-shell">
|
||||
<div class="toolbar" id="toolbar">
|
||||
<div class="toolbar-row">
|
||||
<div class="nav-group">
|
||||
<button class="nav-btn" id="back" type="button" aria-label="Back" disabled>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button class="nav-btn" id="forward" type="button" aria-label="Forward" disabled>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="address-bar-wrapper" id="browser-form">
|
||||
<div class="address-bar" id="address-bar">
|
||||
<div class="address-icon" id="address-icon">
|
||||
<svg class="icon-lock" width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<rect x="2.5" y="6" width="9" height="6.5" rx="1.5" stroke="currentColor" stroke-width="1.3"/>
|
||||
<path d="M4.5 6V4.5a2.5 2.5 0 015 0V6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<svg class="icon-globe" width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2"/>
|
||||
<ellipse cx="7" cy="7" rx="2.8" ry="5.5" stroke="currentColor" stroke-width="1.2"/>
|
||||
<path d="M1.5 7h11M2 4.5h10M2 9.5h10" stroke="currentColor" stroke-width="1"/>
|
||||
</svg>
|
||||
</div>
|
||||
<input id="url-input" type="text" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="none" enterkeyhint="go" value="https://example.com" aria-label="Address" placeholder="Search or enter address">
|
||||
<button class="reload-btn" id="reload" type="button" aria-label="Reload">
|
||||
<svg class="icon-reload" width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M11.5 7a4.5 4.5 0 1 1-1.26-3.12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M10.5 1v3.25h-3.25" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<svg class="icon-stop" width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<div class="loading-bar" id="loading-bar"></div>
|
||||
</div>
|
||||
<div class="suggestions" id="suggestions" hidden></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="page-stage" aria-label="Browser page WebView">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="24" r="20" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
|
||||
<circle cx="24" cy="24" r="12" stroke="currentColor" stroke-width="1.5" opacity="0.2"/>
|
||||
<circle cx="24" cy="24" r="4" fill="currentColor" opacity="0.15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="empty-title">Loading page</p>
|
||||
<p class="empty-sub">A native page WebView will appear here</p>
|
||||
</div>
|
||||
|
||||
<div class="error-state" id="error-state" hidden>
|
||||
<div class="error-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="24" r="20" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M24 16v10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="24" cy="32" r="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="error-title" id="error-title">Can't connect to the server</p>
|
||||
<p class="error-detail" id="error-detail">The site could not be reached.</p>
|
||||
<button class="error-retry" id="error-retry" type="button">Try again</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="status-bar hidden" id="status" role="status" aria-live="polite">
|
||||
<span class="status-text" id="status-text">Ready</span>
|
||||
</div>
|
||||
</main>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,458 @@
|
||||
:root {
|
||||
--toolbar-bg: rgba(251, 251, 254, 0.82);
|
||||
--toolbar-border: rgba(0, 0, 0, 0.08);
|
||||
--toolbar-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
--bar-bg: rgba(0, 0, 0, 0.04);
|
||||
--bar-bg-hover: rgba(0, 0, 0, 0.06);
|
||||
--bar-border: rgba(0, 0, 0, 0.08);
|
||||
--bar-focus-ring: rgba(59, 130, 246, 0.4);
|
||||
--bar-focus-bg: #fff;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: rgba(0, 0, 0, 0.45);
|
||||
--text-tertiary: rgba(0, 0, 0, 0.3);
|
||||
--nav-hover: rgba(0, 0, 0, 0.06);
|
||||
--nav-active: rgba(0, 0, 0, 0.1);
|
||||
--stage-bg: #f5f5f7;
|
||||
--status-bg: rgba(255, 255, 255, 0.88);
|
||||
--status-border: rgba(0, 0, 0, 0.06);
|
||||
--accent: #007aff;
|
||||
--accent-text: #0066d6;
|
||||
--loading-bar: #007aff;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--toolbar-bg: rgba(40, 40, 44, 0.82);
|
||||
--toolbar-border: rgba(255, 255, 255, 0.08);
|
||||
--toolbar-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
--bar-bg: rgba(255, 255, 255, 0.07);
|
||||
--bar-bg-hover: rgba(255, 255, 255, 0.1);
|
||||
--bar-border: rgba(255, 255, 255, 0.1);
|
||||
--bar-focus-ring: rgba(100, 160, 255, 0.4);
|
||||
--bar-focus-bg: rgba(30, 30, 34, 0.95);
|
||||
--text-primary: #f0f0f0;
|
||||
--text-secondary: rgba(255, 255, 255, 0.5);
|
||||
--text-tertiary: rgba(255, 255, 255, 0.25);
|
||||
--nav-hover: rgba(255, 255, 255, 0.08);
|
||||
--nav-active: rgba(255, 255, 255, 0.14);
|
||||
--stage-bg: #1c1c1e;
|
||||
--status-bg: rgba(44, 44, 48, 0.9);
|
||||
--status-border: rgba(255, 255, 255, 0.08);
|
||||
--accent: #4da3ff;
|
||||
--accent-text: #6bb3ff;
|
||||
--loading-bar: #4da3ff;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
button, input { font: inherit; }
|
||||
button { cursor: pointer; border: 0; background: none; }
|
||||
|
||||
/* ── Shell ── */
|
||||
.browser-shell {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.toolbar {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
background: var(--toolbar-bg);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
border-bottom: 0.5px solid var(--toolbar-border);
|
||||
box-shadow: var(--toolbar-shadow);
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
/* ── Navigation ── */
|
||||
.nav-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.12s ease, opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.nav-btn:not(:disabled):hover { background: var(--nav-hover); }
|
||||
.nav-btn:not(:disabled):active { background: var(--nav-active); }
|
||||
|
||||
.nav-btn:disabled {
|
||||
opacity: 0.28;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Address Bar ── */
|
||||
.address-bar-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.address-bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
background: var(--bar-bg);
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.address-bar:hover {
|
||||
background: var(--bar-bg-hover);
|
||||
}
|
||||
|
||||
.address-bar.focused {
|
||||
background: var(--bar-focus-bg);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--bar-focus-ring);
|
||||
}
|
||||
|
||||
.address-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.address-bar.secure .address-icon { color: var(--accent-text); }
|
||||
|
||||
.address-icon .icon-globe { display: block; }
|
||||
.address-icon .icon-lock { display: none; }
|
||||
.address-bar.secure .address-icon .icon-globe { display: none; }
|
||||
.address-bar.secure .address-icon .icon-lock { display: block; }
|
||||
|
||||
#url-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.01em;
|
||||
padding: 0 2px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#url-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
#url-input::selection {
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.reload-btn {
|
||||
width: 34px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 0 7px 7px 0;
|
||||
transition: color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
.reload-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reload-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--nav-hover);
|
||||
}
|
||||
|
||||
.reload-btn:active {
|
||||
background: var(--nav-active);
|
||||
}
|
||||
|
||||
.reload-btn .icon-stop { display: none; }
|
||||
.address-bar.loading .reload-btn .icon-reload { display: none; }
|
||||
.address-bar.loading .reload-btn .icon-stop { display: block; }
|
||||
|
||||
/* ── Suggestions ── */
|
||||
.address-bar-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border-radius: 10px;
|
||||
background: var(--status-bg);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
border: 0.5px solid var(--toolbar-border);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.14), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
padding: 4px;
|
||||
animation: suggestions-in 0.15s ease;
|
||||
}
|
||||
|
||||
.suggestions[hidden] { display: none; }
|
||||
|
||||
@keyframes suggestions-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
transition: background 0.08s ease;
|
||||
}
|
||||
|
||||
.suggestion:hover,
|
||||
.suggestion.active {
|
||||
background: var(--nav-hover);
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.suggestion-url {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.suggestion-url mark {
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.suggestion-host {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Loading Bar ── */
|
||||
.loading-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
width: 0;
|
||||
background: var(--loading-bar);
|
||||
border-radius: 0 1px 1px 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.address-bar.loading .loading-bar {
|
||||
opacity: 1;
|
||||
animation: loading-progress 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes loading-progress {
|
||||
0% { width: 0; }
|
||||
10% { width: 25%; }
|
||||
30% { width: 50%; }
|
||||
50% { width: 70%; }
|
||||
70% { width: 82%; }
|
||||
90% { width: 90%; }
|
||||
100% { width: 94%; }
|
||||
}
|
||||
|
||||
.address-bar.load-complete .loading-bar {
|
||||
width: 100% !important;
|
||||
opacity: 0;
|
||||
transition: width 0.2s ease, opacity 0.4s ease 0.2s;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ── Page Stage ── */
|
||||
.page-stage {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-tertiary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.empty-state[hidden] { display: none; }
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 4px;
|
||||
animation: pulse-ring 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0%, 100% { opacity: 0.7; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.04); }
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Error State ── */
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
animation: error-in 0.3s ease;
|
||||
}
|
||||
|
||||
.error-state[hidden] { display: none; }
|
||||
|
||||
@keyframes error-in {
|
||||
from { opacity: 0; transform: scale(0.97); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.error-detail {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.error-retry {
|
||||
margin-top: 8px;
|
||||
padding: 7px 20px;
|
||||
border-radius: 7px;
|
||||
border: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
transition: filter 0.12s ease;
|
||||
}
|
||||
|
||||
.error-retry:hover { filter: brightness(1.1); }
|
||||
.error-retry:active { filter: brightness(0.9); }
|
||||
|
||||
/* ── Status Bar ── */
|
||||
.status-bar {
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
z-index: 20;
|
||||
max-width: min(480px, calc(100vw - 16px));
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--status-bg);
|
||||
backdrop-filter: saturate(160%) blur(12px);
|
||||
-webkit-backdrop-filter: saturate(160%) blur(12px);
|
||||
border: 0.5px solid var(--status-border);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-bar.hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
const std = @import("std");
|
||||
const runner = @import("runner");
|
||||
const native_sdk = @import("native_sdk");
|
||||
|
||||
pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic);
|
||||
|
||||
const app_permissions = [_][]const u8{native_sdk.security.permission_window};
|
||||
const bridge_origins = [_][]const u8{"zero://app"};
|
||||
const window_permission = [_][]const u8{native_sdk.security.permission_window};
|
||||
const builtin_policies = [_]native_sdk.BridgeCommandPolicy{
|
||||
.{ .name = "native-sdk.window.list", .permissions = &window_permission, .origins = &bridge_origins },
|
||||
.{ .name = "native-sdk.webview.create", .permissions = &window_permission, .origins = &bridge_origins },
|
||||
.{ .name = "native-sdk.webview.list", .permissions = &window_permission, .origins = &bridge_origins },
|
||||
.{ .name = "native-sdk.webview.setFrame", .permissions = &window_permission, .origins = &bridge_origins },
|
||||
.{ .name = "native-sdk.webview.navigate", .permissions = &window_permission, .origins = &bridge_origins },
|
||||
.{ .name = "native-sdk.webview.setZoom", .permissions = &window_permission, .origins = &bridge_origins },
|
||||
.{ .name = "native-sdk.webview.setLayer", .permissions = &window_permission, .origins = &bridge_origins },
|
||||
.{ .name = "native-sdk.webview.close", .permissions = &window_permission, .origins = &bridge_origins },
|
||||
};
|
||||
|
||||
const BrowserApp = struct {
|
||||
fn app(self: *@This()) native_sdk.App {
|
||||
return .{
|
||||
.context = self,
|
||||
.name = "browser",
|
||||
.source = native_sdk.frontend.productionSource(.{
|
||||
.dist = "frontend",
|
||||
.entry = "index.html",
|
||||
.origin = "zero://app",
|
||||
.spa_fallback = false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub fn main(init: std.process.Init) !void {
|
||||
var app = BrowserApp{};
|
||||
try runner.runWithOptions(app.app(), .{
|
||||
.app_name = "Zero Browser",
|
||||
.window_title = "Zero Browser",
|
||||
.bundle_id = "dev.native_sdk.browser",
|
||||
.builtin_bridge = .{ .enabled = true, .commands = &builtin_policies },
|
||||
.security = .{
|
||||
.permissions = &app_permissions,
|
||||
.navigation = .{
|
||||
.allowed_origins = &.{"*"},
|
||||
.external_links = .{ .action = .deny },
|
||||
},
|
||||
},
|
||||
}, init);
|
||||
}
|
||||
|
||||
test "browser app serves static frontend assets" {
|
||||
var state = BrowserApp{};
|
||||
const app = state.app();
|
||||
try std.testing.expectEqualStrings("browser", app.name);
|
||||
try std.testing.expectEqual(native_sdk.WebViewSourceKind.assets, app.source.kind);
|
||||
try std.testing.expectEqualStrings("frontend", app.source.asset_options.?.root_path);
|
||||
try std.testing.expectEqualStrings("index.html", app.source.asset_options.?.entry);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
const std = @import("std");
|
||||
const build_options = @import("build_options");
|
||||
const native_sdk = @import("native_sdk");
|
||||
const app_manifest = @import("app_manifest_zon");
|
||||
const manifest_shortcuts = if (@hasField(@TypeOf(app_manifest), "shortcuts")) app_manifest.shortcuts else .{};
|
||||
const manifest_windows = if (@hasField(@TypeOf(app_manifest), "windows")) app_manifest.windows else .{};
|
||||
|
||||
pub const RunOptions = struct {
|
||||
app_name: []const u8,
|
||||
window_title: []const u8 = "",
|
||||
bundle_id: []const u8,
|
||||
icon_path: []const u8 = "assets/icon.png",
|
||||
bridge: ?native_sdk.BridgeDispatcher = null,
|
||||
builtin_bridge: native_sdk.BridgePolicy = .{},
|
||||
security: native_sdk.SecurityPolicy = .{},
|
||||
shortcuts: ?[]const native_sdk.Shortcut = null,
|
||||
|
||||
fn appInfo(self: RunOptions, buffers: *StateBuffers) native_sdk.AppInfo {
|
||||
var info: native_sdk.AppInfo = .{
|
||||
.app_name = self.app_name,
|
||||
// The identity the OS shows (application menu, Dock, About
|
||||
// panel) reads straight from app.zon at comptime, so dev
|
||||
// runs carry the same display name and version a packaged
|
||||
// bundle gets from its Info.plist.
|
||||
.display_name = manifestStringField("display_name"),
|
||||
.version = manifestStringField("version"),
|
||||
.description = manifestStringField("description"),
|
||||
.has_web_content = manifestHasWebContent(),
|
||||
.window_title = self.window_title,
|
||||
.bundle_id = self.bundle_id,
|
||||
.icon_path = self.icon_path,
|
||||
};
|
||||
const windows = manifestWindowOptions(buffers);
|
||||
if (windows.len > 0) {
|
||||
info.main_window = windows[0];
|
||||
info.windows = windows;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
fn resolvedShortcuts(self: RunOptions, storage: *ShortcutStorage) []const native_sdk.Shortcut {
|
||||
return self.shortcuts orelse storage.fromManifest();
|
||||
}
|
||||
};
|
||||
|
||||
const ShortcutStorage = struct {
|
||||
shortcuts: [native_sdk.platform.max_shortcuts]native_sdk.Shortcut = undefined,
|
||||
|
||||
fn fromManifest(self: *ShortcutStorage) []const native_sdk.Shortcut {
|
||||
comptime {
|
||||
if (manifest_shortcuts.len > native_sdk.platform.max_shortcuts) {
|
||||
@compileError("app.zon defines too many shortcuts");
|
||||
}
|
||||
}
|
||||
|
||||
inline for (manifest_shortcuts, 0..) |shortcut, index| {
|
||||
self.shortcuts[index] = .{
|
||||
.id = shortcut.id,
|
||||
.key = shortcut.key,
|
||||
.modifiers = shortcutModifiers(shortcut),
|
||||
};
|
||||
}
|
||||
return self.shortcuts[0..manifest_shortcuts.len];
|
||||
}
|
||||
};
|
||||
|
||||
const StateBuffers = struct {
|
||||
restored_windows: [native_sdk.platform.max_windows]native_sdk.WindowOptions = undefined,
|
||||
};
|
||||
|
||||
/// A top-level app.zon string field (`display_name`, `version`,
|
||||
/// `description`), or "" when the manifest omits it — optional identity
|
||||
/// stays optional all the way into `AppInfo`.
|
||||
fn manifestStringField(comptime field: []const u8) []const u8 {
|
||||
if (comptime !@hasField(@TypeOf(app_manifest), field)) return "";
|
||||
const value = @field(app_manifest, field);
|
||||
if (comptime @TypeOf(value) == @TypeOf(null)) return "";
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Whether app.zon declares web content: the `webview` capability or a
|
||||
/// `frontend` block. Hosts build honest default menus from this — web
|
||||
/// items like Reload only exist when a webview can answer them, so
|
||||
/// canvas-only apps never ship dead menu items.
|
||||
fn manifestHasWebContent() bool {
|
||||
if (comptime @hasField(@TypeOf(app_manifest), "frontend")) return true;
|
||||
if (comptime !@hasField(@TypeOf(app_manifest), "capabilities")) return false;
|
||||
inline for (app_manifest.capabilities) |capability| {
|
||||
if (comptime std.mem.eql(u8, capability, "webview")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn manifestWindowOptions(buffers: *StateBuffers) []const native_sdk.WindowOptions {
|
||||
comptime {
|
||||
if (manifest_windows.len > native_sdk.platform.max_windows) {
|
||||
@compileError("app.zon defines too many windows");
|
||||
}
|
||||
}
|
||||
|
||||
inline for (manifest_windows, 0..) |window, index| {
|
||||
buffers.restored_windows[index] = manifestWindow(window, index);
|
||||
}
|
||||
return buffers.restored_windows[0..manifest_windows.len];
|
||||
}
|
||||
|
||||
fn manifestWindow(comptime window: anytype, comptime index: usize) native_sdk.WindowOptions {
|
||||
return .{
|
||||
.id = index + 1,
|
||||
.label = windowLabel(window, index),
|
||||
.title = windowTitle(window),
|
||||
.default_frame = native_sdk.geometry.RectF.init(
|
||||
windowFloat(window, "x", 0),
|
||||
windowFloat(window, "y", 0),
|
||||
windowFloat(window, "width", 720),
|
||||
windowFloat(window, "height", 480),
|
||||
),
|
||||
.resizable = windowBool(window, "resizable", true),
|
||||
.restore_state = windowBool(window, "restore_state", true),
|
||||
.restore_policy = windowRestorePolicy(window),
|
||||
};
|
||||
}
|
||||
|
||||
fn windowLabel(comptime window: anytype, comptime index: usize) []const u8 {
|
||||
if (comptime @hasField(@TypeOf(window), "label")) return window.label;
|
||||
return if (index == 0) "main" else "window";
|
||||
}
|
||||
|
||||
fn windowTitle(comptime window: anytype) []const u8 {
|
||||
if (comptime !@hasField(@TypeOf(window), "title")) return "";
|
||||
const title = window.title;
|
||||
if (comptime @TypeOf(title) == @TypeOf(null)) return "";
|
||||
return title;
|
||||
}
|
||||
|
||||
fn windowFloat(comptime window: anytype, comptime field: []const u8, comptime default_value: f32) f32 {
|
||||
if (comptime @hasField(@TypeOf(window), field)) return @field(window, field);
|
||||
return default_value;
|
||||
}
|
||||
|
||||
fn windowBool(comptime window: anytype, comptime field: []const u8, comptime default_value: bool) bool {
|
||||
if (comptime @hasField(@TypeOf(window), field)) return @field(window, field);
|
||||
return default_value;
|
||||
}
|
||||
|
||||
fn windowRestorePolicy(comptime window: anytype) native_sdk.WindowRestorePolicy {
|
||||
if (comptime !@hasField(@TypeOf(window), "restore_policy")) return .clamp_to_visible_screen;
|
||||
const value = window.restore_policy;
|
||||
if (comptime std.mem.eql(u8, value, "clamp_to_visible_screen")) return .clamp_to_visible_screen;
|
||||
if (comptime std.mem.eql(u8, value, "center_on_primary")) return .center_on_primary;
|
||||
@compileError("unknown app.zon window restore_policy");
|
||||
}
|
||||
|
||||
fn shortcutModifiers(comptime shortcut: anytype) native_sdk.ShortcutModifiers {
|
||||
const values = if (@hasField(@TypeOf(shortcut), "modifiers")) shortcut.modifiers else .{};
|
||||
var modifiers: native_sdk.ShortcutModifiers = .{};
|
||||
inline for (values) |value| {
|
||||
const modifier: []const u8 = value;
|
||||
if (comptime std.mem.eql(u8, modifier, "primary")) {
|
||||
modifiers.primary = true;
|
||||
} else if (comptime std.mem.eql(u8, modifier, "command")) {
|
||||
modifiers.command = true;
|
||||
} else if (comptime std.mem.eql(u8, modifier, "control")) {
|
||||
modifiers.control = true;
|
||||
} else if (comptime std.mem.eql(u8, modifier, "option") or std.mem.eql(u8, modifier, "alt")) {
|
||||
modifiers.option = true;
|
||||
} else if (comptime std.mem.eql(u8, modifier, "shift")) {
|
||||
modifiers.shift = true;
|
||||
} else {
|
||||
@compileError("unknown app.zon shortcut modifier");
|
||||
}
|
||||
}
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
pub fn runWithOptions(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
|
||||
if (comptime std.mem.eql(u8, build_options.platform, "macos")) {
|
||||
try runMacos(app, options, init);
|
||||
} else if (comptime std.mem.eql(u8, build_options.platform, "linux")) {
|
||||
try runLinux(app, options, init);
|
||||
} else if (comptime std.mem.eql(u8, build_options.platform, "windows")) {
|
||||
try runWindows(app, options, init);
|
||||
} else {
|
||||
try runNull(app, options, init);
|
||||
}
|
||||
}
|
||||
|
||||
fn runNull(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
|
||||
var buffers: StateBuffers = undefined;
|
||||
const app_info = options.appInfo(&buffers);
|
||||
var null_platform = native_sdk.NullPlatform.initWithOptions(.{}, webEngine(), app_info);
|
||||
try runRuntime(app, options, init, null_platform.platform());
|
||||
}
|
||||
|
||||
fn runMacos(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
|
||||
var buffers: StateBuffers = undefined;
|
||||
const app_info = options.appInfo(&buffers);
|
||||
var mac_platform = try native_sdk.platform.macos.MacPlatform.initWithOptions(native_sdk.geometry.SizeF.init(1120, 780), webEngine(), app_info);
|
||||
defer mac_platform.deinit();
|
||||
try runRuntime(app, options, init, mac_platform.platform());
|
||||
}
|
||||
|
||||
fn runLinux(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
|
||||
var buffers: StateBuffers = undefined;
|
||||
const app_info = options.appInfo(&buffers);
|
||||
var linux_platform = try native_sdk.platform.linux.LinuxPlatform.initWithOptions(native_sdk.geometry.SizeF.init(960, 720), webEngine(), app_info);
|
||||
defer linux_platform.deinit();
|
||||
try runRuntime(app, options, init, linux_platform.platform());
|
||||
}
|
||||
|
||||
fn runWindows(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
|
||||
var buffers: StateBuffers = undefined;
|
||||
const app_info = options.appInfo(&buffers);
|
||||
var windows_platform = try native_sdk.platform.windows.WindowsPlatform.initWithOptions(native_sdk.geometry.SizeF.init(960, 720), webEngine(), app_info);
|
||||
defer windows_platform.deinit();
|
||||
try runRuntime(app, options, init, windows_platform.platform());
|
||||
}
|
||||
|
||||
fn runRuntime(app: native_sdk.App, options: RunOptions, init: std.process.Init, platform: native_sdk.Platform) !void {
|
||||
var shortcut_storage: ShortcutStorage = .{};
|
||||
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
|
||||
// The Runtime is tens of megabytes; construct on the heap (default
|
||||
// main-thread stacks overflow on a stack instance).
|
||||
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
|
||||
defer std.heap.page_allocator.destroy(runtime);
|
||||
native_sdk.Runtime.initAt(runtime, .{
|
||||
.platform = platform,
|
||||
.bridge = options.bridge,
|
||||
.builtin_bridge = options.builtin_bridge,
|
||||
.security = options.security,
|
||||
.shortcuts = shortcuts,
|
||||
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", options.window_title) else null,
|
||||
});
|
||||
try runtime.run(app);
|
||||
}
|
||||
|
||||
fn webEngine() native_sdk.WebEngine {
|
||||
if (comptime std.mem.eql(u8, build_options.web_engine, "chromium")) return .chromium;
|
||||
return .system;
|
||||
}
|
||||
Reference in New Issue
Block a user