Skip to content

Commit 9349e97

Browse files
committed
Add browser discovery APIs
1 parent 126815e commit 9349e97

5 files changed

Lines changed: 260 additions & 18 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ The current phase provides:
4848
`App.Options.use_cookies`;
4949
- loopback-only listening by default and caller-provided TLS for explicit
5050
public listening;
51+
- general OS URL opening and installed-browser discovery;
5152
- default-browser launching and deterministic shutdown.
5253

5354
```zig
@@ -111,6 +112,13 @@ outcome.
111112
it returns the first connected `Client`. `Window.eval()` uses the same total
112113
timeout for connection waiting and JavaScript execution.
113114

115+
Call `openUrl(gpa, io, url)` to open any non-empty URL with the OS default
116+
handler. `browserExists(gpa, io, browser)` checks an explicit `Browser`, while
117+
`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.
121+
114122
Serve a directory by setting
115123
`.content = .{ .directory = "path/to/public" }`. The path is opened when the
116124
app starts and closed when it stops. Custom resources receive `webui.Request`

docs/PURE_ZIG_REFACTOR.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,7 @@ implementations.
297297

298298
| Upstream API | Current gap |
299299
|---|---|
300-
| `webui_open_url()` | The internal OS URL opener is not exposed as a general public API. |
301-
| `webui_get_best_browser()`, `webui_browser_exist()`, `webui_show_browser()`, `webui_set_browser_folder()` | Browser discovery, selection, and custom executable locations are not implemented. |
300+
| `webui_show_browser()`, `webui_set_browser_folder()` | Explicit browser selection and custom executable locations are not implemented. |
302301
| `webui_set_custom_parameters()` | Custom browser command-line arguments are not implemented. |
303302
| `webui_set_kiosk()`, `webui_focus()`, `webui_minimize()`, `webui_maximize()`, `webui_set_hide()` | Browser window mode and lifecycle controls are not implemented. |
304303
| `webui_set_resizable()`, `webui_set_size()`, `webui_set_minimum_size()`, `webui_set_position()`, `webui_set_center()` | Browser window geometry controls are not implemented. |
@@ -330,6 +329,8 @@ not implementation gaps:
330329
| `webui_show()`, `webui_start_server()`, `webui_get_url()` | Initial `Content`, runtime `Window.setContent()`, `App.start()`, `Window.open()`, and `Window.url()`. |
331330
| `webui_show_client()` | `Client.show()` replaces the window content and navigates only the selected client. |
332331
| `webui_is_shown()` | `Window.isShown()` reports whether the window has at least one connected browser client. |
332+
| `webui_open_url()` | `openUrl()` safely passes a non-empty URL as one argument to the platform default opener. |
333+
| `webui_get_best_browser()`, `webui_browser_exist()` | `bestBrowser()` and `browserExists()` discover registered or executable browser candidates through the public `Browser` enum. |
333334
| `webui_set_default_root_folder()` | `App.Options.default_directory` supplies directory content to windows created without explicit content. |
334335
| `webui_set_config(folder_monitor)` | `App.Options.folder_monitor_interval` enables portable recursive directory polling and reloads the affected window's connected clients. |
335336
| `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. |
@@ -487,6 +488,6 @@ zig build -Dtarget=aarch64-macos
487488

488489
Continue capability parity:
489490

490-
1. Expose a general OS URL opener and browser discovery queries.
491-
2. Add explicit browser selection, custom executable locations, and custom
491+
1. Add explicit browser selection, custom executable locations, and custom
492492
browser arguments.
493+
2. Launch and retain selected browser processes and expose their process IDs.

src/app.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1543,7 +1543,7 @@ pub const Window = struct {
15431543
pub fn open(self: Window, io: std.Io, running: *const Running) !void {
15441544
const page_url = try self.url(running, self.state.gpa);
15451545
defer self.state.gpa.free(page_url);
1546-
try browser.open(self.state.gpa, io, page_url);
1546+
try browser.openUrl(self.state.gpa, io, page_url);
15471547
}
15481548

15491549
/// Return whether at least one browser client is connected.

src/browser.zig

Lines changed: 241 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,250 @@
11
const std = @import("std");
22
const builtin = @import("builtin");
33

4-
pub fn open(gpa: std.mem.Allocator, io: std.Io, url: []const u8) !void {
5-
const result = switch (builtin.os.tag) {
6-
.windows => try std.process.run(gpa, io, .{
7-
.argv = &.{ "explorer.exe", url },
8-
}),
9-
.macos => try std.process.run(gpa, io, .{
10-
.argv = &.{ "open", url },
11-
}),
12-
else => try std.process.run(gpa, io, .{
13-
.argv = &.{ "xdg-open", url },
4+
/// Browser families supported by discovery and explicit selection.
5+
pub const Browser = enum {
6+
chrome,
7+
firefox,
8+
edge,
9+
safari,
10+
chromium,
11+
opera,
12+
brave,
13+
vivaldi,
14+
epic,
15+
yandex,
16+
};
17+
18+
/// Open a non-empty URL with the operating system's default handler.
19+
pub fn openUrl(
20+
gpa: std.mem.Allocator,
21+
io: std.Io,
22+
url: []const u8,
23+
) !void {
24+
if (url.len == 0) return error.InvalidUrl;
25+
const argv: []const []const u8 = switch (builtin.os.tag) {
26+
.windows => &.{ "explorer.exe", url },
27+
.macos => &.{ "open", url },
28+
else => &.{ "xdg-open", url },
29+
};
30+
if (!try commandSucceeds(gpa, io, argv))
31+
return error.BrowserOpenFailed;
32+
}
33+
34+
/// Return whether a browser is registered or available as an executable.
35+
pub fn browserExists(
36+
gpa: std.mem.Allocator,
37+
io: std.Io,
38+
selected: Browser,
39+
) !bool {
40+
return switch (builtin.os.tag) {
41+
.windows => windowsBrowserExists(gpa, io, selected),
42+
.macos => commandSucceeds(gpa, io, &.{
43+
"open",
44+
"-R",
45+
"-a",
46+
macosApplication(selected),
1447
}),
48+
else => for (linuxExecutables(selected)) |executable| {
49+
if (try commandSucceeds(gpa, io, &.{ executable, "--version" }))
50+
break true;
51+
} else false,
52+
};
53+
}
54+
55+
/// Return the first available browser in the platform preference order.
56+
pub fn bestBrowser(
57+
gpa: std.mem.Allocator,
58+
io: std.Io,
59+
) !?Browser {
60+
for (preferredBrowsers()) |selected|
61+
if (try browserExists(gpa, io, selected)) return selected;
62+
return null;
63+
}
64+
65+
fn commandSucceeds(
66+
gpa: std.mem.Allocator,
67+
io: std.Io,
68+
argv: []const []const u8,
69+
) !bool {
70+
const result = std.process.run(gpa, io, .{
71+
.argv = argv,
72+
}) catch |err| switch (err) {
73+
error.FileNotFound, error.AccessDenied, error.InvalidExe => return false,
74+
else => return err,
1575
};
1676
defer gpa.free(result.stdout);
1777
defer gpa.free(result.stderr);
18-
switch (result.term) {
19-
.exited => |code| if (code != 0) return error.BrowserOpenFailed,
20-
else => return error.BrowserOpenFailed,
78+
return switch (result.term) {
79+
.exited => |code| code == 0,
80+
else => false,
81+
};
82+
}
83+
84+
fn windowsBrowserExists(
85+
gpa: std.mem.Allocator,
86+
io: std.Io,
87+
selected: Browser,
88+
) !bool {
89+
const executable = windowsExecutable(selected);
90+
if (try commandSucceeds(gpa, io, &.{ "where.exe", executable }))
91+
return true;
92+
// ponytail: Chrome and Chromium share chrome.exe on Windows; inspect
93+
// installation metadata if standalone Chromium detection becomes needed.
94+
if (selected == .chromium) return false;
95+
96+
var key_buffer: [160]u8 = undefined;
97+
for ([_][]const u8{ "HKCU", "HKLM" }) |root| {
98+
const key = try std.fmt.bufPrint(
99+
&key_buffer,
100+
"{s}\\Software\\Microsoft\\Windows\\CurrentVersion\\" ++
101+
"App Paths\\{s}",
102+
.{ root, executable },
103+
);
104+
if (try commandSucceeds(gpa, io, &.{
105+
"reg.exe",
106+
"query",
107+
key,
108+
"/ve",
109+
})) return true;
110+
}
111+
return false;
112+
}
113+
114+
fn windowsExecutable(selected: Browser) []const u8 {
115+
return switch (selected) {
116+
.chrome => "chrome.exe",
117+
.firefox => "firefox.exe",
118+
.edge => "msedge.exe",
119+
.safari => "Safari.exe",
120+
.chromium => "chromium.exe",
121+
.opera => "opera.exe",
122+
.brave => "brave.exe",
123+
.vivaldi => "vivaldi.exe",
124+
.epic => "epic.exe",
125+
.yandex => "browser.exe",
126+
};
127+
}
128+
129+
fn macosApplication(selected: Browser) []const u8 {
130+
return switch (selected) {
131+
.chrome => "Google Chrome",
132+
.firefox => "Firefox",
133+
.edge => "Microsoft Edge",
134+
.safari => "Safari",
135+
.chromium => "Chromium",
136+
.opera => "Opera",
137+
.brave => "Brave Browser",
138+
.vivaldi => "Vivaldi",
139+
.epic => "Epic",
140+
.yandex => "Yandex",
141+
};
142+
}
143+
144+
fn linuxExecutables(selected: Browser) []const []const u8 {
145+
return switch (selected) {
146+
.chrome => &.{ "google-chrome", "google-chrome-stable" },
147+
.firefox => &.{"firefox"},
148+
.edge => &.{
149+
"microsoft-edge-stable",
150+
"microsoft-edge-beta",
151+
"microsoft-edge-dev",
152+
},
153+
.safari => &.{},
154+
.chromium => &.{ "chromium-browser", "chromium" },
155+
.opera => &.{"opera"},
156+
.brave => &.{
157+
"brave",
158+
"brave-browser",
159+
"brave-browser-stable",
160+
"brave-browser-nightly",
161+
"brave-browser-beta",
162+
},
163+
.vivaldi => &.{ "vivaldi", "vivaldi-stable", "vivaldi-snapshot" },
164+
.epic => &.{"epic"},
165+
.yandex => &.{"yandex-browser"},
166+
};
167+
}
168+
169+
fn preferredBrowsers() []const Browser {
170+
return switch (builtin.os.tag) {
171+
.windows => &.{
172+
.chrome,
173+
.edge,
174+
.epic,
175+
.vivaldi,
176+
.brave,
177+
.firefox,
178+
.yandex,
179+
.chromium,
180+
.opera,
181+
.safari,
182+
},
183+
else => &.{
184+
.chrome,
185+
.edge,
186+
.chromium,
187+
.epic,
188+
.vivaldi,
189+
.brave,
190+
.firefox,
191+
.yandex,
192+
.opera,
193+
.safari,
194+
},
195+
};
196+
}
197+
198+
test "browser candidates and preference order cover every browser" {
199+
try std.testing.expectError(
200+
error.InvalidUrl,
201+
openUrl(std.testing.allocator, std.testing.io, ""),
202+
);
203+
try std.testing.expectEqualStrings(
204+
"chrome.exe",
205+
windowsExecutable(.chrome),
206+
);
207+
try std.testing.expectEqualStrings(
208+
"Google Chrome",
209+
macosApplication(.chrome),
210+
);
211+
try std.testing.expectEqualStrings(
212+
"google-chrome",
213+
linuxExecutables(.chrome)[0],
214+
);
215+
216+
var seen: std.EnumSet(Browser) = .initEmpty();
217+
for (preferredBrowsers()) |selected| {
218+
try std.testing.expect(!seen.contains(selected));
219+
seen.insert(selected);
220+
}
221+
try std.testing.expectEqual(
222+
std.meta.fields(Browser).len,
223+
seen.count(),
224+
);
225+
try std.testing.expectEqual(Browser.chrome, preferredBrowsers()[0]);
226+
227+
if (builtin.os.tag == .linux) {
228+
try std.testing.expect(try commandSucceeds(
229+
std.testing.allocator,
230+
std.testing.io,
231+
&.{"/bin/true"},
232+
));
233+
try std.testing.expect(!try commandSucceeds(
234+
std.testing.allocator,
235+
std.testing.io,
236+
&.{"/bin/false"},
237+
));
238+
try std.testing.expect(!try commandSucceeds(
239+
std.testing.allocator,
240+
std.testing.io,
241+
&.{"/definitely/missing/browser"},
242+
));
243+
if (try bestBrowser(std.testing.allocator, std.testing.io)) |selected|
244+
try std.testing.expect(try browserExists(
245+
std.testing.allocator,
246+
std.testing.io,
247+
selected,
248+
));
21249
}
22250
}

src/root.zig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@ pub const Response = @import("app.zig").Response;
2323
pub const BroadcastEvalOutcome = @import("app.zig").BroadcastEvalOutcome;
2424
pub const BroadcastEval = @import("app.zig").BroadcastEval;
2525
pub const BroadcastEvalResults = @import("app.zig").BroadcastEvalResults;
26+
pub const Browser = @import("browser.zig").Browser;
27+
pub const openUrl = @import("browser.zig").openUrl;
28+
pub const browserExists = @import("browser.zig").browserExists;
29+
pub const bestBrowser = @import("browser.zig").bestBrowser;
2630
pub const protocol = @import("protocol.zig");
2731

2832
test {
2933
_ = @import("app.zig");
34+
_ = @import("browser.zig");
3035
_ = protocol;
3136
}

0 commit comments

Comments
 (0)