diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc0ff3ea..c01b4113 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,7 @@ jobs: - name: Scaffold and test frontend templates run: | set -euo pipefail - for frontend in next vite react svelte vue; do + for frontend in next vite react svelte vue angular; do app=".zig-cache/scaffold-${frontend}" rm -rf "$app" ./zig-out/bin/zero-native init "$app" --frontend "$frontend" diff --git a/README.md b/README.md index 9d715103..a2873ce7 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ Framework-specific starter examples live in `examples/`: - `examples/react` - `examples/svelte` - `examples/vue` +- `examples/angular` Each example is a complete zero-native app with `app.zon`, a Zig shell, and a minimal frontend project. Run one with `zig build run` from its directory. diff --git a/build.zig b/build.zig index 705703f3..56d289b8 100644 --- a/build.zig +++ b/build.zig @@ -215,6 +215,7 @@ pub fn build(b: *std.Build) void { addExampleTestStep(b, frontend_examples_step, "test-example-react", "Run React example tests", "examples/react"); addExampleTestStep(b, frontend_examples_step, "test-example-svelte", "Run Svelte example tests", "examples/svelte"); addExampleTestStep(b, frontend_examples_step, "test-example-vue", "Run Vue example tests", "examples/vue"); + addExampleTestStep(b, frontend_examples_step, "test-example-angular", "Run Angular example tests", "examples/angular"); const mobile_examples_step = b.step("test-examples-mobile", "Verify mobile example project layouts"); addLayoutCheckStep(b, mobile_examples_step, "test-example-ios-layout", "Verify iOS example layout", &.{ diff --git a/docs/src/app/cli/page.mdx b/docs/src/app/cli/page.mdx index 588d00a4..92199843 100644 --- a/docs/src/app/cli/page.mdx +++ b/docs/src/app/cli/page.mdx @@ -13,7 +13,7 @@ The `zero-native` CLI provides project scaffolding, validation, packaging, and d - zero-native init [path] --frontend <next|vite|react|svelte|vue> + zero-native init [path] --frontend <next|vite|react|svelte|vue|angular> Scaffold a new zero-native project with the specified frontend. Omit path to scaffold into the current directory. diff --git a/docs/src/app/quick-start/page.mdx b/docs/src/app/quick-start/page.mdx index cd9946f8..7bbd4fff 100644 --- a/docs/src/app/quick-start/page.mdx +++ b/docs/src/app/quick-start/page.mdx @@ -19,7 +19,7 @@ zero-native init my_app --frontend next cd my_app ``` -Frontend options: `next`, `vite`, `react`, `svelte`, `vue`. +Frontend options: `next`, `vite`, `react`, `svelte`, `vue`, `angular`. This scaffolds a complete zero-native project: diff --git a/examples/.gitignore b/examples/.gitignore index 86268824..cebacf9b 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -11,6 +11,7 @@ out/ dist/ next-env.d.ts package-lock.json +.angular/ # Local iOS build products. Libraries/ diff --git a/examples/README.md b/examples/README.md index 88cffdef..543c5877 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,7 +5,7 @@ Use these examples as a progressive path through zero-native: - `hello` is the smallest desktop shell with inline HTML. - `webview` demonstrates bridge commands, built-in window APIs, security policy, automation, and optional CEF. - `browser` is a vanilla no-build shell that uses layered WebViews for isolated page content on macOS and Linux system WebViews. -- `react`, `svelte`, `vue`, and `next` show framework projects with managed frontend assets and dev-server workflows. +- `react`, `svelte`, `vue`, `angular`, and `next` show framework projects with managed frontend assets and dev-server workflows. - `ios` and `android` show how mobile hosts link the zero-native C ABI from `libzero-native.a`. Start with `hello`, then move to `webview` when you need native commands or WebView policy. Use `browser` when you want to see layered native WebViews, and use a framework example when building a real frontend. diff --git a/examples/angular/README.md b/examples/angular/README.md new file mode 100644 index 00000000..32fa6500 --- /dev/null +++ b/examples/angular/README.md @@ -0,0 +1,33 @@ +# Angular Example + +A super basic zero-native example using Angular for the frontend and Zig for the native shell. + +## Run + +```bash +zig build run +``` + +The build installs frontend dependencies, builds the frontend, and opens the native WebView shell. + +## Dev Server + +```bash +zig build dev +``` + +This starts the Angular dev server from `app.zon`, waits for `http://127.0.0.1:4200/`, and launches the native shell with `ZERO_NATIVE_FRONTEND_URL`. + +## Frontend + +- Frontend: `angular` (Angular 20) +- Production assets: `frontend/dist` +- Dev URL: `http://127.0.0.1:4200/` + +## Using Outside The Repo + +This example references zero-native via relative path (`../../`). To use it standalone, override the path: + +```bash +zig build run -Dzero-native-path=/path/to/zero-native +``` diff --git a/examples/angular/app.zon b/examples/angular/app.zon new file mode 100644 index 00000000..c48e03cb --- /dev/null +++ b/examples/angular/app.zon @@ -0,0 +1,32 @@ +.{ + .id = "dev.zero_native.angular-example", + .name = "angular-example", + .display_name = "Angular Example", + .version = "0.1.0", + .icons = .{ "assets/icon.icns" }, + .platforms = .{ "macos", "linux" }, + .permissions = .{}, + .capabilities = .{ "webview" }, + .frontend = .{ + .dist = "frontend/dist", + .entry = "index.html", + .spa_fallback = true, + .dev = .{ + .url = "http://127.0.0.1:4200/", + .command = .{ "npm", "--prefix", "frontend", "run", "dev", "--", "--host", "127.0.0.1" }, + .ready_path = "/", + .timeout_ms = 30000, + }, + }, + .security = .{ + .navigation = .{ + .allowed_origins = .{ "zero://app", "zero://inline", "http://127.0.0.1:4200" }, + .external_links = .{ .action = "deny" }, + }, + }, + .web_engine = "system", + .cef = .{ .dir = "third_party/cef/macos", .auto_install = false }, + .windows = .{ + .{ .label = "main", .title = "Angular Example", .width = 720, .height = 480, .restore_state = true }, + }, +} diff --git a/examples/angular/assets/icon.icns b/examples/angular/assets/icon.icns new file mode 100644 index 00000000..bcc99be1 Binary files /dev/null and b/examples/angular/assets/icon.icns differ diff --git a/examples/angular/build.zig b/examples/angular/build.zig new file mode 100644 index 00000000..93f2cda6 --- /dev/null +++ b/examples/angular/build.zig @@ -0,0 +1,449 @@ +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 PackageTarget = enum { + macos, + windows, + linux, +}; + +const default_zero_native_path = "../.."; +const app_exe_name = "angular"; + +pub fn build(b: *std.Build) void { + const target = zeroNativeTarget(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 debug_overlay = b.option(bool, "debug-overlay", "Enable debug overlay output") orelse false; + const automation_enabled = b.option(bool, "automation", "Enable zero-native automation artifacts") orelse false; + const js_bridge_enabled = b.option(bool, "js-bridge", "Enable optional JavaScript bridge stubs") 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 package_target = b.option(PackageTarget, "package-target", "Package target: macos, windows, linux") orelse .macos; + const zero_native_path = b.option([]const u8, "zero-native-path", "Path to the zero-native framework checkout") orelse default_zero_native_path; + const optimize_name = @tagName(optimize); + 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 app_web_engine = appWebEngineConfig(); + const web_engine = web_engine_override orelse app_web_engine.web_engine; + const cef_dir = cef_dir_override orelse defaultCefDir(selected_platform, app_web_engine.cef_dir); + const cef_auto_install = cef_auto_install_override orelse app_web_engine.cef_auto_install; + if (web_engine == .chromium and selected_platform == .null) { + @panic("-Dweb-engine=chromium requires -Dplatform=macos, linux, or windows"); + } + + const zero_native_mod = zeroNativeModule(b, target, optimize, zero_native_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, "debug_overlay", debug_overlay); + options.addOption(bool, "automation", automation_enabled); + options.addOption(bool, "js_bridge", js_bridge_enabled); + const options_mod = options.createModule(); + + const runner_mod = localModule(b, target, optimize, "src/runner.zig"); + runner_mod.addImport("zero-native", zero_native_mod); + runner_mod.addImport("build_options", options_mod); + + const app_mod = localModule(b, target, optimize, "src/main.zig"); + app_mod.addImport("zero-native", zero_native_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, zero_native_path, cef_dir, cef_auto_install); + b.installArtifact(exe); + + const frontend_install = b.addSystemCommand(&.{ "npm", "install", "--prefix", "frontend" }); + const frontend_install_step = b.step("frontend-install", "Install frontend dependencies"); + frontend_install_step.dependOn(&frontend_install.step); + + const frontend_build = b.addSystemCommand(&.{ "npm", "--prefix", "frontend", "run", "build" }); + frontend_build.step.dependOn(&frontend_install.step); + const frontend_step = b.step("frontend-build", "Build the frontend"); + frontend_step.dependOn(&frontend_build.step); + + const run = b.addRunArtifact(exe); + run.step.dependOn(&frontend_build.step); + addCefRuntimeRunFiles(b, target, run, exe, web_engine, cef_dir); + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run.step); + + const dev = b.addSystemCommand(&.{ "zero-native", "dev", "--manifest", "app.zon", "--binary" }); + dev.addFileArg(exe.getEmittedBin()); + dev.step.dependOn(&exe.step); + dev.step.dependOn(&frontend_install.step); + const dev_step = b.step("dev", "Run the frontend dev server and native shell"); + dev_step.dependOn(&dev.step); + + const package = b.addSystemCommand(&.{ + "zero-native", + "package", + "--target", + @tagName(package_target), + "--manifest", + "app.zon", + "--assets", + "frontend/dist", + "--optimize", + optimize_name, + "--output", + b.fmt("zig-out/package/{s}-0.1.0-{s}-{s}{s}", .{ app_exe_name, @tagName(package_target), optimize_name, packageSuffix(package_target) }), + "--binary", + }); + package.addFileArg(exe.getEmittedBin()); + package.addArgs(&.{ "--web-engine", @tagName(web_engine), "--cef-dir", cef_dir }); + if (cef_auto_install) package.addArg("--cef-auto-install"); + package.step.dependOn(&exe.step); + package.step.dependOn(&frontend_build.step); + const package_step = b.step("package", "Create a local package artifact"); + package_step.dependOn(&package.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 zeroNativeTarget(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 zeroNativePath(b: *std.Build, zero_native_path: []const u8, sub_path: []const u8) std.Build.LazyPath { + return .{ .cwd_relative = b.pathJoin(&.{ zero_native_path, sub_path }) }; +} + +fn zeroNativeModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, zero_native_path: []const u8) *std.Build.Module { + const geometry_mod = externalModule(b, target, optimize, zero_native_path, "src/primitives/geometry/root.zig"); + const assets_mod = externalModule(b, target, optimize, zero_native_path, "src/primitives/assets/root.zig"); + const app_dirs_mod = externalModule(b, target, optimize, zero_native_path, "src/primitives/app_dirs/root.zig"); + const trace_mod = externalModule(b, target, optimize, zero_native_path, "src/primitives/trace/root.zig"); + const app_manifest_mod = externalModule(b, target, optimize, zero_native_path, "src/primitives/app_manifest/root.zig"); + const diagnostics_mod = externalModule(b, target, optimize, zero_native_path, "src/primitives/diagnostics/root.zig"); + const platform_info_mod = externalModule(b, target, optimize, zero_native_path, "src/primitives/platform_info/root.zig"); + const json_mod = externalModule(b, target, optimize, zero_native_path, "src/primitives/json/root.zig"); + const debug_mod = externalModule(b, target, optimize, zero_native_path, "src/debug/root.zig"); + debug_mod.addImport("app_dirs", app_dirs_mod); + debug_mod.addImport("trace", trace_mod); + + const zero_native_mod = externalModule(b, target, optimize, zero_native_path, "src/root.zig"); + zero_native_mod.addImport("geometry", geometry_mod); + zero_native_mod.addImport("assets", assets_mod); + zero_native_mod.addImport("app_dirs", app_dirs_mod); + zero_native_mod.addImport("trace", trace_mod); + zero_native_mod.addImport("app_manifest", app_manifest_mod); + zero_native_mod.addImport("diagnostics", diagnostics_mod); + zero_native_mod.addImport("platform_info", platform_info_mod); + zero_native_mod.addImport("json", json_mod); + return zero_native_mod; +} + +fn externalModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, zero_native_path: []const u8, path: []const u8) *std.Build.Module { + return b.createModule(.{ + .root_source_file = zeroNativePath(b, zero_native_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, zero_native_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", "-ObjC", "-mmacosx-version-min=11.0", "-isysroot", sysroot, sdk_include } else &.{ "-fobjc-arc", "-ObjC", "-mmacosx-version-min=11.0" }; + app_mod.addCSourceFile(.{ .file = zeroNativePath(b, zero_native_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(&.{ "zero-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("-DZERO_NATIVE_CEF_DIR=\"{s}\"", .{cef_dir}); + 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", "-ObjC++", "-std=c++17", "-stdlib=libc++", "-mmacosx-version-min=11.0", "-isysroot", sysroot, sdk_include, include_arg, define_arg } else &.{ "-fobjc-arc", "-ObjC++", "-std=c++17", "-stdlib=libc++", "-mmacosx-version-min=11.0", include_arg, define_arg }; + app_mod.addCSourceFile(.{ .file = zeroNativePath(b, zero_native_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", .{}); + app_mod.linkFramework("Foundation", .{}); + app_mod.linkFramework("UniformTypeIdentifiers", .{}); + app_mod.linkSystemLibrary("c", .{}); + if (web_engine == .chromium) app_mod.linkSystemLibrary("c++", .{}); + } else if (platform == .linux) { + switch (web_engine) { + .system => { + app_mod.addCSourceFile(.{ .file = zeroNativePath(b, zero_native_path, "src/platform/linux/gtk_host.c"), .flags = &.{} }); + app_mod.linkSystemLibrary("gtk4", .{}); + app_mod.linkSystemLibrary("webkitgtk-6.0", .{}); + }, + .chromium => { + const cef_check = addCefCheck(b, target, cef_dir); + if (cef_auto_install) { + const cef_auto = b.addSystemCommand(&.{ "zero-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("-DZERO_NATIVE_CEF_DIR=\"{s}\"", .{cef_dir}); + app_mod.addCSourceFile(.{ .file = zeroNativePath(b, zero_native_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 => app_mod.addCSourceFile(.{ .file = zeroNativePath(b, zero_native_path, "src/platform/windows/webview2_host.cpp"), .flags = &.{"-std=c++17"} }), + .chromium => { + const cef_check = addCefCheck(b, target, cef_dir); + if (cef_auto_install) { + const cef_auto = b.addSystemCommand(&.{ "zero-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("-DZERO_NATIVE_CEF_DIR=\"{s}\"", .{cef_dir}); + app_mod.addCSourceFile(.{ .file = zeroNativePath(b, zero_native_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("ole32", .{}); + app_mod.linkSystemLibrary("shell32", .{}); + if (web_engine == .chromium) app_mod.linkSystemLibrary("libcef", .{}); + } +} + +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) return; + if (target.result.os.tag != .macos) return; + const copy = b.addSystemCommand(&.{ + "sh", "-c", + b.fmt( + \\set -e + \\exe="$0" + \\exe_dir="$(dirname "$exe")" + \\rm -rf "zig-out/Frameworks/Chromium Embedded Framework.framework" "zig-out/bin/Frameworks/Chromium Embedded Framework.framework" ".zig-cache/o/Frameworks/Chromium Embedded Framework.framework" && + \\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/" && + \\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/libEGL.dylib" "$exe_dir/" && + \\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/libGLESv2.dylib" "$exe_dir/" && + \\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/libvk_swiftshader.dylib" "$exe_dir/" && + \\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/vk_swiftshader_icd.json" "$exe_dir/" + , .{ cef_dir, cef_dir, cef_dir, cef_dir, 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 "Expected:" >&2 + \\ echo " {s}/include/cef_app.h" >&2 + \\ echo " {s}/Release/Chromium Embedded Framework.framework" >&2 + \\ echo " {s}/libcef_dll_wrapper/libcef_dll_wrapper.a" >&2 + \\ echo "Fix with: zero-native cef install --dir {s}" >&2 + \\ echo "Or rerun with: -Dcef-auto-install=true" >&2 + \\ echo "Pass -Dcef-dir=/path/to/cef if your bundle lives elsewhere." >&2 + \\ exit 1 + \\}} + , .{ cef_dir, cef_dir, cef_dir, 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: zero-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: zero-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 packageSuffix(target: PackageTarget) []const u8 { + return switch (target) { + .macos => ".app", + .windows, .linux => "", + }; +} + +const AppWebEngineConfig = struct { + web_engine: WebEngineOption = .system, + cef_dir: []const u8 = "third_party/cef/macos", + cef_auto_install: bool = false, +}; + +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, + }; +} + +fn appWebEngineConfig() AppWebEngineConfig { + const source = @embedFile("app.zon"); + var config: AppWebEngineConfig = .{}; + if (stringField(source, ".web_engine")) |value| { + config.web_engine = parseWebEngine(value) orelse .system; + } + if (objectSection(source, ".cef")) |cef| { + if (stringField(cef, ".dir")) |value| config.cef_dir = value; + if (boolField(cef, ".auto_install")) |value| config.cef_auto_install = value; + } + return config; +} + +fn parseWebEngine(value: []const u8) ?WebEngineOption { + if (std.mem.eql(u8, value, "system")) return .system; + if (std.mem.eql(u8, value, "chromium")) return .chromium; + return null; +} + +fn stringField(source: []const u8, field: []const u8) ?[]const u8 { + const field_index = std.mem.indexOf(u8, source, field) orelse return null; + const equals = std.mem.indexOfScalarPos(u8, source, field_index, '=') orelse return null; + const start_quote = std.mem.indexOfScalarPos(u8, source, equals, '"') orelse return null; + const end_quote = std.mem.indexOfScalarPos(u8, source, start_quote + 1, '"') orelse return null; + return source[start_quote + 1 .. end_quote]; +} + +fn objectSection(source: []const u8, field: []const u8) ?[]const u8 { + const field_index = std.mem.indexOf(u8, source, field) orelse return null; + const open = std.mem.indexOfScalarPos(u8, source, field_index, '{') orelse return null; + var depth: usize = 0; + var index = open; + while (index < source.len) : (index += 1) { + switch (source[index]) { + '{' => depth += 1, + '}' => { + depth -= 1; + if (depth == 0) return source[open + 1 .. index]; + }, + else => {}, + } + } + return null; +} + +fn boolField(source: []const u8, field: []const u8) ?bool { + const field_index = std.mem.indexOf(u8, source, field) orelse return null; + const equals = std.mem.indexOfScalarPos(u8, source, field_index, '=') orelse return null; + var index = equals + 1; + while (index < source.len and std.ascii.isWhitespace(source[index])) : (index += 1) {} + if (std.mem.startsWith(u8, source[index..], "true")) return true; + if (std.mem.startsWith(u8, source[index..], "false")) return false; + return null; +} diff --git a/examples/angular/build.zig.zon b/examples/angular/build.zig.zon new file mode 100644 index 00000000..e5155d08 --- /dev/null +++ b/examples/angular/build.zig.zon @@ -0,0 +1,8 @@ +.{ + .name = .angular, + .fingerprint = 0x768075445a707070, + .version = "0.1.0", + .minimum_zig_version = "0.16.0", + .dependencies = .{}, + .paths = .{ "build.zig", "build.zig.zon", "src", "assets", "frontend", "app.zon", "README.md" }, +} diff --git a/examples/angular/frontend/angular.json b/examples/angular/frontend/angular.json new file mode 100644 index 00000000..7fe4b1f5 --- /dev/null +++ b/examples/angular/frontend/angular.json @@ -0,0 +1,48 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "angular_example": { + "projectType": "application", + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "outputPath": { "base": "dist", "browser": "" }, + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "styles": ["src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" }, + { "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "options": { "host": "127.0.0.1" }, + "configurations": { + "production": { "buildTarget": "angular_example:build:production" }, + "development": { "buildTarget": "angular_example:build:development" } + }, + "defaultConfiguration": "development" + } + } + } + } +} diff --git a/examples/angular/frontend/package.json b/examples/angular/frontend/package.json new file mode 100644 index 00000000..9d04ac8b --- /dev/null +++ b/examples/angular/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "angular-example", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "ng serve", + "build": "ng build" + }, + "dependencies": { + "@angular/common": "^20.3.0", + "@angular/compiler": "^20.3.0", + "@angular/core": "^20.3.0", + "@angular/forms": "^20.3.0", + "@angular/platform-browser": "^20.3.0", + "@angular/router": "^20.3.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^20.3.26", + "@angular/cli": "^20.3.26", + "@angular/compiler-cli": "^20.3.0", + "typescript": "~5.9.2" + } +} diff --git a/examples/angular/frontend/src/app/app.config.ts b/examples/angular/frontend/src/app/app.config.ts new file mode 100644 index 00000000..69a1a93b --- /dev/null +++ b/examples/angular/frontend/src/app/app.config.ts @@ -0,0 +1,16 @@ +import { + ApplicationConfig, + provideBrowserGlobalErrorListeners, + provideZonelessChangeDetection, +} from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideZonelessChangeDetection(), + provideRouter(routes), + ], +}; diff --git a/examples/angular/frontend/src/app/app.routes.ts b/examples/angular/frontend/src/app/app.routes.ts new file mode 100644 index 00000000..7e9e7798 --- /dev/null +++ b/examples/angular/frontend/src/app/app.routes.ts @@ -0,0 +1,7 @@ +import { Routes } from '@angular/router'; + +import { Home } from './home/home'; + +export const routes: Routes = [ + { path: '', pathMatch: 'full', component: Home }, +]; diff --git a/examples/angular/frontend/src/app/app.ts b/examples/angular/frontend/src/app/app.ts new file mode 100644 index 00000000..d0321f20 --- /dev/null +++ b/examples/angular/frontend/src/app/app.ts @@ -0,0 +1,10 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterOutlet], + template: '', +}) +export class App {} diff --git a/examples/angular/frontend/src/app/home/home.css b/examples/angular/frontend/src/app/home/home.css new file mode 100644 index 00000000..e69de29b diff --git a/examples/angular/frontend/src/app/home/home.html b/examples/angular/frontend/src/app/home/home.html new file mode 100644 index 00000000..5144a3b0 --- /dev/null +++ b/examples/angular/frontend/src/app/home/home.html @@ -0,0 +1,9 @@ +
+

zero-native + Angular

+

Angular Example

+

An Angular frontend running inside the system WebView.

+
+ Native bridge + {{ bridge() }} +
+
diff --git a/examples/angular/frontend/src/app/home/home.ts b/examples/angular/frontend/src/app/home/home.ts new file mode 100644 index 00000000..86dfc5c7 --- /dev/null +++ b/examples/angular/frontend/src/app/home/home.ts @@ -0,0 +1,11 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; + +@Component({ + selector: 'app-home', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './home.html', + styleUrl: './home.css', +}) +export class Home { + protected readonly bridge = signal('zero' in globalThis ? 'available' : 'not enabled'); +} diff --git a/examples/angular/frontend/src/index.html b/examples/angular/frontend/src/index.html new file mode 100644 index 00000000..7f3e66ff --- /dev/null +++ b/examples/angular/frontend/src/index.html @@ -0,0 +1,12 @@ + + + + + Angular Example + + + + + + + diff --git a/examples/angular/frontend/src/main.ts b/examples/angular/frontend/src/main.ts new file mode 100644 index 00000000..190f3418 --- /dev/null +++ b/examples/angular/frontend/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/examples/angular/frontend/src/styles.css b/examples/angular/frontend/src/styles.css new file mode 100644 index 00000000..0f107fc5 --- /dev/null +++ b/examples/angular/frontend/src/styles.css @@ -0,0 +1,52 @@ +:root { + color: #0f172a; + background: #f8fafc; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + display: grid; + place-items: center; +} + +main { + width: min(560px, calc(100vw - 48px)); + padding: 32px; + border-radius: 24px; + background: white; + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.14); +} + +h1 { + margin: 0 0 12px; + font-size: clamp(2rem, 8vw, 4rem); + line-height: 1; +} + +.eyebrow { + margin: 0 0 12px; + color: #2563eb; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.lede { + margin: 0 0 24px; + color: #475569; + line-height: 1.6; +} + +.card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px; + border: 1px solid #e2e8f0; + border-radius: 16px; + background: #f8fafc; +} diff --git a/examples/angular/frontend/tsconfig.app.json b/examples/angular/frontend/tsconfig.app.json new file mode 100644 index 00000000..8426ad95 --- /dev/null +++ b/examples/angular/frontend/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/examples/angular/frontend/tsconfig.json b/examples/angular/frontend/tsconfig.json new file mode 100644 index 00000000..b1b0d297 --- /dev/null +++ b/examples/angular/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "references": [{ "path": "./tsconfig.app.json" }] +} diff --git a/examples/angular/src/main.zig b/examples/angular/src/main.zig new file mode 100644 index 00000000..8482f70b --- /dev/null +++ b/examples/angular/src/main.zig @@ -0,0 +1,47 @@ +const std = @import("std"); +const runner = @import("runner"); +const zero_native = @import("zero-native"); + +pub const panic = std.debug.FullPanic(zero_native.debug.capturePanic); + +const App = struct { + env_map: *std.process.Environ.Map, + + fn app(self: *@This()) zero_native.App { + return .{ + .context = self, + .name = "angular-example", + .source = zero_native.frontend.productionSource(.{ .dist = "frontend/dist" }), + .source_fn = source, + }; + } + + fn source(context: *anyopaque) anyerror!zero_native.WebViewSource { + const self: *@This() = @ptrCast(@alignCast(context)); + return zero_native.frontend.sourceFromEnv(self.env_map, .{ + .dist = "frontend/dist", + .entry = "index.html", + }); + } +}; + +const dev_origins = [_][]const u8{ "zero://app", "zero://inline", "http://127.0.0.1:4200" }; + +pub fn main(init: std.process.Init) !void { + var app = App{ .env_map = init.environ_map }; + try runner.runWithOptions(app.app(), .{ + .app_name = "Angular Example", + .window_title = "Angular Example", + .bundle_id = "dev.zero_native.angular-example", + .icon_path = "assets/icon.icns", + .security = .{ + .navigation = .{ .allowed_origins = &dev_origins }, + }, + }, init); +} + +test "production source points at Angular build output" { + const source = zero_native.frontend.productionSource(.{ .dist = "frontend/dist" }); + try std.testing.expectEqual(zero_native.WebViewSourceKind.assets, source.kind); + try std.testing.expectEqualStrings("frontend/dist", source.asset_options.?.root_path); +} diff --git a/examples/angular/src/runner.zig b/examples/angular/src/runner.zig new file mode 100644 index 00000000..c2f9f033 --- /dev/null +++ b/examples/angular/src/runner.zig @@ -0,0 +1,217 @@ +const std = @import("std"); +const build_options = @import("build_options"); +const zero_native = @import("zero-native"); + +pub const StdoutTraceSink = struct { + pub fn sink(self: *StdoutTraceSink) zero_native.trace.Sink { + return .{ .context = self, .write_fn = write }; + } + + fn write(context: *anyopaque, record: zero_native.trace.Record) zero_native.trace.WriteError!void { + _ = context; + if (!shouldTrace(record)) return; + var buffer: [1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + zero_native.trace.formatText(record, &writer) catch return error.OutOfSpace; + std.debug.print("{s}\n", .{writer.buffered()}); + } +}; + +pub const RunOptions = struct { + app_name: []const u8, + window_title: []const u8 = "", + bundle_id: []const u8, + icon_path: []const u8 = "assets/icon.icns", + bridge: ?zero_native.BridgeDispatcher = null, + builtin_bridge: zero_native.BridgePolicy = .{}, + security: zero_native.SecurityPolicy = .{}, + + fn appInfo(self: RunOptions) zero_native.AppInfo { + return .{ + .app_name = self.app_name, + .window_title = self.window_title, + .bundle_id = self.bundle_id, + .icon_path = self.icon_path, + }; + } +}; + +pub fn runWithOptions(app: zero_native.App, options: RunOptions, init: std.process.Init) !void { + if (build_options.debug_overlay) { + std.debug.print("debug-overlay=true backend={s} web-engine={s} trace={s}\n", .{ build_options.platform, build_options.web_engine, build_options.trace }); + } + 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: zero_native.App, options: RunOptions, init: std.process.Init) !void { + var buffers: StateBuffers = undefined; + var app_info = options.appInfo(); + const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers); + var null_platform = zero_native.NullPlatform.initWithOptions(.{}, webEngine(), app_info); + var trace_sink = StdoutTraceSink{}; + var log_buffers: zero_native.debug.LogPathBuffers = .{}; + const log_setup = zero_native.debug.setupLogging(init.io, init.environ_map, app_info.bundle_id, &log_buffers) catch null; + if (log_setup) |setup| zero_native.debug.installPanicCapture(init.io, setup.paths); + var file_trace_sink: zero_native.debug.FileTraceSink = undefined; + var fanout_sinks: [2]zero_native.trace.Sink = undefined; + var fanout_sink: zero_native.debug.FanoutTraceSink = undefined; + var runtime_trace_sink = trace_sink.sink(); + if (log_setup) |setup| { + file_trace_sink = zero_native.debug.FileTraceSink.init(init.io, setup.paths.log_dir, setup.paths.log_file, setup.format); + fanout_sinks = .{ trace_sink.sink(), file_trace_sink.sink() }; + fanout_sink = .{ .sinks = &fanout_sinks }; + runtime_trace_sink = fanout_sink.sink(); + } + var runtime = zero_native.Runtime.init(.{ + .platform = null_platform.platform(), + .trace_sink = runtime_trace_sink, + .log_path = if (log_setup) |setup| setup.paths.log_file else null, + .bridge = options.bridge, + .builtin_bridge = options.builtin_bridge, + .security = options.security, + .automation = if (build_options.automation) zero_native.automation.Server.init(init.io, ".zig-cache/zero-native-automation", app_info.resolvedWindowTitle()) else null, + .window_state_store = store, + }); + + try runtime.run(app); +} + +fn runMacos(app: zero_native.App, options: RunOptions, init: std.process.Init) !void { + var buffers: StateBuffers = undefined; + var app_info = options.appInfo(); + const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers); + var mac_platform = try zero_native.platform.macos.MacPlatform.initWithOptions(zero_native.geometry.SizeF.init(720, 480), webEngine(), app_info); + defer mac_platform.deinit(); + var trace_sink = StdoutTraceSink{}; + var log_buffers: zero_native.debug.LogPathBuffers = .{}; + const log_setup = zero_native.debug.setupLogging(init.io, init.environ_map, app_info.bundle_id, &log_buffers) catch null; + if (log_setup) |setup| zero_native.debug.installPanicCapture(init.io, setup.paths); + var file_trace_sink: zero_native.debug.FileTraceSink = undefined; + var fanout_sinks: [2]zero_native.trace.Sink = undefined; + var fanout_sink: zero_native.debug.FanoutTraceSink = undefined; + var runtime_trace_sink = trace_sink.sink(); + if (log_setup) |setup| { + file_trace_sink = zero_native.debug.FileTraceSink.init(init.io, setup.paths.log_dir, setup.paths.log_file, setup.format); + fanout_sinks = .{ trace_sink.sink(), file_trace_sink.sink() }; + fanout_sink = .{ .sinks = &fanout_sinks }; + runtime_trace_sink = fanout_sink.sink(); + } + var runtime = zero_native.Runtime.init(.{ + .platform = mac_platform.platform(), + .trace_sink = runtime_trace_sink, + .log_path = if (log_setup) |setup| setup.paths.log_file else null, + .bridge = options.bridge, + .builtin_bridge = options.builtin_bridge, + .security = options.security, + .automation = if (build_options.automation) zero_native.automation.Server.init(init.io, ".zig-cache/zero-native-automation", app_info.resolvedWindowTitle()) else null, + .window_state_store = store, + }); + + try runtime.run(app); +} + +fn runLinux(app: zero_native.App, options: RunOptions, init: std.process.Init) !void { + var buffers: StateBuffers = undefined; + var app_info = options.appInfo(); + const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers); + var linux_platform = try zero_native.platform.linux.LinuxPlatform.initWithOptions(zero_native.geometry.SizeF.init(720, 480), webEngine(), app_info); + defer linux_platform.deinit(); + var trace_sink = StdoutTraceSink{}; + var log_buffers: zero_native.debug.LogPathBuffers = .{}; + const log_setup = zero_native.debug.setupLogging(init.io, init.environ_map, app_info.bundle_id, &log_buffers) catch null; + if (log_setup) |setup| zero_native.debug.installPanicCapture(init.io, setup.paths); + var file_trace_sink: zero_native.debug.FileTraceSink = undefined; + var fanout_sinks: [2]zero_native.trace.Sink = undefined; + var fanout_sink: zero_native.debug.FanoutTraceSink = undefined; + var runtime_trace_sink = trace_sink.sink(); + if (log_setup) |setup| { + file_trace_sink = zero_native.debug.FileTraceSink.init(init.io, setup.paths.log_dir, setup.paths.log_file, setup.format); + fanout_sinks = .{ trace_sink.sink(), file_trace_sink.sink() }; + fanout_sink = .{ .sinks = &fanout_sinks }; + runtime_trace_sink = fanout_sink.sink(); + } + var runtime = zero_native.Runtime.init(.{ + .platform = linux_platform.platform(), + .trace_sink = runtime_trace_sink, + .log_path = if (log_setup) |setup| setup.paths.log_file else null, + .bridge = options.bridge, + .builtin_bridge = options.builtin_bridge, + .security = options.security, + .automation = if (build_options.automation) zero_native.automation.Server.init(init.io, ".zig-cache/zero-native-automation", app_info.resolvedWindowTitle()) else null, + .window_state_store = store, + }); + + try runtime.run(app); +} + +fn runWindows(app: zero_native.App, options: RunOptions, init: std.process.Init) !void { + var buffers: StateBuffers = undefined; + var app_info = options.appInfo(); + const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers); + var windows_platform = try zero_native.platform.windows.WindowsPlatform.initWithOptions(zero_native.geometry.SizeF.init(720, 480), webEngine(), app_info); + defer windows_platform.deinit(); + var trace_sink = StdoutTraceSink{}; + var log_buffers: zero_native.debug.LogPathBuffers = .{}; + const log_setup = zero_native.debug.setupLogging(init.io, init.environ_map, app_info.bundle_id, &log_buffers) catch null; + if (log_setup) |setup| zero_native.debug.installPanicCapture(init.io, setup.paths); + var file_trace_sink: zero_native.debug.FileTraceSink = undefined; + var fanout_sinks: [2]zero_native.trace.Sink = undefined; + var fanout_sink: zero_native.debug.FanoutTraceSink = undefined; + var runtime_trace_sink = trace_sink.sink(); + if (log_setup) |setup| { + file_trace_sink = zero_native.debug.FileTraceSink.init(init.io, setup.paths.log_dir, setup.paths.log_file, setup.format); + fanout_sinks = .{ trace_sink.sink(), file_trace_sink.sink() }; + fanout_sink = .{ .sinks = &fanout_sinks }; + runtime_trace_sink = fanout_sink.sink(); + } + var runtime = zero_native.Runtime.init(.{ + .platform = windows_platform.platform(), + .trace_sink = runtime_trace_sink, + .log_path = if (log_setup) |setup| setup.paths.log_file else null, + .bridge = options.bridge, + .builtin_bridge = options.builtin_bridge, + .security = options.security, + .automation = if (build_options.automation) zero_native.automation.Server.init(init.io, ".zig-cache/zero-native-automation", app_info.resolvedWindowTitle()) else null, + .window_state_store = store, + }); + + try runtime.run(app); +} + +fn shouldTrace(record: zero_native.trace.Record) bool { + if (comptime std.mem.eql(u8, build_options.trace, "off")) return false; + if (comptime std.mem.eql(u8, build_options.trace, "all")) return true; + if (comptime std.mem.eql(u8, build_options.trace, "events")) return true; + return std.mem.indexOf(u8, record.name, build_options.trace) != null; +} + +fn webEngine() zero_native.WebEngine { + if (comptime std.mem.eql(u8, build_options.web_engine, "chromium")) return .chromium; + return .system; +} + +const StateBuffers = struct { + state_dir: [1024]u8 = undefined, + file_path: [1200]u8 = undefined, + read: [8192]u8 = undefined, + restored_windows: [zero_native.platform.max_windows]zero_native.WindowOptions = undefined, +}; + +fn prepareStateStore(io: std.Io, env_map: *std.process.Environ.Map, app_info: *zero_native.AppInfo, buffers: *StateBuffers) ?zero_native.window_state.Store { + const paths = zero_native.window_state.defaultPaths(&buffers.state_dir, &buffers.file_path, app_info.bundle_id, zero_native.debug.envFromMap(env_map)) catch return null; + const store = zero_native.window_state.Store.init(io, paths.state_dir, paths.file_path); + if (app_info.main_window.restore_state) { + if (store.loadWindow(app_info.main_window.label, &buffers.read) catch null) |saved| { + app_info.main_window.default_frame = saved.frame; + } + } + return store; +} diff --git a/packages/zero-native/README.md b/packages/zero-native/README.md index ac551bac..cfcf853c 100644 --- a/packages/zero-native/README.md +++ b/packages/zero-native/README.md @@ -22,7 +22,7 @@ The first run installs the generated frontend dependencies automatically. | Command | Description | |---------|-------------| -| `zero-native init [name] --frontend ` | Scaffold a new zero-native project | +| `zero-native init [name] --frontend ` | Scaffold a new zero-native project | | `zero-native dev --binary ` | Start the app with a managed frontend dev server | | `zero-native doctor` | Check host environment, WebView, manifest, and CEF | | `zero-native validate` | Validate `app.zon` against the manifest schema | diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index c7cf021e..0c53c10c 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -25,7 +25,7 @@ These references are included by `zero-native skills get core --full`. Use them - Project creation, generated files, build steps: `references/project-anatomy.md` - `App`, `Runtime`, callbacks, embedding, tests: `references/app-model-runtime.md` -- React/Vue/Svelte/Next/Vite, dev server, bundled assets: `references/frontend-assets.md` +- React/Vue/Svelte/Angular/Next/Vite, dev server, bundled assets: `references/frontend-assets.md` - App-defined bridge commands, builtin commands, permissions, windows, WebViews, dialogs: `references/bridge-security-native-capabilities.md` - Web engine choice, CEF, packaging, signing, doctor, logs, debugging: `references/web-engines-packaging-debugging.md` - Running-app inspection and smoke tests: `zero-native skills get automation` @@ -41,7 +41,7 @@ cd my_app zig build run ``` -Frontend choices are `next`, `vite`, `react`, `svelte`, and `vue`. The first `zig build run` installs frontend dependencies, builds the native shell, and opens a desktop window. +Frontend choices are `next`, `vite`, `react`, `svelte`, `vue`, and `angular`. The first `zig build run` installs frontend dependencies, builds the native shell, and opens a desktop window. ## Workflow for existing apps @@ -141,7 +141,7 @@ Use exact local origins for dev servers. Add `zero://inline` only for inline HTM ### Add a new framework app -Use `zero-native init --frontend `. Then inspect the generated `app.zon`, `src/main.zig`, and `build.zig` before customizing. For framework behavior, keep frontend work in `frontend/` and use `sourceFromEnv` so development and packaged builds share one app shell. +Use `zero-native init --frontend `. Then inspect the generated `app.zon`, `src/main.zig`, and `build.zig` before customizing. For framework behavior, keep frontend work in `frontend/` and use `sourceFromEnv` so development and packaged builds share one app shell. ### Add a native bridge command @@ -186,7 +186,7 @@ Or run the CLI directly after building the binary: zero-native dev --manifest app.zon --binary zig-out/bin/MyApp ``` -Vite usually uses `http://127.0.0.1:5173/`; Next.js usually uses `http://127.0.0.1:3000/`. The app WebView loads the dev URL directly, so framework HMR remains owned by Vite, Next.js, or the selected dev server. +Vite usually uses `http://127.0.0.1:5173/`; Angular usually uses `http://127.0.0.1:4200/`; Next.js usually uses `http://127.0.0.1:3000/`. The app WebView loads the dev URL directly, so framework HMR remains owned by Vite, Next.js, Angular, or the selected dev server. ## Security defaults @@ -232,6 +232,7 @@ When changing app behavior, add focused Zig tests when the code can run headless - `examples/browser`: layered WebView/browser-style example. - `examples/next`: Next.js with production assets. - `examples/react`, `examples/svelte`, `examples/vue`: Vite frontend apps. +- `examples/angular`: Angular 20 SPA. - `examples/ios`, `examples/android`: mobile host embedding examples. ## When answering users diff --git a/skill-data/core/references/frontend-assets.md b/skill-data/core/references/frontend-assets.md index 5d7bd370..d408caab 100644 --- a/skill-data/core/references/frontend-assets.md +++ b/skill-data/core/references/frontend-assets.md @@ -120,5 +120,6 @@ For builtin windows/WebViews helpers, enable `js_window_api` and policy in the r - `examples/react`: React with Vite. - `examples/svelte`: Svelte with Vite. - `examples/vue`: Vue with Vite. +- `examples/angular`: Angular 20 SPA with `ng serve`/`ng build` and flat assets under `frontend/dist`. When unsure about frontend build commands or output directories, inspect the matching example before editing. diff --git a/skill-data/core/references/project-anatomy.md b/skill-data/core/references/project-anatomy.md index 9a8c3867..4ac66066 100644 --- a/skill-data/core/references/project-anatomy.md +++ b/skill-data/core/references/project-anatomy.md @@ -10,7 +10,7 @@ Use this when creating, orienting in, or restructuring a zero-native app. - `src/main.zig`: app state, `app()` method, source resolver, optional bridge dispatcher, lifecycle callbacks. - `src/runner.zig`: platform and runtime setup: native backend, trace sinks, panic capture, log paths, state store, security policy, builtin bridge policy, automation. - `assets/`: icons and other package resources. -- `frontend/`: framework app when using Next, Vite, React, Svelte, or Vue. +- `frontend/`: framework app when using Next, Vite, React, Svelte, Vue, or Angular. ## app.zon responsibilities diff --git a/src/tooling/templates.zig b/src/tooling/templates.zig index 5230ccaf..f441dd2c 100644 --- a/src/tooling/templates.zig +++ b/src/tooling/templates.zig @@ -8,6 +8,7 @@ pub const Frontend = enum { react, svelte, vue, + angular, pub fn parse(value: []const u8) ?Frontend { if (std.mem.eql(u8, value, "next")) return .next; @@ -15,13 +16,14 @@ pub const Frontend = enum { if (std.mem.eql(u8, value, "react")) return .react; if (std.mem.eql(u8, value, "svelte")) return .svelte; if (std.mem.eql(u8, value, "vue")) return .vue; + if (std.mem.eql(u8, value, "angular")) return .angular; return null; } pub fn distDir(self: Frontend) []const u8 { return switch (self) { .next => "frontend/out", - .vite, .react, .svelte, .vue => "frontend/dist", + .vite, .react, .svelte, .vue, .angular => "frontend/dist", }; } @@ -29,6 +31,7 @@ pub const Frontend = enum { return switch (self) { .next => "3000", .vite, .react, .svelte, .vue => "5173", + .angular => "4200", }; } @@ -36,6 +39,7 @@ pub const Frontend = enum { return switch (self) { .next => "http://127.0.0.1:3000/", .vite, .react, .svelte, .vue => "http://127.0.0.1:5173/", + .angular => "http://127.0.0.1:4200/", }; } }; @@ -740,7 +744,7 @@ fn runnerZig() []const u8 { \\ bridge: ?zero_native.BridgeDispatcher = null, \\ builtin_bridge: zero_native.BridgePolicy = .{}, \\ security: zero_native.SecurityPolicy = .{}, - \\ js_window_api: bool = false, + \\ js_window_api: bool = false, \\ \\ fn appInfo(self: RunOptions) zero_native.AppInfo { \\ return .{ @@ -793,7 +797,7 @@ fn runnerZig() []const u8 { \\ .bridge = options.bridge, \\ .builtin_bridge = options.builtin_bridge, \\ .security = options.security, - \\ .js_window_api = options.js_window_api, + \\ .js_window_api = options.js_window_api, \\ .automation = if (build_options.automation) zero_native.automation.Server.init(init.io, ".zig-cache/zero-native-automation", app_info.resolvedWindowTitle()) else null, \\ .window_state_store = store, \\ }); @@ -828,7 +832,7 @@ fn runnerZig() []const u8 { \\ .bridge = options.bridge, \\ .builtin_bridge = options.builtin_bridge, \\ .security = options.security, - \\ .js_window_api = options.js_window_api, + \\ .js_window_api = options.js_window_api, \\ .automation = if (build_options.automation) zero_native.automation.Server.init(init.io, ".zig-cache/zero-native-automation", app_info.resolvedWindowTitle()) else null, \\ .window_state_store = store, \\ }); @@ -863,7 +867,7 @@ fn runnerZig() []const u8 { \\ .bridge = options.bridge, \\ .builtin_bridge = options.builtin_bridge, \\ .security = options.security, - \\ .js_window_api = options.js_window_api, + \\ .js_window_api = options.js_window_api, \\ .automation = if (build_options.automation) zero_native.automation.Server.init(init.io, ".zig-cache/zero-native-automation", app_info.resolvedWindowTitle()) else null, \\ .window_state_store = store, \\ }); @@ -898,7 +902,7 @@ fn runnerZig() []const u8 { \\ .bridge = options.bridge, \\ .builtin_bridge = options.builtin_bridge, \\ .security = options.security, - \\ .js_window_api = options.js_window_api, + \\ .js_window_api = options.js_window_api, \\ .automation = if (build_options.automation) zero_native.automation.Server.init(init.io, ".zig-cache/zero-native-automation", app_info.resolvedWindowTitle()) else null, \\ .window_state_store = store, \\ }); @@ -1026,9 +1030,319 @@ fn writeFrontendFiles(allocator: std.mem.Allocator, io: std.Io, app_dir: std.Io. .react => try writeReactFrontend(allocator, io, app_dir, names), .svelte => try writeSvelteFrontend(allocator, io, app_dir, names), .vue => try writeVueFrontend(allocator, io, app_dir, names), + .angular => try writeAngularFrontend(allocator, io, app_dir, names), } } +fn angularProjectKey(allocator: std.mem.Allocator, package_name: []const u8) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(allocator); + if (package_name.len > 0 and !isAsciiAlpha(package_name[0])) { + try out.appendSlice(allocator, "app_"); + } + for (package_name) |ch| { + const normalized = if (isAsciiAlpha(ch) or isAsciiDigit(ch)) ch else '_'; + if (normalized == '_' and (out.items.len == 0 or out.items[out.items.len - 1] == '_')) continue; + try out.append(allocator, normalized); + } + while (out.items.len > 0 and out.items[out.items.len - 1] == '_') _ = out.pop(); + if (out.items.len == 0) try out.appendSlice(allocator, "app"); + return out.toOwnedSlice(allocator); +} + +fn writeAngularFrontend(allocator: std.mem.Allocator, io: std.Io, app_dir: std.Io.Dir, names: TemplateNames) !void { + try app_dir.createDirPath(io, "frontend/src/app/home"); + const project_key = try angularProjectKey(allocator, names.package_name); + defer allocator.free(project_key); + + const angular_json = try angularAngularJson(allocator, project_key); + defer allocator.free(angular_json); + + const package_json = try angularPackageJson(allocator, names); + defer allocator.free(package_json); + + const index_html = try angularIndexHtml(allocator, names.display_name); + defer allocator.free(index_html); + + const home_html = try angularHomeHtml(allocator, names.display_name); + defer allocator.free(home_html); + + try writeFile(app_dir, io, "frontend/angular.json", angular_json); + try writeFile(app_dir, io, "frontend/package.json", package_json); + try writeFile(app_dir, io, "frontend/tsconfig.json", angularTsConfigJson()); + try writeFile(app_dir, io, "frontend/tsconfig.app.json", angularTsConfigAppJson()); + try writeFile(app_dir, io, "frontend/src/main.ts", angularMainTs()); + try writeFile(app_dir, io, "frontend/src/index.html", index_html); + try writeFile(app_dir, io, "frontend/src/styles.css", frontendStylesCss()); + try writeFile(app_dir, io, "frontend/src/app/app.config.ts", angularAppConfigTs()); + try writeFile(app_dir, io, "frontend/src/app/app.routes.ts", angularAppRoutesTs()); + try writeFile(app_dir, io, "frontend/src/app/app.ts", angularAppTs()); + try writeFile(app_dir, io, "frontend/src/app/home/home.ts", angularHomeTs()); + try writeFile(app_dir, io, "frontend/src/app/home/home.html", home_html); + try writeFile(app_dir, io, "frontend/src/app/home/home.css", ""); +} + +fn angularAngularJson(allocator: std.mem.Allocator, project_key: []const u8) ![]const u8 { + return std.fmt.allocPrint(allocator, + \\{{ + \\ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + \\ "version": 1, + \\ "newProjectRoot": "projects", + \\ "projects": {{ + \\ "{s}": {{ + \\ "projectType": "application", + \\ "root": "", + \\ "sourceRoot": "src", + \\ "prefix": "app", + \\ "architect": {{ + \\ "build": {{ + \\ "builder": "@angular/build:application", + \\ "options": {{ + \\ "outputPath": {{ "base": "dist", "browser": "" }}, + \\ "browser": "src/main.ts", + \\ "tsConfig": "tsconfig.app.json", + \\ "styles": ["src/styles.css"] + \\ }}, + \\ "configurations": {{ + \\ "production": {{ + \\ "budgets": [ + \\ {{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" }}, + \\ {{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }} + \\ ], + \\ "outputHashing": "all" + \\ }}, + \\ "development": {{ + \\ "optimization": false, + \\ "extractLicenses": false, + \\ "sourceMap": true + \\ }} + \\ }}, + \\ "defaultConfiguration": "production" + \\ }}, + \\ "serve": {{ + \\ "builder": "@angular/build:dev-server", + \\ "options": {{ "host": "127.0.0.1" }}, + \\ "configurations": {{ + \\ "production": {{ "buildTarget": "{s}:build:production" }}, + \\ "development": {{ "buildTarget": "{s}:build:development" }} + \\ }}, + \\ "defaultConfiguration": "development" + \\ }} + \\ }} + \\ }} + \\ }} + \\}} + \\ + , .{ project_key, project_key, project_key }); +} + +fn angularPackageJson(allocator: std.mem.Allocator, names: TemplateNames) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(allocator); + try out.appendSlice(allocator, "{\n \"name\": "); + try appendJsonString(&out, allocator, names.package_name); + try out.appendSlice(allocator, + \\, + \\ "version": "0.1.0", + \\ "private": true, + \\ "scripts": { + \\ "dev": "ng serve", + \\ "build": "ng build" + \\ }, + \\ "dependencies": { + \\ "@angular/common": "^20.3.0", + \\ "@angular/compiler": "^20.3.0", + \\ "@angular/core": "^20.3.0", + \\ "@angular/forms": "^20.3.0", + \\ "@angular/platform-browser": "^20.3.0", + \\ "@angular/router": "^20.3.0", + \\ "rxjs": "~7.8.0", + \\ "tslib": "^2.3.0" + \\ }, + \\ "devDependencies": { + \\ "@angular/build": "^20.3.26", + \\ "@angular/cli": "^20.3.26", + \\ "@angular/compiler-cli": "^20.3.0", + \\ "typescript": "~5.9.2" + \\ } + \\} + \\ + ); + return out.toOwnedSlice(allocator); +} + +fn angularTsConfigJson() []const u8 { + return + \\{ + \\ "compileOnSave": false, + \\ "compilerOptions": { + \\ "strict": true, + \\ "noImplicitOverride": true, + \\ "noPropertyAccessFromIndexSignature": true, + \\ "noImplicitReturns": true, + \\ "noFallthroughCasesInSwitch": true, + \\ "skipLibCheck": true, + \\ "isolatedModules": true, + \\ "experimentalDecorators": true, + \\ "importHelpers": true, + \\ "target": "ES2022", + \\ "module": "preserve" + \\ }, + \\ "angularCompilerOptions": { + \\ "enableI18nLegacyMessageIdFormat": false, + \\ "strictInjectionParameters": true, + \\ "strictInputAccessModifiers": true, + \\ "typeCheckHostBindings": true, + \\ "strictTemplates": true + \\ }, + \\ "files": [], + \\ "references": [{ "path": "./tsconfig.app.json" }] + \\} + \\ + ; +} + +fn angularTsConfigAppJson() []const u8 { + return + \\{ + \\ "extends": "./tsconfig.json", + \\ "compilerOptions": { + \\ "outDir": "./out-tsc/app", + \\ "types": [] + \\ }, + \\ "include": ["src/**/*.ts"], + \\ "exclude": ["src/**/*.spec.ts"] + \\} + \\ + ; +} + +fn angularMainTs() []const u8 { + return + \\import { bootstrapApplication } from '@angular/platform-browser'; + \\import { appConfig } from './app/app.config'; + \\import { App } from './app/app'; + \\ + \\bootstrapApplication(App, appConfig).catch((err) => console.error(err)); + \\ + ; +} + +fn angularAppConfigTs() []const u8 { + return + \\import { + \\ ApplicationConfig, + \\ provideBrowserGlobalErrorListeners, + \\ provideZonelessChangeDetection, + \\} from '@angular/core'; + \\import { provideRouter } from '@angular/router'; + \\ + \\import { routes } from './app.routes'; + \\ + \\export const appConfig: ApplicationConfig = { + \\ providers: [ + \\ provideBrowserGlobalErrorListeners(), + \\ provideZonelessChangeDetection(), + \\ provideRouter(routes), + \\ ], + \\}; + \\ + ; +} + +fn angularAppRoutesTs() []const u8 { + return + \\import { Routes } from '@angular/router'; + \\ + \\import { Home } from './home/home'; + \\ + \\export const routes: Routes = [ + \\ { path: '', pathMatch: 'full', component: Home }, + \\]; + \\ + ; +} + +fn angularAppTs() []const u8 { + return + \\import { ChangeDetectionStrategy, Component } from '@angular/core'; + \\import { RouterOutlet } from '@angular/router'; + \\ + \\@Component({ + \\ selector: 'app-root', + \\ changeDetection: ChangeDetectionStrategy.OnPush, + \\ imports: [RouterOutlet], + \\ template: '', + \\}) + \\export class App {} + \\ + ; +} + +fn angularHomeTs() []const u8 { + return + \\import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; + \\ + \\@Component({ + \\ selector: 'app-home', + \\ changeDetection: ChangeDetectionStrategy.OnPush, + \\ templateUrl: './home.html', + \\ styleUrl: './home.css', + \\}) + \\export class Home { + \\ protected readonly bridge = signal('zero' in globalThis ? 'available' : 'not enabled'); + \\} + \\ + ; +} + +fn angularIndexHtml(allocator: std.mem.Allocator, display_name: []const u8) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(allocator); + try out.appendSlice(allocator, + \\ + \\ + \\ + \\ + \\ + ); + try appendXmlText(&out, allocator, display_name); + try out.appendSlice(allocator, + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + ); + return out.toOwnedSlice(allocator); +} + +fn angularHomeHtml(allocator: std.mem.Allocator, display_name: []const u8) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(allocator); + try out.appendSlice(allocator, + \\
+ \\

zero-native + Angular

+ \\

+ ); + try appendXmlText(&out, allocator, display_name); + try out.appendSlice(allocator, + \\

+ \\

An Angular frontend running inside the system WebView.

+ \\
+ \\ Native bridge + \\ {{ bridge() }} + \\
+ \\
+ \\ + ); + return out.toOwnedSlice(allocator); +} + fn writeNextFrontend(allocator: std.mem.Allocator, io: std.Io, app_dir: std.Io.Dir, names: TemplateNames) !void { try app_dir.createDirPath(io, "frontend/app"); const package_json = try nextPackageJson(allocator, names); @@ -1873,6 +2187,45 @@ test "writeDefaultApp emits frontend-specific Next paths" { try std.testing.expect(std.mem.indexOf(u8, tsconfig_text, "\"@/*\": [\"./app/*\"]") != null); } +test "writeDefaultApp emits frontend-specific Angular paths" { + const destination = ".zig-cache/test-angular-init-template"; + try writeDefaultApp(std.testing.allocator, std.testing.io, destination, .{ .app_name = "Angular App", .framework_path = ".", .frontend = .angular }); + + const app_zon_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "app.zon"); + defer std.testing.allocator.free(app_zon_text); + const angular_json_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "frontend/angular.json"); + defer std.testing.allocator.free(angular_json_text); + const main_zig_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "src/main.zig"); + defer std.testing.allocator.free(main_zig_text); + const package_json_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "frontend/package.json"); + defer std.testing.allocator.free(package_json_text); + const app_ts_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "frontend/src/app/app.ts"); + defer std.testing.allocator.free(app_ts_text); + const home_html_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "frontend/src/app/home/home.html"); + defer std.testing.allocator.free(home_html_text); + + try std.testing.expect(std.mem.indexOf(u8, app_zon_text, "frontend/dist") != null); + try std.testing.expect(std.mem.indexOf(u8, app_zon_text, "127.0.0.1:4200") != null); + try std.testing.expect(std.mem.indexOf(u8, main_zig_text, "frontend/dist") != null); + try std.testing.expect(std.mem.indexOf(u8, main_zig_text, "127.0.0.1:4200") != null); + try std.testing.expect(std.mem.indexOf(u8, angular_json_text, "\"base\": \"dist\"") != null); + try std.testing.expect(std.mem.indexOf(u8, angular_json_text, "\"browser\": \"\"") != null); + try std.testing.expect(std.mem.indexOf(u8, angular_json_text, "\"host\": \"127.0.0.1\"") != null); + try std.testing.expect(std.mem.indexOf(u8, angular_json_text, "zone.js") == null); + try std.testing.expect(std.mem.indexOf(u8, app_ts_text, "ChangeDetectionStrategy.OnPush") != null); + try std.testing.expect(std.mem.indexOf(u8, app_ts_text, "standalone: true") == null); + try std.testing.expect(std.mem.indexOf(u8, home_html_text, "role=\"status\" aria-live=\"polite\"") != null); + try std.testing.expect(std.mem.indexOf(u8, package_json_text, "\"@angular/core\"") != null); + try std.testing.expect(std.mem.indexOf(u8, package_json_text, "\"zone.js\"") == null); +} + +test "angularProjectKey emits Angular-safe target names" { + const key = try angularProjectKey(std.testing.allocator, "123 bad.name/@scope"); + defer std.testing.allocator.free(key); + + try std.testing.expectEqualStrings("app_123_bad_name_scope", key); +} + fn normalizePackageName(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(allocator); @@ -1982,6 +2335,19 @@ fn appendJsonString(out: *std.ArrayList(u8), allocator: std.mem.Allocator, value try appendEscapedString(out, allocator, value); } +fn appendXmlText(out: *std.ArrayList(u8), allocator: std.mem.Allocator, value: []const u8) !void { + for (value) |ch| { + switch (ch) { + '&' => try out.appendSlice(allocator, "&"), + '<' => try out.appendSlice(allocator, "<"), + '>' => try out.appendSlice(allocator, ">"), + '"' => try out.appendSlice(allocator, """), + '\'' => try out.appendSlice(allocator, "'"), + else => try out.append(allocator, ch), + } + } +} + fn appendEscapedString(out: *std.ArrayList(u8), allocator: std.mem.Allocator, value: []const u8) !void { try out.append(allocator, '"'); for (value) |ch| { diff --git a/tools/zero-native/main.zig b/tools/zero-native/main.zig index ef7c9462..ad3bba35 100644 --- a/tools/zero-native/main.zig +++ b/tools/zero-native/main.zig @@ -17,8 +17,8 @@ pub fn main(init: std.process.Init) !void { std.debug.print("zero-native {s}\n", .{version}); } else if (std.mem.eql(u8, command, "init")) { const destination = positionalArg(args[2..]) orelse "."; - const frontend_str = flagValue(args, "--frontend") catch fail("--frontend requires a value: next, vite, react, svelte, vue") orelse fail("--frontend is required: next, vite, react, svelte, vue"); - const frontend = tooling.templates.Frontend.parse(frontend_str) orelse fail("invalid --frontend value: use next, vite, react, svelte, or vue"); + const frontend_str = flagValue(args, "--frontend") catch fail("--frontend requires a value: next, vite, react, svelte, vue, angular") orelse fail("--frontend is required: next, vite, react, svelte, vue, angular"); + const frontend = tooling.templates.Frontend.parse(frontend_str) orelse fail("invalid --frontend value: use next, vite, react, svelte, vue, or angular"); const app_name, const free_app_name = try initAppName(allocator, init.io, destination); defer if (free_app_name) allocator.free(app_name); const framework_path, const free_framework_path = try initFrameworkPath(allocator, init.io); @@ -146,7 +146,7 @@ fn usage() void { \\usage: zero-native \\ \\commands: - \\ init [path] --frontend + \\ init [path] --frontend \\ cef install|path|doctor [--dir path] [--version version] [--source prepared|official] [--force] \\ doctor [--strict] [--manifest app.zon] [--web-engine system|chromium] [--cef-dir path] [--cef-auto-install] \\ validate [app.zon]