///|
/// macOS title-bar style
pub(all) enum TitleBarStyle {
/// Normal native title bar (default).
Visible = 0
/// Native title bar rendered transparent, still present.
Transparent = 1
/// Content extends underneath the native title bar (custom title bars).
Overlay = 2
} derive(Debug, Eq, ToJson, FromJson, Hash)
///|
pub struct MemoryFile {
path : String
content : Bytes
} derive(Debug, Eq, ToJson, FromJson, Hash)
///|
pub struct MemorySource {
entry : String
files : Array[MemoryFile]
} derive(Debug, Eq, ToJson, FromJson, Hash)
///|
pub(all) enum Source {
Url(String)
Local(String)
Memory(MemorySource)
} derive(Debug, Eq, ToJson, FromJson, Hash)
///|
/// Window configuration.
///
/// Every field is optional. `None` means "not specified — keep the native
/// platform default", so unspecified style options never trigger a native
/// call at all. Pass a value to override it, e.g. `decorations=false` or
/// `title_bar_style=Overlay`.
///
/// `focused` is the one option whose default needs an action rather than
/// inaction: a new window is focused by default, and only `focused=false`
/// opts out of taking focus.
pub struct WindowConfig {
label : String?
title : String?
width : Int?
height : Int?
debug : Int?
devtools : Bool?
/// Keep the OS-provided window chrome (title bar + border). `Some(false)`
/// produces a frameless window; `frameless` is kept as a back-compat alias.
decorations : Bool?
resizable : Bool?
closeable : Bool?
minimizable : Bool?
maximizable : Bool?
always_on_top : Bool?
always_on_bottom : Bool?
transparent : Bool?
shadow : Bool?
skip_taskbar : Bool?
visible_on_all_workspaces : Bool?
title_bar_style : TitleBarStyle?
title_bar_overlay : Bool?
hidden_title : Bool?
traffic_light_position : (Int, Int)?
position : (Int, Int)?
hidden : Bool?
focused : Bool?
source : Source?
} derive(Debug, Eq, ToJson, FromJson, Hash)
///|
priv struct ResolvedSource {
url : String
custom_protocols : Array[(String, String)]
}
///|
pub fn MemoryFile::from_bytes(path : String, content : Bytes) -> MemoryFile {
{ path, content, }
}
///|
pub fn MemoryFile::from_string(path : String, content : String) -> MemoryFile {
let content = @utf8.encode(content)
{ path, content, }
}
///|
#alias(new, deprecated="Use `MemorySource()` instead")
pub fn MemorySource::MemorySource(
files : Array[MemoryFile],
entry? : String = "index.html",
) -> MemorySource {
{ entry, files, }
}
///|
pub fn Source::html(html : String) -> Source {
Memory(MemorySource([MemoryFile::from_string("index.html", html)]))
}
///|
pub fn Source::zip(zip : Bytes) -> Source raise {
let archive = @zipc.Archive::from_bytes(zip)
let files = []
archive.members.each((name, m) => {
if m.kind is File(file) {
files.push(MemoryFile::from_bytes(name, file.to_bytes()))
}
})
Memory(MemorySource(files))
}
///|
pub fn WindowConfig::new(
label? : String,
title? : String,
width? : Int,
height? : Int,
debug? : Int,
devtools? : Bool,
decorations? : Bool,
resizable? : Bool,
closeable? : Bool,
minimizable? : Bool,
maximizable? : Bool,
always_on_top? : Bool,
always_on_bottom? : Bool,
transparent? : Bool,
shadow? : Bool,
skip_taskbar? : Bool,
visible_on_all_workspaces? : Bool,
title_bar_style? : TitleBarStyle,
title_bar_overlay? : Bool,
hidden_title? : Bool,
frameless? : Bool = false,
traffic_light_position? : (Int, Int),
position? : (Int, Int),
hidden? : Bool,
focused? : Bool,
source? : Source,
) -> WindowConfig {
{
label,
title,
width,
height,
debug,
devtools,
decorations: if frameless {
Some(false)
} else {
decorations
},
resizable,
closeable,
minimizable,
maximizable,
always_on_top,
always_on_bottom,
transparent,
shadow,
skip_taskbar,
visible_on_all_workspaces,
title_bar_style,
title_bar_overlay,
hidden_title,
traffic_light_position,
position,
hidden,
focused,
source,
}
}
///|
/// Returns the window label used for stable cross-window addressing.
/// Falls back to `title`, then to the default app title, when no explicit
/// label was given.
pub fn WindowConfig::label(self : WindowConfig) -> String {
match self.label {
Some(l) if !l.is_empty() => l
_ => self.title.unwrap_or("Lepus App")
}
}
///|
pub fn WindowConfig::set_source(
self : WindowConfig,
source : Source,
) -> WindowConfig {
{ ..self, source: Some(source), }
}
///|
pub fn WindowConfig::navigate(
self : WindowConfig,
url : String,
) -> WindowConfig {
self.set_source(Url(url))
}
///|
/// Maps the config's title-bar style onto the `@webview` enum, preserving
/// "not specified".
fn to_webview_title_bar_style(
style : TitleBarStyle?,
) -> @webview.TitleBarStyle? {
match style {
Some(Visible) => Some(@webview.TitleBarStyle::Visible)
Some(Transparent) => Some(@webview.TitleBarStyle::Transparent)
Some(Overlay) => Some(@webview.TitleBarStyle::Overlay)
None => None
}
}
///|
/// `true` when the config explicitly asks for a custom (non-native) title bar,
/// which is what needs the injected title-bar support layer.
fn wants_custom_titlebar(config : WindowConfig) -> Bool {
if config.decorations is Some(false) {
return true
}
if config.title_bar_overlay is Some(true) {
return true
}
match config.title_bar_style {
Some(Transparent) | Some(Overlay) => true
_ => false
}
}
///|
/// `true` only when the config explicitly asks for a transparent background.
/// An unset `transparent` must not opt in: the support layer is expensive and
/// would change how the window looks.
fn wants_transparency(config : WindowConfig) -> Bool {
config.transparent is Some(true)
}
///|
test "a default window config leaves every native option unset" {
let config = WindowConfig::new(source=Source::html(""))
assert_true(config.width is None)
assert_true(config.height is None)
assert_true(config.decorations is None)
assert_true(config.shadow is None)
assert_true(config.title_bar_style is None)
assert_true(config.position is None)
assert_true(config.hidden is None)
assert_true(config.focused is None)
// Nothing opted in, so no support layer is injected.
assert_false(wants_custom_titlebar(config))
assert_false(wants_transparency(config))
// Title still falls back to the documented default.
assert_eq(config.label(), "Lepus App")
}
///|
test "frameless keeps working as a decorations alias" {
let config = WindowConfig::new(frameless=true, source=Source::html(""))
assert_eq(config.decorations, Some(false))
assert_true(wants_custom_titlebar(config))
let explicit = WindowConfig::new(decorations=false, source=Source::html(""))
assert_eq(explicit.decorations, Some(false))
}
///|
test "transparency support is only injected on an explicit opt-in" {
assert_false(
wants_transparency(
WindowConfig::new(transparent=false, source=Source::html("")),
),
)
assert_true(
wants_transparency(
WindowConfig::new(transparent=true, source=Source::html("")),
),
)
}
///|
test "overlay and transparent title bars request custom-titlebar support" {
for style in [Transparent, Overlay] {
assert_true(
wants_custom_titlebar(
WindowConfig::new(title_bar_style=style, source=Source::html("")),
),
)
}
assert_false(
wants_custom_titlebar(
WindowConfig::new(title_bar_style=Visible, source=Source::html("")),
),
)
}
///|
test "a config JSON round trip preserves which options were set" {
let config = WindowConfig::new(
decorations=false,
title_bar_style=Overlay,
source=Source::Url("https://example.com"),
)
let restored : WindowConfig = @json.from_json(config.to_json())
// Specified options survive...
assert_eq(restored.decorations, Some(false))
assert_eq(restored.title_bar_style, Some(Overlay))
// ...and unspecified ones stay unspecified instead of turning into
// concrete "reset to default" values.
assert_true(restored.shadow is None)
assert_true(restored.focused is None)
assert_true(restored.transparent is None)
assert_eq(restored, config)
}
///|
pub struct App {
windows : Array[WindowConfig]
plugins : Array[@webview.Plugin]
}
///|
pub struct Window {
config : WindowConfig
plugins : Array[@webview.Plugin]
}
///|
/// Creates a single-window app.
///
/// Every window option is optional; an omitted option keeps the native
/// platform default and costs no native call.
#alias(new, deprecated="Use `Window()` instead")
pub fn Window::Window(
label? : String,
title? : String,
width? : Int,
height? : Int,
debug? : Int,
devtools? : Bool,
decorations? : Bool,
resizable? : Bool,
closeable? : Bool,
minimizable? : Bool,
maximizable? : Bool,
always_on_top? : Bool,
always_on_bottom? : Bool,
transparent? : Bool,
shadow? : Bool,
skip_taskbar? : Bool,
visible_on_all_workspaces? : Bool,
title_bar_style? : TitleBarStyle,
title_bar_overlay? : Bool,
hidden_title? : Bool,
frameless? : Bool = false,
traffic_light_position? : (Int, Int),
position? : (Int, Int),
hidden? : Bool,
focused? : Bool,
source? : Source,
plugins? : Array[@webview.Plugin] = [],
) -> Window {
{
config: {
label,
title,
width,
height,
debug,
devtools,
decorations: if frameless {
Some(false)
} else {
decorations
},
resizable,
closeable,
minimizable,
maximizable,
always_on_top,
always_on_bottom,
transparent,
shadow,
skip_taskbar,
visible_on_all_workspaces,
title_bar_style,
title_bar_overlay,
hidden_title,
traffic_light_position,
position,
hidden,
focused,
source,
},
plugins,
}
}
///|
pub fn Window::set_source(self : Window, source : Source) -> Window {
{ ..self, config: self.config.set_source(source), }
}
///|
pub fn Window::navigate(self : Window, url : String) -> Window {
self.set_source(Url(url))
}
///|
pub async fn Window::run(self : Window) -> Unit {
App::new([self.config], plugins=self.plugins).run()
}
///|
pub fn App::new(
windows : Array[WindowConfig],
plugins? : Array[@webview.Plugin] = [],
) -> App {
{ windows, plugins, }
}
///|
pub fn App::with_windows(
windows : Array[WindowConfig],
plugins? : Array[@webview.Plugin] = [],
) -> App {
guard windows.length() > 0 else {
abort("App::with_windows requires at least one window")
}
{ windows, plugins, }
}
///|
pub fn App::add_window(self : App, config : WindowConfig) -> Unit {
self.windows.push(config)
}
///|
pub fn App::window_count(self : App) -> Int {
self.windows.length()
}
///|
pub async fn App::run(self : App, window_index? : Int) -> Unit {
let selected = match window_index {
Some(i) => {
guard i >= 0 && i < self.windows.length() else {
abort("App::run window_index out of range")
}
[self.windows[i]]
}
None => self.windows
}
guard selected.length() > 0 else {
abort("App::run requires at least one window")
}
assert_unique_labels(selected)
match @webview.detect_child_window() {
Some((base, label)) => {
// 子进程:事件插件的 parent handler 不会被使用(此进程只安装
// child-side proxy),故传空映射与子进程 WM 即可。
let bus = EventBus::new(Map([]))
let wm = @webview.WindowManager::init(is_main=false)
let all_plugins = self.plugins + [bus.plugin(wm)]
let config = find_window_config(self.windows, label)
let window = build_runtime_window(config, all_plugins)
window.run_child(base)
}
None => {
// 主进程:建立 label → window_id 映射(window_id == spawn 时的 base),
// 并为主进程的命令路由装配事件插件。
let ids = assign_window_ids(selected)
let bus = EventBus::new(ids)
let wm = @webview.WindowManager::init(is_main=true)
let all_plugins = self.plugins + [bus.plugin(wm)]
let labels : Array[String] = []
for config in selected {
labels.push(config.label())
}
let router = @webview.build_router(all_plugins)
@webview.Window::run_many(labels, router)
}
}
}
///|
/// Runs all windows in a **single process**.
///
/// Unlike `App::run` (which spawns one child process per window), this mode
/// creates every window inside the current process. All windows share one
/// `NSApplication` / Dock icon (macOS), one menu bar, and one event loop —
/// matching the Tauri/Electron model. Cross-window events are delivered by
/// direct `eval` (no IPC), so this is the recommended mode for most apps.
///
/// The event loop runs once (a single `webview_run` services all windows);
/// it returns when every window has closed.
pub async fn App::run_single_process(self : App) -> Unit {
guard self.windows.length() > 0 else {
abort("App::run_single_process requires at least one window")
}
assert_unique_labels(self.windows)
let wm = @webview.WindowManager::init(is_main=true)
// macOS:单进程 = 单个 NSApplication = 单个 Dock 图标,窗口天然可聚焦。
// Regular 策略由 vendor 默认设置;安装标准主菜单启用快捷键。
ignore(wm.install_default_app_menu())
let bus = LocalEventBus::new()
@async.with_task_group(task_group => {
let first_id = build_single_process_windows(
task_group,
self.windows,
self.plugins,
bus,
)
guard first_id > 0 else {
abort("App::run_single_process: no window created")
}
// 单次 webview_run 服务全部窗口(共享 NSApp run loop)。
// window_ref_count 归零时 vendor 自动 terminate。
ignore(wm.run_window(first_id))
})
}
///|
/// 在单进程内为每个 WindowConfig 创建 WebView、安装插件(直连,无 IPC)、
/// 注册到本地事件总线,并返回首个窗口的 id(供单次 run loop 使用)。
async fn build_single_process_windows(
task_group : @async.TaskGroup[Unit],
configs : Array[WindowConfig],
extra_plugins : Array[@webview.Plugin],
bus : LocalEventBus,
) -> Int {
let mut first_id = 0
for config in configs {
let webview = @webview.WebView::new_managed(
task_group,
title=config.title.unwrap_or("Lepus App"),
url="",
width?=config.width,
height?=config.height,
debug?=config.debug,
devtools?=config.devtools,
decorations?=config.decorations,
resizable?=config.resizable,
closeable?=config.closeable,
minimizable?=config.minimizable,
maximizable?=config.maximizable,
always_on_top?=config.always_on_top,
always_on_bottom?=config.always_on_bottom,
transparent?=config.transparent,
shadow?=config.shadow,
skip_taskbar?=config.skip_taskbar,
visible_on_all_workspaces?=config.visible_on_all_workspaces,
title_bar_style?=to_webview_title_bar_style(config.title_bar_style),
title_bar_overlay?=config.title_bar_overlay,
hidden_title?=config.hidden_title,
traffic_light_position?=config.traffic_light_position,
position?=config.position,
hidden?=config.hidden,
focused?=config.focused,
)
let wid = webview.window_id()
if first_id == 0 {
first_id = wid
}
// 自定义协议
guard config.source is Some(source) else {
abort("WindowConfig::source is required")
}
let resolved = resolve_source(source)
resolved.custom_protocols.each(mapping => {
let (scheme, root_dir) = mapping
webview.set_custom_protocol(scheme, root_dir)
})
// 平台标题栏支持
if wants_custom_titlebar(config) {
webview.enable_custom_titlebar_support()
}
if wants_transparency(config) {
webview.enable_transparent_background_support()
}
match config.traffic_light_position {
Some((x, y)) if x >= 0 && y >= 0 =>
webview.set_traffic_light_position(x, y)
_ => ()
}
// 插件:window_controls + 用户插件 + 事件总线(全部直连,proxy=None)
let host = @webview.PluginHost::PluginHost(webview)
host.install(@webview.window_controls_plugin(wid))
for plugin in extra_plugins {
host.install(plugin)
}
bus.register(config.label(), webview)
host.install(bus.plugin())
// 内容
if resolved.url != "" {
webview.navigate(resolved.url)
}
}
first_id
}
///|
/// Assigns a globally-unique window id to each label, matching the `base`
/// values the child processes use when creating their windows.
fn assign_window_ids(windows : Array[WindowConfig]) -> Map[String, Int] {
let ids = Map([])
let mut base = 1
for config in windows {
ids.set(config.label(), base)
base = base + 1
}
ids
}
///|
/// Returns the window whose `label()` matches `label`, aborting when absent.
fn find_window_config(
windows : Array[WindowConfig],
label : String,
) -> WindowConfig {
for config in windows {
if config.label() == label {
return config
}
}
abort("App: no window with label " + label)
}
///|
/// Asserts that every window label is unique so cross-window addressing and
/// child-process dispatch are unambiguous.
fn assert_unique_labels(windows : Array[WindowConfig]) -> Unit {
let seen : Array[String] = []
for config in windows {
let label = config.label()
guard !seen.contains(label) else {
abort("App: duplicate window label " + label)
}
seen.push(label)
}
}
///|
/// Builds a runtime `@webview.Window` from a `WindowConfig`, resolving its
/// source, registering custom protocols, and installing the App-level plugins.
async fn build_runtime_window(
config : WindowConfig,
plugins : Array[@webview.Plugin],
) -> @webview.Window {
let window = @webview.Window(
label=config.label(),
title=config.title.unwrap_or("Lepus App"),
width?=config.width,
height?=config.height,
debug?=config.debug,
devtools?=config.devtools,
decorations?=config.decorations,
resizable?=config.resizable,
closeable?=config.closeable,
minimizable?=config.minimizable,
maximizable?=config.maximizable,
always_on_top?=config.always_on_top,
always_on_bottom?=config.always_on_bottom,
transparent?=config.transparent,
shadow?=config.shadow,
skip_taskbar?=config.skip_taskbar,
visible_on_all_workspaces?=config.visible_on_all_workspaces,
title_bar_style?=to_webview_title_bar_style(config.title_bar_style),
title_bar_overlay?=config.title_bar_overlay,
hidden_title?=config.hidden_title,
traffic_light_position?=config.traffic_light_position,
position?=config.position,
hidden?=config.hidden,
focused?=config.focused,
)
guard config.source is Some(source) else {
abort("WindowConfig::source is required")
}
let resolved = resolve_source(source)
resolved.custom_protocols.each(mapping => {
let (scheme, root_dir) = mapping
window.set_custom_protocol(scheme, root_dir)
})
window.navigate(resolved.url)
plugins.each(plugin => window.install(plugin))
window
}
///|
async fn resolve_source(source : Source) -> ResolvedSource {
match source {
Url(url) => { url, custom_protocols: [], }
Local(path) => resolve_local_fs(path)
Memory(source) => resolve_memory_source(source)
}
}
///|
async fn resolve_local_fs(path : String) -> ResolvedSource {
let fs_path = if path.has_prefix("file://") {
file_url_to_path(path)
} else {
path
}
let normalized = fs_path.replace_all(old="\\", new="/")
let entry_path = if is_html_path(normalized) {
normalized
} else {
join_path(normalized, "index.html")
}
guard @fs.exists(entry_path) else {
abort("LocalFs source does not exist: " + entry_path)
}
guard @fs.kind(entry_path) == Regular else {
abort("LocalFs source is not a regular file: " + entry_path)
}
guard @fs.can_read(entry_path) else {
abort("LocalFs source is not readable: " + entry_path)
}
{ url: to_file_url(@fs.realpath(entry_path)), custom_protocols: [], }
}
///|
async fn resolve_memory_source(source : MemorySource) -> ResolvedSource {
guard source.files.length() > 0 else {
abort("MemoryFs source requires at least one file")
}
let entry = sanitize_memory_relative_path(source.entry)
let root = @fs.realpath(@fs.tmpdir(prefix="lepus-source"))
let mut has_entry = false
for file in source.files {
let relative_path = sanitize_memory_relative_path(file.path)
if relative_path == entry {
has_entry = true
}
let file_path = join_path(root, relative_path)
match parent_dir(file_path) {
Some(parent) =>
if !@fs.exists(parent) {
@fs.mkdir(parent, permission=0o700, recursive=true)
}
None => ()
}
@fs.write_file(
file_path,
file.content,
create_mode=CreateOrTruncate,
permission=0o644,
)
}
guard has_entry else { abort("MemoryFs entry file not found: " + entry) }
{
url: memory_source_entry_url(entry),
custom_protocols: [(memory_source_mapping_name(), root)],
}
}
///|
#cfg(platform="windows")
fn memory_source_mapping_name() -> String {
"lepus"
}
///|
#cfg(not(platform="windows"))
fn memory_source_mapping_name() -> String {
"lepus"
}
///|
#cfg(platform="windows")
fn memory_source_entry_url(entry : String) -> String {
"http://lepus/" + entry
}
///|
#cfg(not(platform="windows"))
fn memory_source_entry_url(entry : String) -> String {
"lepus://localhost/" + entry
}
///|
fn to_file_url(path : String) -> String {
let normalized = path.replace_all(old="\\", new="/")
if normalized.has_prefix("/") {
"file://" + normalized
} else {
"file:///" + normalized
}
}
///|
fn join_path(base : String, leaf : String) -> String {
let base = base.trim_end(chars="/\\").to_owned()
let leaf = leaf.trim_start(chars="/\\").to_owned()
if base.is_empty() {
leaf
} else if leaf.is_empty() {
base
} else {
base + "/" + leaf
}
}
///|
fn is_html_path(path : String) -> Bool {
path.has_suffix(".html") ||
path.has_suffix(".htm") ||
path.has_suffix(".xhtml")
}
///|
fn file_url_to_path(url : String) -> String {
guard url.has_prefix("file://") else { url }
let path = url[7:].to_owned()
if path.is_empty() {
"/"
} else {
path
}
}
///|
fn sanitize_memory_relative_path(path : String) -> String {
let normalized = path.replace_all(old="\\", new="/")
let trimmed = normalized.trim_start(chars="/").trim_end(chars="/").to_owned()
guard !trimmed.is_empty() else { abort("MemoryFs path cannot be empty") }
guard trimmed != ".." &&
!trimmed.has_prefix("../") &&
!trimmed.has_suffix("/..") &&
!trimmed.contains("/../") &&
trimmed != "." &&
!trimmed.has_prefix("./") &&
!trimmed.has_suffix("/.") &&
!trimmed.contains("/./") else {
abort("MemoryFs path cannot use '.' or '..': " + path)
}
trimmed
}
///|
fn parent_dir(path : String) -> String? {
let path = path.trim_end(chars="/\\").to_owned()
guard !path.is_empty() else { None }
match path.rev_find("/") {
Some(index) => {
let parent = path[:index].trim_end(chars="/\\").to_owned()
if parent.is_empty() {
None
} else {
Some(parent)
}
}
None =>
match path.rev_find("\\") {
Some(index) => {
let parent = path[:index].trim_end(chars="/\\").to_owned()
if parent.is_empty() {
None
} else {
Some(parent)
}
}
None => None
}
}
}