Skip to content

Commit a5dbde0

Browse files
committed
Add managed browser launching
1 parent 9349e97 commit a5dbde0

5 files changed

Lines changed: 433 additions & 37 deletions

File tree

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ The current phase provides:
4949
- loopback-only listening by default and caller-provided TLS for explicit
5050
public listening;
5151
- general OS URL opening and installed-browser discovery;
52+
- explicit browser launching with custom executable paths and argv;
53+
- per-window browser child identifiers and deterministic process cleanup;
5254
- default-browser launching and deterministic shutdown.
5355

5456
```zig
@@ -115,9 +117,17 @@ timeout for connection waiting and JavaScript execution.
115117
Call `openUrl(gpa, io, url)` to open any non-empty URL with the OS default
116118
handler. `browserExists(gpa, io, browser)` checks an explicit `Browser`, while
117119
`bestBrowser(gpa, io)` returns the first installed browser in the preferred
118-
platform order or `null`. Discovery probes registered applications on
119-
Windows and macOS and executable candidates on other platforms without
120-
opening the selected browser.
120+
platform order or `null`. Discovery probes Windows application registration,
121+
standard macOS application bundles, and executable candidates on other
122+
platforms without opening the selected browser.
123+
124+
`Window.openWithBrowser(&running, options)` launches a selected `Browser`
125+
with an optional full executable path and additional argv. Chromium-family
126+
browsers receive an `--app=` URL argument; Firefox receives `-new-window`.
127+
The returned `BrowserProcessId`, also available through
128+
`Window.browserProcessId()`, is a PID on POSIX and a process handle on
129+
Windows. Each window retains at most one launched child; launching another
130+
replaces it, and `Running.stop()` kills and reaps every retained child.
121131

122132
Serve a directory by setting
123133
`.content = .{ .directory = "path/to/public" }`. The path is opened when the

docs/PURE_ZIG_REFACTOR.md

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -297,15 +297,13 @@ implementations.
297297

298298
| Upstream API | Current gap |
299299
|---|---|
300-
| `webui_show_browser()`, `webui_set_browser_folder()` | Explicit browser selection and custom executable locations are not implemented. |
301-
| `webui_set_custom_parameters()` | Custom browser command-line arguments are not implemented. |
302300
| `webui_set_kiosk()`, `webui_focus()`, `webui_minimize()`, `webui_maximize()`, `webui_set_hide()` | Browser window mode and lifecycle controls are not implemented. |
303301
| `webui_set_resizable()`, `webui_set_size()`, `webui_set_minimum_size()`, `webui_set_position()`, `webui_set_center()` | Browser window geometry controls are not implemented. |
304302
| `webui_set_frameless()`, `webui_set_transparent()` | Frameless and transparent browser window modes are not implemented. |
305303
| `webui_set_high_contrast()`, `webui_is_high_contrast()` | High-contrast mode control and detection are not implemented. |
306304
| `webui_set_profile()`, `webui_delete_profile()`, `webui_delete_all_profiles()` | Managed browser profiles are not implemented. |
307305
| `webui_set_proxy()` | Browser proxy configuration is not implemented. |
308-
| `webui_get_parent_process_id()`, `webui_get_child_process_id()` | Browser process tracking is not implemented. |
306+
| `webui_get_parent_process_id()` | A portable parent-process numeric ID accessor is not implemented. |
309307
| `webui_set_runtime()` | Deno, Node.js, and Bun execution for served files is not implemented. |
310308
| `webui_show_wv()`, `webui_set_close_handler_wv()`, `webui_get_hwnd()`, `webui_win32_get_hwnd()` | Native WebView hosting and native window handles are outside the pure Zig browser core. |
311309

@@ -331,6 +329,8 @@ not implementation gaps:
331329
| `webui_is_shown()` | `Window.isShown()` reports whether the window has at least one connected browser client. |
332330
| `webui_open_url()` | `openUrl()` safely passes a non-empty URL as one argument to the platform default opener. |
333331
| `webui_get_best_browser()`, `webui_browser_exist()` | `bestBrowser()` and `browserExists()` discover registered or executable browser candidates through the public `Browser` enum. |
332+
| `webui_show_browser()`, `webui_set_browser_folder()`, `webui_set_custom_parameters()` | `Window.openWithBrowser()` accepts a `BrowserLaunchOptions` value with an explicit browser, optional full executable path, and additional argv. |
333+
| `webui_get_child_process_id()` | `Window.openWithBrowser()` returns the retained direct child's `BrowserProcessId`; `Window.browserProcessId()` retrieves it later. |
334334
| `webui_set_default_root_folder()` | `App.Options.default_directory` supplies directory content to windows created without explicit content. |
335335
| `webui_set_config(folder_monitor)` | `App.Options.folder_monitor_interval` enables portable recursive directory polling and reloads the affected window's connected clients. |
336336
| `webui_set_icon()`, `webui_set_icon_file()` | `Window.setIcon()` copies inline data and MIME type; `Window.setIconFile()` loads a supported image file as the window favicon. |
@@ -396,19 +396,23 @@ This completes `webui_show()`, `webui_show_client()`, `webui_is_shown()`,
396396
the dynamic root and file-handler methods, `webui_set_default_root_folder()`,
397397
`webui_set_icon()`, and `webui_set_icon_file()`.
398398

399-
### Managed browsers and window controls
399+
### Managed browser launch (complete)
400+
401+
Browser discovery, default URL opening, explicit browser selection, custom
402+
executable paths and argv, per-window direct child identifiers, replacement,
403+
and shutdown cleanup are implemented.
404+
405+
This completes the browser discovery, selection, custom-parameter, and direct
406+
child tracking methods in the ledger.
407+
408+
### Browser window controls
400409

401-
- Discover supported browsers and select the best or an explicit browser.
402-
- Support a custom browser executable directory and caller-provided argv.
403-
- Expose a general URL opener.
404-
- Launch and retain managed browser processes and expose their process IDs.
405410
- Implement kiosk, focus, minimize, maximize, hidden, resizable, geometry,
406411
frameless, transparent, and high-contrast controls where the selected
407412
browser and platform support them.
408413
- Implement managed profiles and proxy configuration.
409414

410-
This completes the browser selection, browser process, window control,
411-
profile, and proxy methods in the ledger.
415+
This completes the window control, profile, and proxy methods in the ledger.
412416

413417
### File monitoring (complete)
414418

@@ -488,6 +492,6 @@ zig build -Dtarget=aarch64-macos
488492

489493
Continue capability parity:
490494

491-
1. Add explicit browser selection, custom executable locations, and custom
492-
browser arguments.
493-
2. Launch and retain selected browser processes and expose their process IDs.
495+
1. Add typed browser launch controls for kiosk, hidden, high-contrast,
496+
resizable, size, and position behavior.
497+
2. Add managed browser profiles and proxy configuration.

src/app.zig

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ pub const EventHandler = *const fn (
6363
) anyerror!void;
6464
pub const Request = Linsang.Request;
6565
pub const Response = Linsang.Response;
66+
pub const BrowserLaunchOptions = browser.LaunchOptions;
67+
pub const BrowserProcessId = browser.ProcessId;
6668

6769
pub const EventKind = enum {
6870
connected,
@@ -1546,6 +1548,41 @@ pub const Window = struct {
15461548
try browser.openUrl(self.state.gpa, io, page_url);
15471549
}
15481550

1551+
/// Launch and retain one explicitly selected browser process.
1552+
pub fn openWithBrowser(
1553+
self: Window,
1554+
running: *Running,
1555+
options: BrowserLaunchOptions,
1556+
) !BrowserProcessId {
1557+
if (running.stopped or !running.app.started)
1558+
return error.NotRunning;
1559+
if (!running.app.hasWindow(self.state)) return error.UnknownWindow;
1560+
const page_url = try self.url(running, self.state.gpa);
1561+
defer self.state.gpa.free(page_url);
1562+
const child = try browser.launch(
1563+
self.state.gpa,
1564+
running.inner.io,
1565+
page_url,
1566+
options,
1567+
);
1568+
return running.app.manageBrowser(
1569+
running.inner.io,
1570+
self.state,
1571+
child,
1572+
);
1573+
}
1574+
1575+
/// Return the platform-native identifier of the retained browser child.
1576+
pub fn browserProcessId(
1577+
self: Window,
1578+
running: *const Running,
1579+
) !?BrowserProcessId {
1580+
if (running.stopped or !running.app.started)
1581+
return error.NotRunning;
1582+
if (!running.app.hasWindow(self.state)) return error.UnknownWindow;
1583+
return running.app.browserId(running.inner.io, self.state);
1584+
}
1585+
15491586
/// Return whether at least one browser client is connected.
15501587
pub fn isShown(self: Window, io: std.Io) bool {
15511588
return self.state.hasClients(io);
@@ -1712,6 +1749,13 @@ pub const Window = struct {
17121749
}
17131750
};
17141751

1752+
const ManagedBrowser = struct {
1753+
// ponytail: exited launchers are reaped on replacement or stop; add wait
1754+
// tasks only if one bounded child per window becomes insufficient.
1755+
window: *WindowState,
1756+
child: std.process.Child,
1757+
};
1758+
17151759
pub const App = struct {
17161760
gpa: std.mem.Allocator,
17171761
options: Options,
@@ -1720,6 +1764,8 @@ pub const App = struct {
17201764
server_io: ?std.Io = null,
17211765
tls_auth: ?Linsang.tls.CertKeyPair = null,
17221766
monitor_tasks: std.Io.Group = .init,
1767+
managed_browsers: std.ArrayList(ManagedBrowser) = .empty,
1768+
browser_mutex: std.Io.Mutex = .init,
17231769
started: bool = false,
17241770
closed: std.atomic.Value(bool) = .init(false),
17251771
unauthenticated_connections: std.atomic.Value(usize) = .init(0),
@@ -1764,6 +1810,8 @@ pub const App = struct {
17641810
std.debug.assert(self.server_io == null);
17651811
std.debug.assert(self.tls_auth == null);
17661812
std.debug.assert(self.monitor_tasks.token.load(.acquire) == null);
1813+
std.debug.assert(self.managed_browsers.items.len == 0);
1814+
self.managed_browsers.deinit(self.gpa);
17671815
for (self.windows.items) |window| window.deinit();
17681816
self.windows.deinit(self.gpa);
17691817
self.* = undefined;
@@ -1917,6 +1965,51 @@ pub const App = struct {
19171965
for (self.windows.items) |window| window.content.closeDirectory();
19181966
}
19191967

1968+
fn manageBrowser(
1969+
self: *App,
1970+
io: std.Io,
1971+
window: *WindowState,
1972+
child: std.process.Child,
1973+
) !BrowserProcessId {
1974+
var owned = child;
1975+
errdefer owned.kill(io);
1976+
const id = owned.id.?;
1977+
1978+
self.browser_mutex.lockUncancelable(io);
1979+
defer self.browser_mutex.unlock(io);
1980+
for (self.managed_browsers.items) |*managed| {
1981+
if (managed.window != window) continue;
1982+
managed.child.kill(io);
1983+
managed.child = owned;
1984+
return id;
1985+
}
1986+
try self.managed_browsers.append(self.gpa, .{
1987+
.window = window,
1988+
.child = owned,
1989+
});
1990+
return id;
1991+
}
1992+
1993+
fn browserId(
1994+
self: *App,
1995+
io: std.Io,
1996+
window: *WindowState,
1997+
) ?BrowserProcessId {
1998+
self.browser_mutex.lockUncancelable(io);
1999+
defer self.browser_mutex.unlock(io);
2000+
for (self.managed_browsers.items) |*managed|
2001+
if (managed.window == window) return managed.child.id;
2002+
return null;
2003+
}
2004+
2005+
fn stopBrowsers(self: *App, io: std.Io) void {
2006+
self.browser_mutex.lockUncancelable(io);
2007+
defer self.browser_mutex.unlock(io);
2008+
for (self.managed_browsers.items) |*managed|
2009+
managed.child.kill(io);
2010+
self.managed_browsers.clearRetainingCapacity();
2011+
}
2012+
19202013
fn hasWindow(self: *const App, state: *WindowState) bool {
19212014
// ponytail: window counts are tiny; use a map if hundreds become normal.
19222015
for (self.windows.items) |window|
@@ -1960,6 +2053,7 @@ pub const Running = struct {
19602053
self.app.monitor_tasks.cancel(self.inner.io);
19612054
for (self.app.windows.items) |window|
19622055
window.cancelEvents(self.inner.io);
2056+
self.app.stopBrowsers(self.inner.io);
19632057
self.app.closeDirectories();
19642058
self.app.deinitTls();
19652059
self.stopped = true;
@@ -3161,6 +3255,141 @@ fn authenticateTestClient(
31613255
return std.mem.eql(u8, response.payload, &.{1});
31623256
}
31633257

3258+
fn readTestFileEventually(
3259+
dir: std.Io.Dir,
3260+
io: std.Io,
3261+
gpa: std.mem.Allocator,
3262+
path: []const u8,
3263+
) ![]u8 {
3264+
for (0..100) |_| {
3265+
const data = dir.readFileAlloc(
3266+
io,
3267+
path,
3268+
gpa,
3269+
.limited(4096),
3270+
) catch |err| {
3271+
if (err != error.FileNotFound) return err;
3272+
try std.Io.sleep(io, .fromMilliseconds(1), .awake);
3273+
continue;
3274+
};
3275+
return data;
3276+
}
3277+
return error.Timeout;
3278+
}
3279+
3280+
test "selected browser launch owns argv process and shutdown" {
3281+
if (@import("builtin").os.tag != .linux) return error.SkipZigTest;
3282+
const gpa = std.testing.allocator;
3283+
var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited });
3284+
defer threaded.deinit();
3285+
const io = threaded.io();
3286+
3287+
var tmp = std.testing.tmpDir(.{});
3288+
defer tmp.cleanup();
3289+
try tmp.dir.writeFile(io, .{
3290+
.sub_path = "fake-browser",
3291+
.data =
3292+
\\#!/bin/sh
3293+
\\output=$1
3294+
\\shift
3295+
\\printf '%s\n' "$@" > "$output"
3296+
\\exec sleep 30
3297+
,
3298+
.flags = .{ .permissions = .executable_file },
3299+
});
3300+
const executable = try std.fmt.allocPrint(
3301+
gpa,
3302+
".zig-cache/tmp/{s}/fake-browser",
3303+
.{tmp.sub_path},
3304+
);
3305+
defer gpa.free(executable);
3306+
const first_capture = try std.fmt.allocPrint(
3307+
gpa,
3308+
".zig-cache/tmp/{s}/first-argv",
3309+
.{tmp.sub_path},
3310+
);
3311+
defer gpa.free(first_capture);
3312+
const second_capture = try std.fmt.allocPrint(
3313+
gpa,
3314+
".zig-cache/tmp/{s}/second-argv",
3315+
.{tmp.sub_path},
3316+
);
3317+
defer gpa.free(second_capture);
3318+
3319+
var app = App.init(gpa, .{});
3320+
defer app.deinit();
3321+
const window = try app.createWindow(.{
3322+
.content = .{ .html = "managed browser" },
3323+
});
3324+
var running = try app.start(io);
3325+
defer running.stop() catch {};
3326+
try std.testing.expectError(
3327+
error.InvalidBrowserExecutable,
3328+
window.openWithBrowser(&running, .{
3329+
.browser = .firefox,
3330+
.executable = "",
3331+
}),
3332+
);
3333+
const page_url = try window.url(&running, gpa);
3334+
defer gpa.free(page_url);
3335+
3336+
const first_id = try window.openWithBrowser(&running, .{
3337+
.browser = .firefox,
3338+
.executable = executable,
3339+
.arguments = &.{ first_capture, "--private-window" },
3340+
});
3341+
try std.testing.expectEqual(
3342+
first_id,
3343+
(try window.browserProcessId(&running)).?,
3344+
);
3345+
const first_argv = try readTestFileEventually(
3346+
tmp.dir,
3347+
io,
3348+
gpa,
3349+
"first-argv",
3350+
);
3351+
defer gpa.free(first_argv);
3352+
const expected_first = try std.fmt.allocPrint(
3353+
gpa,
3354+
"--private-window\n-new-window\n{s}\n",
3355+
.{page_url},
3356+
);
3357+
defer gpa.free(expected_first);
3358+
try std.testing.expectEqualStrings(expected_first, first_argv);
3359+
3360+
const second_id = try window.openWithBrowser(&running, .{
3361+
.browser = .chromium,
3362+
.executable = executable,
3363+
.arguments = &.{ second_capture, "--guest" },
3364+
});
3365+
try std.testing.expectEqual(
3366+
second_id,
3367+
(try window.browserProcessId(&running)).?,
3368+
);
3369+
try std.testing.expectEqual(@as(usize, 1), app.managed_browsers.items.len);
3370+
const second_argv = try readTestFileEventually(
3371+
tmp.dir,
3372+
io,
3373+
gpa,
3374+
"second-argv",
3375+
);
3376+
defer gpa.free(second_argv);
3377+
const expected_second = try std.fmt.allocPrint(
3378+
gpa,
3379+
"--guest\n--app={s}\n",
3380+
.{page_url},
3381+
);
3382+
defer gpa.free(expected_second);
3383+
try std.testing.expectEqualStrings(expected_second, second_argv);
3384+
3385+
try running.stop();
3386+
try std.testing.expectEqual(@as(usize, 0), app.managed_browsers.items.len);
3387+
try std.testing.expectError(
3388+
error.NotRunning,
3389+
window.browserProcessId(&running),
3390+
);
3391+
}
3392+
31643393
test "directory monitor reloads changed window only" {
31653394
if (@import("builtin").os.tag != .linux) return error.SkipZigTest;
31663395
const gpa = std.testing.allocator;

0 commit comments

Comments
 (0)