diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d87e283..8dcf342 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,39 @@ jobs: - run: mix test env: DESKTOP_WEBVIEW_BINARY: ${{ github.workspace }}/priv/native/macos/DesktopWebView + + linux-e2e: + name: Linux host + E2E + runs-on: ubuntu-latest + env: + NO_WX: "1" + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "26" + elixir-version: "1.18" + - name: Checkout desktop (path dep) + uses: actions/checkout@v4 + with: + repository: elixir-desktop/desktop + path: desktop + - name: Install GTK / WebKitGTK + run: | + sudo apt-get update -qq + sudo apt-get install -y \ + build-essential cmake pkg-config \ + libgtk-4-dev libwebkitgtk-6.0-dev \ + libnotify-dev libjson-glib-dev \ + xvfb dbus-x11 + - name: Build host + run: ./scripts/build_linux.sh + - run: mix deps.get + - name: E2E under Xvfb + run: dbus-run-session -- xvfb-run -a mix test + env: + DESKTOP_WEBVIEW_BINARY: ${{ github.workspace }}/priv/native/linux/DesktopWebView + WEBKIT_DISABLE_COMPOSITING_MODE: "1" + WEBKIT_DISABLE_DMABUF_RENDERER: "1" + WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS: "1" + LIBGL_ALWAYS_SOFTWARE: "1" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f310713..5256ca8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,5 +34,32 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + linux: + name: Linux x86_64 artifact + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install GTK / WebKitGTK + run: | + sudo apt-get update -qq + sudo apt-get install -y \ + build-essential cmake pkg-config \ + libgtk-4-dev libwebkitgtk-6.0-dev \ + libnotify-dev libjson-glib-dev + - name: Build host + run: ./scripts/build_linux.sh + - name: Pack + run: | + mkdir -p dist + cp priv/native/linux/DesktopWebView dist/DesktopWebView-linux-x86_64 + sha256sum dist/DesktopWebView-linux-x86_64 > dist/DesktopWebView-linux-x86_64.sha256 + - uses: softprops/action-gh-release@v2 + with: + draft: true + files: | + dist/DesktopWebView-linux-x86_64 + dist/DesktopWebView-linux-x86_64.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # windows: add when native/windows host exists — attach DesktopWebView-windows-* to the same draft release - # linux: add when native/linux host exists — attach DesktopWebView-linux-* to the same draft release diff --git a/.gitignore b/.gitignore index 5a83e7d..b0d21f4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ desktop_webview-*.tar *.xcworkspace/xcuserdata/ native/macos/.build/ native/macos/Package.resolved +native/linux/build/ priv/native/macos/DesktopWebView +priv/native/linux/DesktopWebView .DS_Store *.swp diff --git a/README.md b/README.md index 5bf3212..9d77f09 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ spawns the BEAM release; in development an existing BEAM can launch the host wit |----------|--------|--------| | macOS | WKWebView | Primary — see [docs/status/macos.md](docs/status/macos.md) | | Windows | WebView2 | Planned — [docs/status/windows.md](docs/status/windows.md) | -| Linux | WebKitGTK | Planned — [docs/status/linux.md](docs/status/linux.md) | +| Linux | WebKitGTK | Usable — [docs/status/linux.md](docs/status/linux.md) | ## Quick start (development) @@ -25,10 +25,11 @@ config :desktop, :backend, DesktopWebview.Backend config :desktop, :menu_adapter, DesktopWebview.Menu.Adapter ``` -Build the macOS host (or use the vendored binary under `priv/native/macos/`): +Build the native host for your OS (or use a vendored/CI binary): ```bash -./scripts/build_macos.sh +./scripts/build_macos.sh # macOS → priv/native/macos/DesktopWebView +./scripts/build_linux.sh # Linux → priv/native/linux/DesktopWebView mix test # unit mix test.e2e # needs host binary; starts with --edw-test-rpc ``` diff --git a/docs/packaging.md b/docs/packaging.md index da29ba6..0d56af1 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -71,7 +71,10 @@ MyApp/ - Release asset name: `DesktopWebView-linux-x86_64`. - Prefer shipping against a documented WebKitGTK/GTK baseline (note distro packages in `native/linux/README.md`). -- Tray: StatusNotifierItem when available. + Ubuntu 24.04 baseline: GTK 4.14 + WebKitGTK 2.52 (`libgtk-4-dev`, + `libwebkitgtk-6.0-dev`). +- Tray: StatusNotifierItem when available; current host keeps an in-memory tray + for RPC conformance (AppIndicator is GTK 3 and not linked). - Microphone / camera: PipeWire/Pulse + portal prompts may appear; hybrid permission policy still applies. Flatpak/snap portals need extra packaging notes when those formats are supported. diff --git a/docs/status/linux.md b/docs/status/linux.md index 3a77f8c..ada799e 100644 --- a/docs/status/linux.md +++ b/docs/status/linux.md @@ -2,35 +2,36 @@ States: `done` | `partial` | `todo` | `n/a` -Host not implemented yet. Follow [docs/porting.md](../porting.md). -Binary delivery via GitHub Releases (not Hex `priv/`). +Host: GTK 4 + WebKitGTK 6 (`native/linux/`). Binary delivery via GitHub Releases +(not Hex `priv/`); local/CI uses `./scripts/build_linux.sh` → +`priv/native/linux/DesktopWebView` or `DESKTOP_WEBVIEW_BINARY`. | Feature | Status | Notes | |---------|--------|-------| -| TCP listen + `listening ` | todo | | -| `initialize` + capabilities | todo | | -| `--edw-no-beam` / argv strip | todo | | -| Ini + defaults | todo | | -| Lifetime reconnect | todo | | -| Lifetime coupled | todo | | -| Window open/show/hide/title/min size | todo | | -| Multi-window (ids) | todo | | -| Close veto → Elixir | todo | | -| Raise / iconize / shown / active | todo | | -| Webview loadURL / reload / current URL | todo | | -| Webview rebuild | todo | | -| New window → external open | todo | | -| Context menu disable | todo | | -| Menubar from DOM | todo | | -| Tray / status item | todo | | -| Apple menu | n/a | | -| Notifications | todo | | -| Icons from path / PNG | todo | | -| OS events (reopen, open url/file) | todo | | -| Locale / os_description | todo | | -| Permission policy hybrid | todo | | -| Microphone in webview | todo | | -| Camera in webview | todo | | -| Test RPC channel | todo | | +| TCP listen + `listening ` | done | | +| `initialize` + capabilities | done | `platform: "linux"` | +| `--edw-no-beam` / argv strip | done | | +| Ini + defaults | done | beside executable | +| Lifetime reconnect | done | default | +| Lifetime coupled | done | | +| Window open/show/hide/title/min size | done | | +| Multi-window (ids) | done | | +| Close veto → Elixir | done | | +| Raise / iconize / shown / active | done | | +| Webview loadURL / reload / current URL | done | | +| Webview rebuild | done | | +| New window → external open | done | | +| Context menu disable | done | | +| Menubar from DOM | done | GtkPopoverMenuBar + GMenu | +| Tray / status item | done | In-memory tray for RPC; visual SNI/AppIndicator still TBD | +| Apple menu | n/a | successful no-op | +| Notifications | done | libnotify (falls back to stderr log) | +| Icons from path / PNG | done | | +| OS events (reopen, open url/file) | partial | `system.open_url` works; desktop-file reopen/open-file TBD | +| Locale / os_description | done | | +| Permission policy hybrid | done | | +| Microphone in webview | done | E2E via test RPC + fixture | +| Camera in webview | done | E2E via test RPC + fixture | +| Test RPC channel | done | `--edw-test-rpc` | | Release artifact download | todo | | -| CI build | todo | | +| CI build | done | ubuntu-latest + xvfb | diff --git a/lib/desktop_webview/launcher.ex b/lib/desktop_webview/launcher.ex index e73ff7a..c5a8551 100644 --- a/lib/desktop_webview/launcher.ex +++ b/lib/desktop_webview/launcher.ex @@ -40,7 +40,10 @@ defmodule DesktopWebview.Launcher do case await_listening(port, Keyword.get(opts, :timeout, 10_000)) do {:ok, listen_port} -> - {:ok, %{port: port, listen_port: listen_port, binary: binary}} + # Keep draining host stdout/stderr so WebKit logs cannot fill the pipe. + drain_pid = spawn_link(fn -> drain_port(port) end) + true = Port.connect(port, drain_pid) + {:ok, %{port: port, listen_port: listen_port, binary: binary, drain_pid: drain_pid}} {:error, reason} -> Port.close(port) @@ -49,7 +52,12 @@ defmodule DesktopWebview.Launcher do end end - def stop(%{port: port}) when is_port(port) do + def stop(%{port: port} = launcher) when is_port(port) do + if pid = Map.get(launcher, :drain_pid) do + Process.unlink(pid) + Process.exit(pid, :kill) + end + try do case Port.info(port) do nil -> :ok @@ -101,4 +109,11 @@ defmodule DesktopWebview.Launcher do end end end + + defp drain_port(port) do + receive do + {^port, {:data, _}} -> drain_port(port) + {^port, {:exit_status, _}} -> :ok + end + end end diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..6be7bfb --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +erlang = "26" +elixir = "1.18.4-otp-26" diff --git a/native/linux/CMakeLists.txt b/native/linux/CMakeLists.txt new file mode 100644 index 0000000..448804a --- /dev/null +++ b/native/linux/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.16) +project(DesktopWebView LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(DEPS REQUIRED IMPORTED_TARGET + gtk4 + webkitgtk-6.0 + libnotify + json-glib-1.0 +) + +add_executable(DesktopWebView + src/main.cpp + src/config.cpp + src/json_util.cpp + src/rpc_server.cpp + src/web_window.cpp + src/host_controller.cpp +) + +target_include_directories(DesktopWebView PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) +target_link_libraries(DesktopWebView PRIVATE PkgConfig::DEPS) + +install(TARGETS DesktopWebView RUNTIME DESTINATION bin) diff --git a/native/linux/README.md b/native/linux/README.md index d368548..aa7a93d 100644 --- a/native/linux/README.md +++ b/native/linux/README.md @@ -1,19 +1,63 @@ # Linux host (WebKitGTK) -Not implemented yet. This directory will hold the WebKitGTK host. +C++ + **GTK 4** + **WebKitGTK 6** executable `DesktopWebView`. -## Specs +## Distro packages (Ubuntu 24.04 / noble) + +Build: + +```text +build-essential cmake pkg-config +libgtk-4-dev +libwebkitgtk-6.0-dev +libnotify-dev +libjson-glib-dev +``` + +Runtime (same SONAMEs): `libgtk-4-1`, `libwebkitgtk-6.0-4`, `libnotify4`, +`libjson-glib-1.0-0`. + +Baseline verified in CI: GTK **4.14**, WebKitGTK **2.52** (`webkitgtk-6.0`). + +Tray: AppIndicator libraries are GTK 3 and are not linked into this GTK 4 process. +`tray.*` RPC succeeds with an in-memory tray; StatusNotifierItem integration is +tracked as partial in [status/linux.md](../../docs/status/linux.md). + +## Build + +```bash +# from repo root +./scripts/build_linux.sh -- [Porting guide](../../docs/porting.md) — checklist, toolchain, E2E gate -- [Protocol](../../docs/protocol.md) — JSON-RPC + behavioral semantics -- [Packaging](../../docs/packaging.md) — Linux layout and release artifact names -- [Status](../../docs/status/linux.md) — feature matrix +# or +cmake -S native/linux -B native/linux/build -DCMAKE_BUILD_TYPE=Release +cmake --build native/linux/build +``` -## Contract (summary) +Output is copied to `priv/native/linux/DesktopWebView` for local/CI use. +Release artifact name: `DesktopWebView-linux-x86_64` (see [packaging.md](../../docs/packaging.md)). + +## Run (dev / E2E) + +```bash +./priv/native/linux/DesktopWebView --edw-no-beam --edw-test-rpc --edw-port=0 +# prints: listening +``` + +Headless CI needs a display (`xvfb-run` or a pre-set `DISPLAY`). + +## Structure + +| Path | Role | +|------|------| +| `CMakeLists.txt` | CMake project | +| `src/` | Host sources (config, RPC, windows, dispatch) | + +Do not add a native unit-test suite; exercise the binary from Elixir E2E (`mix test.e2e`). + +## Specs -- Native process listens on TCP; Elixir connects -- JSON-RPC 2.0 over 4-byte length-prefixed frames -- `--edw-*` host flags; remaining argv forwarded to BEAM -- Binary: `DesktopWebView-linux-x86_64` via GitHub Releases (not Hex `priv/`) -- `menu.set_apple` → successful no-op -- Document GTK / WebKitGTK package versions here when the host lands +- [Porting guide](../../docs/porting.md) +- [Protocol](../../docs/protocol.md) +- [Packaging](../../docs/packaging.md) +- [Status](../../docs/status/linux.md) diff --git a/native/linux/src/config.cpp b/native/linux/src/config.cpp new file mode 100644 index 0000000..665867e --- /dev/null +++ b/native/linux/src/config.cpp @@ -0,0 +1,175 @@ +#include "config.hpp" + +#include +#include +#include +#include + +namespace { + +std::string dirname_of(const std::string& path) { + auto pos = path.find_last_of('/'); + if (pos == std::string::npos) return "."; + if (pos == 0) return "/"; + return path.substr(0, pos); +} + +std::string join_path(const std::string& a, const std::string& b) { + if (a.empty()) return b; + if (a.back() == '/') return a + b; + return a + "/" + b; +} + +bool file_exists(const std::string& path) { + return access(path.c_str(), R_OK) == 0; +} + +class Ini { + public: + static Ini parse(const std::string& text) { + Ini ini; + std::string current = "default"; + std::istringstream in(text); + std::string raw; + while (std::getline(in, raw)) { + std::string line = raw; + while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t')) + line.pop_back(); + size_t start = 0; + while (start < line.size() && (line[start] == ' ' || line[start] == '\t')) start++; + line = line.substr(start); + if (line.empty() || line[0] == '#' || line[0] == ';') continue; + if (line.front() == '[' && line.back() == ']') { + current = line.substr(1, line.size() - 2); + for (auto& c : current) c = static_cast(tolower(c)); + continue; + } + auto eq = line.find('='); + if (eq == std::string::npos) continue; + std::string key = line.substr(0, eq); + std::string val = line.substr(eq + 1); + auto trim = [](std::string& s) { + while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.erase(s.begin()); + while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.pop_back(); + }; + trim(key); + trim(val); + ini.sections_[current][key] = val; + } + return ini; + } + + std::optional get(const std::string& section, const std::string& key) const { + std::string sec = section; + for (auto& c : sec) c = static_cast(tolower(c)); + auto it = sections_.find(sec); + if (it == sections_.end()) return std::nullopt; + auto kit = it->second.find(key); + if (kit == it->second.end()) return std::nullopt; + return kit->second; + } + + std::map section(const std::string& name) const { + std::string sec = name; + for (auto& c : sec) c = static_cast(tolower(c)); + auto it = sections_.find(sec); + if (it == sections_.end()) return {}; + return it->second; + } + + private: + std::map> sections_; +}; + +} // namespace + +HostConfig HostConfig::parse(int argc, char** argv) { + HostConfig cfg; + bool passthrough = false; + for (int i = 1; i < argc; i++) { + std::string a = argv[i]; + if (passthrough || a == "--") { + if (a == "--") { + passthrough = true; + continue; + } + cfg.forwarded_argv.push_back(a); + continue; + } + if (a.rfind("--edw-", 0) == 0) { + std::string body = a.substr(6); + if (body == "no-beam") { + cfg.no_beam = true; + } else if (body == "test-rpc") { + cfg.test_rpc = true; + } else if (body.rfind("port=", 0) == 0) { + cfg.port = static_cast(std::stoi(body.substr(5))); + } else if (body.rfind("host=", 0) == 0) { + cfg.host = body.substr(5); + } else if (body.rfind("config=", 0) == 0) { + cfg.config_path = body.substr(7); + } else if (body.rfind("lifetime=", 0) == 0) { + auto v = body.substr(9); + cfg.lifetime = (v == "coupled") ? Lifetime::Coupled : Lifetime::Reconnect; + } else if (body.rfind("beam-path=", 0) == 0) { + cfg.beam_path = body.substr(10); + } else if (body.rfind("beam-app=", 0) == 0) { + cfg.beam_app = body.substr(9); + } else { + fprintf(stderr, "unknown --edw flag: %s\n", a.c_str()); + } + continue; + } + cfg.forwarded_argv.push_back(a); + } + cfg.apply_ini(); + if (cfg.no_beam) cfg.beam_enabled = false; + return cfg; +} + +std::string HostConfig::resources_root() const { + char buf[4096]; + ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (n > 0) { + buf[n] = '\0'; + return dirname_of(buf); + } + return "."; +} + +std::optional HostConfig::resolve_ini_path() const { + if (config_path) return *config_path; + auto beside = join_path(resources_root(), "DesktopWebView.ini"); + if (file_exists(beside)) return beside; + return std::nullopt; +} + +void HostConfig::apply_ini() { + auto path = resolve_ini_path(); + if (!path) return; + std::ifstream in(*path); + if (!in) return; + std::ostringstream ss; + ss << in.rdbuf(); + auto ini = Ini::parse(ss.str()); + if (auto v = ini.get("network", "host")) host = *v; + if (auto v = ini.get("network", "port")) port = static_cast(std::stoi(*v)); + if (auto v = ini.get("lifetime", "mode")) { + lifetime = (*v == "coupled") ? Lifetime::Coupled : Lifetime::Reconnect; + } + if (auto v = ini.get("beam", "enabled")) { + beam_enabled = !(*v == "false" || *v == "0"); + } + if (auto v = ini.get("beam", "path")) beam_path = *v; + if (auto v = ini.get("beam", "app_name")) beam_app = *v; + if (auto v = ini.get("beam", "args")) { + beam_args.clear(); + std::istringstream args(*v); + std::string tok; + while (args >> tok) beam_args.push_back(tok); + } + if (auto v = ini.get("beam", "working_dir")) beam_working_dir = *v; + for (auto& [k, v] : ini.section("env")) { + extra_env[k] = v; + } +} diff --git a/native/linux/src/config.hpp b/native/linux/src/config.hpp new file mode 100644 index 0000000..52101ef --- /dev/null +++ b/native/linux/src/config.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include +#include + +enum class Lifetime { Reconnect, Coupled }; + +struct HostConfig { + bool no_beam = false; + uint16_t port = 0; + std::string host = "127.0.0.1"; + std::optional config_path; + Lifetime lifetime = Lifetime::Reconnect; + bool test_rpc = false; + std::optional beam_path; + std::optional beam_app; + std::vector beam_args{"start"}; + std::optional beam_working_dir; + bool beam_enabled = true; + std::map extra_env; + std::vector forwarded_argv; + + static HostConfig parse(int argc, char** argv); + + std::string resources_root() const; + std::optional resolve_ini_path() const; + + private: + void apply_ini(); +}; diff --git a/native/linux/src/host_controller.cpp b/native/linux/src/host_controller.cpp new file mode 100644 index 0000000..5f55917 --- /dev/null +++ b/native/linux/src/host_controller.cpp @@ -0,0 +1,896 @@ +#include "host_controller.hpp" + +#include "json_util.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +std::string join_path(const std::string& a, const std::string& b) { + if (a.empty()) return b; + if (a.back() == '/') return a + b; + return a + "/" + b; +} + +bool is_absolute(const std::string& p) { return !p.empty() && p[0] == '/'; } + +std::vector base64_decode(const std::string& in) { + gsize len = 0; + guchar* data = g_base64_decode(in.c_str(), &len); + std::vector out; + if (data) { + out.assign(data, data + len); + g_free(data); + } + return out; +} + +JsonObject* params_obj(JsonNode* params) { + if (!params) return nullptr; + return jsonutil::as_object(params); +} + +} // namespace + +HostController::HostController(HostConfig config) : config_(std::move(config)) {} + +HostController::~HostController() { + if (beam_pid_ > 0) { + kill(beam_pid_, SIGTERM); + beam_pid_ = 0; + } + for (auto& [_, icon] : icons_) { + if (icon.texture) g_object_unref(icon.texture); + } + windows_.clear(); +} + +bool HostController::start() { + notify_init("DesktopWebView"); + + // WebKitGTK sandboxes the network/web process. Paths must be allowlisted + // BEFORE any subprocess is spawned (creating/loading a WebView). Broad + // roots like "/" are rejected by WebKit; allow the process cwd and the + // host binary directory (packaged resources / local fixtures). + { + WebKitWebContext* ctx = webkit_web_context_get_default(); + gchar* cwd = g_get_current_dir(); + if (cwd) { + webkit_web_context_add_path_to_sandbox(ctx, cwd, TRUE); + g_free(cwd); + } + auto root = config_.resources_root(); + if (!root.empty()) webkit_web_context_add_path_to_sandbox(ctx, root.c_str(), TRUE); + } + + server_.set_request_handler([this](JsonNode* id, const std::string& method, JsonNode* params, + RpcServer::ReplyFn reply) { + // Marshal onto GTK main thread + struct Ctx { + HostController* self; + JsonNode* id; + std::string method; + JsonNode* params; + RpcServer::ReplyFn reply; + }; + auto* ctx = new Ctx{this, id, method, params, std::move(reply)}; + g_idle_add( + +[](gpointer data) -> gboolean { + auto* ctx = static_cast(data); + ctx->self->handle_request(ctx->id, ctx->method, ctx->params, ctx->reply); + // id/params ownership transferred into handle_request free paths + delete ctx; + return G_SOURCE_REMOVE; + }, + ctx); + }); + + server_.set_disconnect_handler([this]() { + g_idle_add( + +[](gpointer data) -> gboolean { + static_cast(data)->client_disconnected(); + return G_SOURCE_REMOVE; + }, + this); + }); + + if (!server_.start(config_.host, config_.port)) return false; + + if (config_.beam_enabled && !config_.no_beam) { + spawn_beam(); + } + return true; +} + +void HostController::client_disconnected() { + if (config_.lifetime == Lifetime::Coupled) { + if (beam_pid_ > 0) { + kill(beam_pid_, SIGTERM); + beam_pid_ = 0; + } + exit(0); + } + initialized_ = false; +} + +std::string HostController::next_id(const std::string& prefix) { + id_counter_++; + return prefix + std::to_string(id_counter_); +} + +void HostController::spawn_beam() { + auto root = config_.resources_root(); + std::string beam_dir = + config_.beam_path + ? (is_absolute(*config_.beam_path) ? *config_.beam_path : join_path(root, *config_.beam_path)) + : join_path(root, "beam"); + std::string app_name = config_.beam_app.value_or(""); + if (app_name.empty()) { + // first script in bin/ + GDir* dir = g_dir_open(join_path(beam_dir, "bin").c_str(), 0, nullptr); + if (dir) { + const gchar* name; + while ((name = g_dir_read_name(dir))) { + if (name[0] == '.') continue; + app_name = name; + break; + } + g_dir_close(dir); + } + } + if (app_name.empty()) { + fprintf(stderr, "edw: no beam app_name and no bin script found in %s\n", beam_dir.c_str()); + return; + } + std::string script = join_path(join_path(beam_dir, "bin"), app_name); + std::string wd = + config_.beam_working_dir + ? (is_absolute(*config_.beam_working_dir) ? *config_.beam_working_dir + : join_path(root, *config_.beam_working_dir)) + : beam_dir; + + std::vector argv_store; + argv_store.push_back(script); + for (auto& a : config_.beam_args) argv_store.push_back(a); + for (auto& a : config_.forwarded_argv) argv_store.push_back(a); + std::vector argv; + for (auto& s : argv_store) argv.push_back(s.data()); + argv.push_back(nullptr); + + std::vector env_store; + for (char** e = environ; e && *e; ++e) env_store.emplace_back(*e); + env_store.push_back("EDW_PORT=" + std::to_string(server_.port())); + env_store.push_back("EDW_HOST=" + config_.host); + for (auto& [k, v] : config_.extra_env) env_store.push_back(k + "=" + v); + std::vector envp; + for (auto& s : env_store) envp.push_back(s.data()); + envp.push_back(nullptr); + + GError* err = nullptr; + if (!g_spawn_async(wd.c_str(), argv.data(), envp.data(), G_SPAWN_DEFAULT, nullptr, nullptr, + &beam_pid_, &err)) { + fprintf(stderr, "edw: failed to spawn beam: %s\n", err ? err->message : "unknown"); + if (err) g_error_free(err); + beam_pid_ = 0; + } +} + +void HostController::handle_request(JsonNode* id, const std::string& method, JsonNode* params, + RpcServer::ReplyFn reply) { + auto free_params = [&]() { + if (params) json_node_free(params); + if (id) json_node_free(id); + }; + + if (method.rfind("test.", 0) == 0) { + if (!config_.test_rpc) { + reply(jsonutil::rpc_error(id, -32003, "Test RPC disabled")); + free_params(); + return; + } + bool async = false; + JsonNode* result = handle_test(method, params, id, &async); + if (!async) { + if (result) { + // handle_test returns full response for sync + reply(result); + } + free_params(); + } else { + // async path copied id; free originals + free_params(); + } + return; + } + + if (method != "initialize" && !initialized_) { + reply(jsonutil::rpc_error(id, -32001, "Not initialized")); + free_params(); + return; + } + + JsonNode* out = nullptr; + int err_code = 0; + std::string err_msg; + if (!dispatch_ok(method, params, &out, &err_code, &err_msg)) { + reply(jsonutil::rpc_error(id, err_code, err_msg)); + } else { + reply(jsonutil::rpc_ok(id, out)); + } + free_params(); +} + +bool HostController::dispatch_ok(const std::string& method, JsonNode* params, JsonNode** out_result, + int* err_code, std::string* err_msg) { + try { + *out_result = dispatch(method, params); + return true; + } catch (const HostError& e) { + *err_code = e.code; + *err_msg = e.message; + return false; + } catch (const std::exception& e) { + *err_code = -32603; + *err_msg = e.what(); + return false; + } +} + +WebWindow* HostController::require_window(JsonObject* params) { + auto id = jsonutil::object_get_string(params, "window_id"); + if (!id) throw HostError{-32002, "unknown window"}; + auto it = windows_.find(*id); + if (it == windows_.end()) throw HostError{-32002, "unknown window"}; + return it->second.get(); +} + +WebWindow* HostController::require_web(JsonObject* params) { + auto id = jsonutil::object_get_string(params, "webview_id"); + if (id) { + auto wit = webviews_.find(*id); + if (wit == webviews_.end()) throw HostError{-32002, "unknown webview"}; + auto it = windows_.find(wit->second); + if (it == windows_.end()) throw HostError{-32002, "unknown webview"}; + return it->second.get(); + } + if (jsonutil::object_get_string(params, "window_id")) return require_window(params); + throw HostError{-32002, "unknown webview"}; +} + +void HostController::wire_window(WebWindow* w) { + w->set_handlers( + [this](const std::string& window_id) { + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "window_id", window_id.c_str()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + server_.notify("event.window.close_requested", n); + }, + [this](const std::string& window_id, bool focused) { + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "window_id", window_id.c_str()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + server_.notify(focused ? "event.window.focus" : "event.window.blur", n); + }, + [this](const std::string& webview_id, const std::string& url) { + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "webview_id", webview_id.c_str()); + json_object_set_string_member(o, "url", url.c_str()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + server_.notify("event.webview.new_window", n); + open_external(url); + }, + [this](const std::string& webview_id, const std::string& url, bool error, + const std::string& message) { + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "webview_id", webview_id.c_str()); + if (error) { + json_object_set_string_member(o, "message", message.c_str()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + server_.notify("event.webview.error", n); + } else { + json_object_set_string_member(o, "url", url.c_str()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + server_.notify("event.webview.finished", n); + } + }, + [this](const std::string& origin, const std::string& type, const std::string& webview_id, + WebKitPermissionRequest* request) { + handle_permission(origin, type, webview_id, request); + }); +} + +void HostController::open_external(const std::string& url) { + g_app_info_launch_default_for_uri(url.c_str(), nullptr, nullptr); +} + +void HostController::handle_permission(const std::string& origin, const std::string& type, + const std::string& webview_id, + WebKitPermissionRequest* request) { + auto policy = permission_policy_[origin][type]; + if (policy.empty()) policy = "ask"; + if (policy == "allow") { + webkit_permission_request_allow(request); + g_object_unref(request); + return; + } + if (policy == "deny") { + webkit_permission_request_deny(request); + g_object_unref(request); + return; + } + + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "origin", origin.c_str()); + json_object_set_string_member(o, "type", type.c_str()); + json_object_set_string_member(o, "webview_id", webview_id.c_str()); + JsonNode* params = json_node_alloc(); + json_node_init_object(params, o); + json_object_unref(o); + + server_.request("permission.request", params, [request](JsonNode* result) { + std::string decision = "ask"; + if (auto* obj = jsonutil::as_object(result)) { + decision = jsonutil::object_get_string(obj, "decision").value_or("ask"); + } + if (decision == "allow") { + webkit_permission_request_allow(request); + } else if (decision == "deny") { + webkit_permission_request_deny(request); + } else { + // ask → leave to engine; allow triggers OS/portal prompts when applicable + webkit_permission_request_allow(request); + } + g_object_unref(request); + }); +} + +JsonNode* HostController::window_open(JsonObject* params) { + auto title = jsonutil::object_get_string(params, "title").value_or("Desktop"); + int width = static_cast(jsonutil::object_get_int(params, "width").value_or(800)); + int height = static_cast(jsonutil::object_get_int(params, "height").value_or(600)); + auto window_id = next_id("w"); + auto webview_id = next_id("v"); + auto win = std::make_unique(window_id, webview_id, title, width, height); + wire_window(win.get()); + if (auto mw = jsonutil::object_get_int(params, "min_width")) { + auto mh = jsonutil::object_get_int(params, "min_height").value_or(0); + win->set_min_size(static_cast(*mw), static_cast(mh)); + } + win->show(); + webviews_[webview_id] = window_id; + windows_[window_id] = std::move(win); + + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "window_id", window_id.c_str()); + json_object_set_string_member(o, "webview_id", webview_id.c_str()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + return n; +} + +JsonNode* HostController::window_close(JsonObject* params) { + auto* w = require_window(params); + auto wid = w->window_id(); + for (auto it = webviews_.begin(); it != webviews_.end();) { + if (it->second == wid) + it = webviews_.erase(it); + else + ++it; + } + windows_.erase(wid); + return jsonutil::bool_node(true); +} + +void HostController::append_menu_node(GMenu* menu, JsonNode* node, MenuEntry& entry, + const std::string& menu_id) { + JsonObject* obj = jsonutil::as_object(node); + if (!obj) return; + auto tag = jsonutil::object_get_string(obj, "tag").value_or(""); + if (tag == "hr") { + g_menu_append_section(menu, nullptr, G_MENU_MODEL(g_menu_new())); + return; + } + JsonObject* attrs = nullptr; + if (auto* a = jsonutil::object_get(obj, "attrs")) attrs = jsonutil::as_object(a); + + auto label_from_children = [&]() -> std::string { + if (auto* kids = jsonutil::as_array(jsonutil::object_get(obj, "children"))) { + if (json_array_get_length(kids) > 0) { + if (auto s = jsonutil::as_string(json_array_get_element(kids, 0))) return *s; + } + } + if (attrs) return jsonutil::object_get_string(attrs, "label").value_or(""); + return ""; + }; + + if (tag == "menu") { + std::string label = + attrs ? jsonutil::object_get_string(attrs, "label").value_or(label_from_children()) + : label_from_children(); + GMenu* sub = g_menu_new(); + if (auto* kids = jsonutil::as_array(jsonutil::object_get(obj, "children"))) { + guint n = json_array_get_length(kids); + for (guint i = 0; i < n; i++) { + append_menu_node(sub, json_array_get_element(kids, i), entry, menu_id); + } + } + g_menu_append_submenu(menu, label.c_str(), G_MENU_MODEL(sub)); + g_object_unref(sub); + return; + } + + if (tag == "item") { + std::string label = label_from_children(); + std::string onclick = + attrs ? jsonutil::object_get_string(attrs, "onclick").value_or("") : ""; + std::string action = "item" + std::to_string(entry.onclicks.size() + 1); + std::string detailed = entry.action_group_name + "." + action; + entry.onclicks[action] = onclick; + auto* simple = g_simple_action_new(action.c_str(), nullptr); + g_signal_connect( + simple, "activate", + G_CALLBACK(+[](GSimpleAction* act, GVariant*, gpointer user) { + auto* self = static_cast(user); + const gchar* name = g_action_get_name(G_ACTION(act)); + // Find menu entry containing this action — scan menus + for (auto& [mid, ment] : self->menus_) { + auto it = ment.onclicks.find(name); + if (it == ment.onclicks.end()) continue; + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "menu_id", mid.c_str()); + json_object_set_string_member(o, "onclick", it->second.c_str()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + self->server_.notify("event.menu.click", n); + break; + } + }), + this); + g_action_map_add_action(G_ACTION_MAP(entry.action_group), G_ACTION(simple)); + g_object_unref(simple); + g_menu_append(menu, label.c_str(), detailed.c_str()); + } +} + +void HostController::build_menu_into(GMenu* menu, JsonNode* dom, MenuEntry& entry, + const std::string& menu_id) { + if (!dom) return; + JsonObject* root = jsonutil::as_object(dom); + JsonArray* children = nullptr; + if (root) { + auto tag = jsonutil::object_get_string(root, "tag").value_or(""); + if (tag == "menubar" || tag == "menu") { + children = jsonutil::as_array(jsonutil::object_get(root, "children")); + } + } + if (!children) children = jsonutil::as_array(dom); + if (!children) return; + guint n = json_array_get_length(children); + for (guint i = 0; i < n; i++) { + append_menu_node(menu, json_array_get_element(children, i), entry, menu_id); + } +} + +JsonNode* HostController::menu_create(JsonObject* params) { + auto id = next_id("m"); + MenuEntry entry; + entry.menu = g_menu_new(); + entry.action_group_name = "edw" + id; + entry.action_group = g_simple_action_group_new(); + build_menu_into(entry.menu, jsonutil::object_get(params, "dom"), entry, id); + // Insert action group on default display / app — attach to each window when set + menus_[id] = std::move(entry); + + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "menu_id", id.c_str()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + return n; +} + +JsonNode* HostController::menu_update(JsonObject* params) { + auto id = jsonutil::object_get_string(params, "menu_id"); + if (!id) throw HostError{-32602, "menu_id"}; + auto it = menus_.find(*id); + if (it == menus_.end()) throw HostError{-32002, "unknown menu"}; + if (it->second.menu) g_object_unref(it->second.menu); + if (it->second.action_group) g_object_unref(it->second.action_group); + it->second.menu = g_menu_new(); + it->second.action_group = g_simple_action_group_new(); + it->second.onclicks.clear(); + it->second.action_group_name = "edw" + *id; + build_menu_into(it->second.menu, jsonutil::object_get(params, "dom"), it->second, *id); + return jsonutil::bool_node(true); +} + +JsonNode* HostController::tray_create(JsonObject* params) { + auto id = next_id("tray"); + TrayEntry t; + t.icon_id = jsonutil::object_get_string(params, "icon_id").value_or(""); + t.menu_id = jsonutil::object_get_string(params, "menu_id").value_or(""); + trays_[id] = std::move(t); + // StatusNotifierItem / AppIndicator is GTK3-linked; keep in-memory tray for protocol + // conformance and document fallback in README. + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "tray_id", id.c_str()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + return n; +} + +JsonNode* HostController::icon_create(JsonObject* params) { + auto id = next_id("icon"); + IconEntry icon; + if (auto path = jsonutil::object_get_string(params, "path")) { + icon.path = *path; + GError* err = nullptr; + icon.texture = gdk_texture_new_from_filename(path->c_str(), &err); + if (err) { + g_error_free(err); + icon.texture = nullptr; + } + } else if (auto b64 = jsonutil::object_get_string(params, "png_base64")) { + icon.png = base64_decode(*b64); + if (!icon.png.empty()) { + GBytes* bytes = g_bytes_new(icon.png.data(), icon.png.size()); + GError* err = nullptr; + icon.texture = gdk_texture_new_from_bytes(bytes, &err); + g_bytes_unref(bytes); + if (err) { + g_error_free(err); + icon.texture = nullptr; + } + } + } + icons_[id] = std::move(icon); + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "icon_id", id.c_str()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + return n; +} + +JsonNode* HostController::notification_show(JsonObject* params) { + auto id = jsonutil::object_get_string(params, "id").value_or(next_id("n")); + auto title = jsonutil::object_get_string(params, "title").value_or(""); + auto message = jsonutil::object_get_string(params, "message").value_or(""); + NotifyNotification* n = + notify_notification_new(title.c_str(), message.c_str(), nullptr); + GError* err = nullptr; + if (!notify_notification_show(n, &err)) { + fprintf(stderr, "notification: %s: %s\n", title.c_str(), message.c_str()); + if (err) g_error_free(err); + } + g_object_unref(n); + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "notification_id", id.c_str()); + JsonNode* node = json_node_alloc(); + json_node_init_object(node, o); + json_object_unref(o); + return node; +} + +JsonNode* HostController::dispatch(const std::string& method, JsonNode* params) { + JsonObject* p = params_obj(params); + + if (method == "initialize") { + initialized_ = true; + JsonObject* caps = jsonutil::object_new(); + json_object_set_boolean_member(caps, "window", TRUE); + json_object_set_boolean_member(caps, "webview", TRUE); + json_object_set_boolean_member(caps, "menu", TRUE); + json_object_set_boolean_member(caps, "tray", TRUE); + json_object_set_boolean_member(caps, "notification", TRUE); + json_object_set_boolean_member(caps, "permission", TRUE); + json_object_set_boolean_member(caps, "media", TRUE); + json_object_set_boolean_member(caps, "test_rpc", config_.test_rpc); + + JsonObject* o = jsonutil::object_new(); + json_object_set_int_member(o, "protocol_version", 1); + json_object_set_string_member(o, "platform", "linux"); + JsonNode* caps_node = json_node_alloc(); + json_node_init_object(caps_node, caps); + json_object_unref(caps); + json_object_set_member(o, "capabilities", caps_node); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + return n; + } + + if (method == "window.open") return window_open(p); + if (method == "window.close" || method == "window.destroy") return window_close(p); + if (method == "window.show") { + auto* w = require_window(p); + bool show = jsonutil::object_get_bool(p, "show").value_or(true); + if (show) + w->show(); + else + w->hide(); + return jsonutil::bool_node(true); + } + if (method == "window.hide") { + require_window(p)->hide(); + return jsonutil::bool_node(true); + } + if (method == "window.set_title") { + auto* w = require_window(p); + w->set_title(jsonutil::object_get_string(p, "title").value_or("")); + return jsonutil::bool_node(true); + } + if (method == "window.set_min_size") { + auto* w = require_window(p); + w->set_min_size(static_cast(jsonutil::object_get_int(p, "width").value_or(0)), + static_cast(jsonutil::object_get_int(p, "height").value_or(0))); + return jsonutil::bool_node(true); + } + if (method == "window.set_icon") { + auto* w = require_window(p); + if (auto icon_id = jsonutil::object_get_string(p, "icon_id")) { + auto it = icons_.find(*icon_id); + if (it != icons_.end()) w->set_icon(it->second.texture); + } + return jsonutil::bool_node(true); + } + if (method == "window.set_menubar") { + auto* w = require_window(p); + if (auto menu_id = jsonutil::object_get_string(p, "menu_id")) { + auto it = menus_.find(*menu_id); + if (it != menus_.end()) { + gtk_widget_insert_action_group(GTK_WIDGET(w->window()), it->second.action_group_name.c_str(), + G_ACTION_GROUP(it->second.action_group)); + w->set_menubar(G_MENU_MODEL(it->second.menu)); + } + } + return jsonutil::bool_node(true); + } + if (method == "window.iconize") { + require_window(p)->iconize(jsonutil::object_get_bool(p, "iconize").value_or(false)); + return jsonutil::bool_node(true); + } + if (method == "window.shown") return jsonutil::bool_node(require_window(p)->shown()); + if (method == "window.active") return jsonutil::bool_node(require_window(p)->active()); + if (method == "window.raise") { + require_window(p)->raise(); + return jsonutil::bool_node(true); + } + if (method == "window.close_veto") { + require_window(p); + return jsonutil::bool_node(true); + } + + if (method == "webview.load_url") { + auto* w = require_web(p); + auto url = jsonutil::object_get_string(p, "url"); + if (!url) throw HostError{-32602, "url required"}; + w->load_url(*url); + return jsonutil::bool_node(true); + } + if (method == "webview.reload") { + require_web(p)->reload(); + return jsonutil::bool_node(true); + } + if (method == "webview.current_url") { + return jsonutil::string_node(require_web(p)->current_url()); + } + if (method == "webview.rebuild") { + auto* w = require_window(p); + auto new_id = next_id("v"); + for (auto it = webviews_.begin(); it != webviews_.end();) { + if (it->second == w->window_id()) + it = webviews_.erase(it); + else + ++it; + } + auto vid = w->rebuild(new_id); + webviews_[vid] = w->window_id(); + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "webview_id", vid.c_str()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + return n; + } + if (method == "webview.set_context_menu") { + require_web(p)->set_context_menu_enabled( + jsonutil::object_get_bool(p, "enabled").value_or(false)); + return jsonutil::bool_node(true); + } + + if (method == "menu.create") return menu_create(p); + if (method == "menu.update") return menu_update(p); + if (method == "menu.destroy") { + if (auto id = jsonutil::object_get_string(p, "menu_id")) { + auto it = menus_.find(*id); + if (it != menus_.end()) { + if (it->second.menu) g_object_unref(it->second.menu); + if (it->second.action_group) g_object_unref(it->second.action_group); + menus_.erase(it); + } + } + return jsonutil::bool_node(true); + } + if (method == "menu.set_apple") { + // n/a on Linux — successful no-op + return jsonutil::bool_node(true); + } + + if (method == "tray.create") return tray_create(p); + if (method == "tray.set_icon") { + auto id = jsonutil::object_get_string(p, "tray_id"); + if (!id || trays_.find(*id) == trays_.end()) return jsonutil::bool_node(false); + trays_[*id].icon_id = jsonutil::object_get_string(p, "icon_id").value_or(""); + return jsonutil::bool_node(true); + } + if (method == "tray.set_menu") { + auto id = jsonutil::object_get_string(p, "tray_id"); + if (!id || trays_.find(*id) == trays_.end()) return jsonutil::bool_node(false); + trays_[*id].menu_id = jsonutil::object_get_string(p, "menu_id").value_or(""); + return jsonutil::bool_node(true); + } + if (method == "tray.destroy") { + if (auto id = jsonutil::object_get_string(p, "tray_id")) trays_.erase(*id); + return jsonutil::bool_node(true); + } + + if (method == "notification.show") return notification_show(p); + if (method == "notification.close") { + // libnotify has no stable close-by-id without keeping handles; no-op success + return jsonutil::bool_node(true); + } + + if (method == "icon.create") return icon_create(p); + if (method == "icon.destroy") { + if (auto id = jsonutil::object_get_string(p, "icon_id")) { + auto it = icons_.find(*id); + if (it != icons_.end()) { + if (it->second.texture) g_object_unref(it->second.texture); + icons_.erase(it); + } + } + return jsonutil::bool_node(true); + } + + if (method == "system.open_url") { + if (auto url = jsonutil::object_get_string(p, "url")) open_external(*url); + return jsonutil::bool_node(true); + } + if (method == "system.locale") { + const gchar* lang = g_getenv("LANG"); + if (!lang || !*lang) lang = setlocale(LC_ALL, nullptr); + std::string loc = lang ? lang : "en_US"; + // normalize to lowercase identifier-ish + for (auto& c : loc) c = static_cast(tolower(c)); + auto dot = loc.find('.'); + if (dot != std::string::npos) loc = loc.substr(0, dot); + return jsonutil::string_node(loc); + } + if (method == "system.os_description") { + struct utsname u {}; + uname(&u); + std::string desc = std::string("Linux ") + u.release; + return jsonutil::string_node(desc); + } + if (method == "system.set_permission_policy") { + auto origin = jsonutil::object_get_string(p, "origin"); + if (!origin) throw HostError{-32602, "origin"}; + auto& map = permission_policy_[*origin]; + if (auto c = jsonutil::object_get_string(p, "camera")) map["camera"] = *c; + if (auto m = jsonutil::object_get_string(p, "microphone")) map["microphone"] = *m; + return jsonutil::bool_node(true); + } + + throw HostError{-32601, "Method not found: " + method}; +} + +JsonNode* HostController::handle_test(const std::string& method, JsonNode* params, JsonNode* id, + bool* async_reply) { + *async_reply = false; + if (method == "test.ping") { + return jsonutil::rpc_ok(id, jsonutil::string_node("pong")); + } + if (method == "test.echo") { + return jsonutil::rpc_ok(id, params ? json_node_copy(params) : jsonutil::null_node()); + } + if (method == "test.capabilities") { + JsonObject* caps = jsonutil::object_new(); + json_object_set_boolean_member(caps, "window", TRUE); + json_object_set_boolean_member(caps, "webview", TRUE); + json_object_set_boolean_member(caps, "menu", TRUE); + json_object_set_boolean_member(caps, "tray", TRUE); + json_object_set_boolean_member(caps, "notification", TRUE); + json_object_set_boolean_member(caps, "permission", TRUE); + json_object_set_boolean_member(caps, "media", TRUE); + json_object_set_boolean_member(caps, "test_rpc", TRUE); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, caps); + json_object_unref(caps); + return jsonutil::rpc_ok(id, n); + } + if (method == "test.window.list") { + std::vector items; + for (auto& [_, w] : windows_) { + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "window_id", w->window_id().c_str()); + json_object_set_string_member(o, "webview_id", w->webview_id().c_str()); + const gchar* title = gtk_window_get_title(w->window()); + json_object_set_string_member(o, "title", title ? title : ""); + json_object_set_string_member(o, "url", w->current_url().c_str()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + items.push_back(n); + } + return jsonutil::rpc_ok(id, jsonutil::array_node(items)); + } + if (method == "test.webview.eval") { + JsonObject* p = params_obj(params); + auto wv = jsonutil::object_get_string(p, "webview_id"); + auto script = jsonutil::object_get_string(p, "script"); + if (!wv || !script) return jsonutil::rpc_error(id, -32002, "unknown webview"); + auto wit = webviews_.find(*wv); + if (wit == webviews_.end()) return jsonutil::rpc_error(id, -32002, "unknown webview"); + auto it = windows_.find(wit->second); + if (it == windows_.end()) return jsonutil::rpc_error(id, -32002, "unknown webview"); + *async_reply = true; + JsonNode* id_keep = json_node_copy(id); + it->second->eval_js(*script, [this, id_keep](JsonNode* result) { + server_.send_node(jsonutil::rpc_ok(id_keep, result)); + json_node_free(id_keep); + }); + return nullptr; + } + if (method == "test.permission.simulate") { + JsonObject* p = params_obj(params); + auto origin = jsonutil::object_get_string(p, "origin").value_or("http://127.0.0.1"); + auto type = jsonutil::object_get_string(p, "type").value_or("microphone"); + std::string wv; + if (auto v = jsonutil::object_get_string(p, "webview_id")) + wv = *v; + else if (!windows_.empty()) + wv = windows_.begin()->second->webview_id(); + + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "origin", origin.c_str()); + json_object_set_string_member(o, "type", type.c_str()); + json_object_set_string_member(o, "webview_id", wv.c_str()); + JsonNode* req_params = json_node_alloc(); + json_node_init_object(req_params, o); + json_object_unref(o); + server_.request("permission.request", req_params, [](JsonNode*) {}); + return jsonutil::rpc_ok(id, jsonutil::bool_node(true)); + } + if (method == "test.disconnect") { + server_.close_connection(); + return jsonutil::rpc_ok(id, jsonutil::bool_node(true)); + } + if (method == "test.crash") { + _exit(2); + } + return jsonutil::rpc_error(id, -32601, "Unknown test method"); +} diff --git a/native/linux/src/host_controller.hpp b/native/linux/src/host_controller.hpp new file mode 100644 index 0000000..17a09cb --- /dev/null +++ b/native/linux/src/host_controller.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include "config.hpp" +#include "rpc_server.hpp" +#include "web_window.hpp" + +#include +#include + +#include +#include +#include +#include + +struct HostError { + int code; + std::string message; +}; + +struct MenuEntry { + GMenu* menu = nullptr; + // action name -> onclick string + std::map onclicks; + std::string action_group_name; + GSimpleActionGroup* action_group = nullptr; +}; + +struct TrayEntry { + std::string icon_id; + std::string menu_id; +}; + +struct IconEntry { + std::string path; + std::vector png; + GdkTexture* texture = nullptr; +}; + +class HostController { + public: + explicit HostController(HostConfig config); + ~HostController(); + + bool start(); + RpcServer& server() { return server_; } + + private: + void client_disconnected(); + void spawn_beam(); + std::string next_id(const std::string& prefix); + + void handle_request(JsonNode* id, const std::string& method, JsonNode* params, + RpcServer::ReplyFn reply); + JsonNode* dispatch(const std::string& method, JsonNode* params); // may throw HostError via optional + bool dispatch_ok(const std::string& method, JsonNode* params, JsonNode** out_result, + int* err_code, std::string* err_msg); + + JsonNode* handle_test(const std::string& method, JsonNode* params, JsonNode* id, + bool* async_reply); + + WebWindow* require_window(JsonObject* params); + WebWindow* require_web(JsonObject* params); + + JsonNode* window_open(JsonObject* params); + JsonNode* window_close(JsonObject* params); + JsonNode* menu_create(JsonObject* params); + JsonNode* menu_update(JsonObject* params); + void build_menu_into(GMenu* menu, JsonNode* dom, MenuEntry& entry, const std::string& menu_id); + void append_menu_node(GMenu* menu, JsonNode* node, MenuEntry& entry, const std::string& menu_id); + JsonNode* tray_create(JsonObject* params); + JsonNode* icon_create(JsonObject* params); + JsonNode* notification_show(JsonObject* params); + + void open_external(const std::string& url); + void handle_permission(const std::string& origin, const std::string& type, + const std::string& webview_id, WebKitPermissionRequest* request); + void wire_window(WebWindow* w); + + HostConfig config_; + RpcServer server_; + bool initialized_ = false; + int id_counter_ = 0; + std::map> windows_; + std::map webviews_; // webview_id -> window_id + std::map menus_; + std::map trays_; + std::map icons_; + std::map> permission_policy_; + GPid beam_pid_ = 0; + GtkApplication* app_ = nullptr; +}; diff --git a/native/linux/src/json_util.cpp b/native/linux/src/json_util.cpp new file mode 100644 index 0000000..ea501ca --- /dev/null +++ b/native/linux/src/json_util.cpp @@ -0,0 +1,215 @@ +#include "json_util.hpp" + +namespace jsonutil { +namespace { + +JsonNode* take_id_copy(JsonNode* id) { + if (!id) return null_node(); + return json_node_copy(id); +} + +JsonNode* empty_object_node() { + JsonObject* o = object_new(); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + return n; +} + +} // namespace + +JsonNode* parse(const std::string& text) { + JsonParser* parser = json_parser_new(); + GError* err = nullptr; + if (!json_parser_load_from_data(parser, text.c_str(), static_cast(text.size()), &err)) { + if (err) g_error_free(err); + g_object_unref(parser); + return nullptr; + } + JsonNode* root = json_parser_steal_root(parser); + g_object_unref(parser); + return root; +} + +std::string stringify(JsonNode* node) { + if (!node) return "null"; + JsonGenerator* gen = json_generator_new(); + json_generator_set_root(gen, node); + gchar* data = json_generator_to_data(gen, nullptr); + std::string out = data ? data : "null"; + g_free(data); + g_object_unref(gen); + return out; +} + +std::string stringify_object(JsonObject* obj) { + JsonNode* n = json_node_alloc(); + json_node_init_object(n, obj); + auto s = stringify(n); + json_node_free(n); + return s; +} + +JsonNode* null_node() { + JsonNode* n = json_node_alloc(); + json_node_init_null(n); + return n; +} + +JsonNode* bool_node(bool v) { + JsonNode* n = json_node_alloc(); + json_node_init_boolean(n, v); + return n; +} + +JsonNode* int_node(gint64 v) { + JsonNode* n = json_node_alloc(); + json_node_init_int(n, v); + return n; +} + +JsonNode* double_node(double v) { + JsonNode* n = json_node_alloc(); + json_node_init_double(n, v); + return n; +} + +JsonNode* string_node(const std::string& s) { + JsonNode* n = json_node_alloc(); + json_node_init_string(n, s.c_str()); + return n; +} + +JsonNode* array_node(const std::vector& items) { + JsonArray* arr = json_array_new(); + for (auto* item : items) { + json_array_add_element(arr, item); + } + JsonNode* n = json_node_alloc(); + json_node_init_array(n, arr); + json_array_unref(arr); + return n; +} + +JsonObject* object_new() { return json_object_new(); } + +bool is_null(JsonNode* n) { return !n || JSON_NODE_HOLDS_NULL(n); } + +std::optional as_bool(JsonNode* n) { + if (!n || !JSON_NODE_HOLDS_VALUE(n)) return std::nullopt; + if (json_node_get_value_type(n) != G_TYPE_BOOLEAN) return std::nullopt; + return json_node_get_boolean(n); +} + +std::optional as_int(JsonNode* n) { + if (!n || !JSON_NODE_HOLDS_VALUE(n)) return std::nullopt; + GType t = json_node_get_value_type(n); + if (t == G_TYPE_INT64 || t == G_TYPE_INT) return json_node_get_int(n); + if (t == G_TYPE_DOUBLE) return static_cast(json_node_get_double(n)); + return std::nullopt; +} + +std::optional as_string(JsonNode* n) { + if (!n || !JSON_NODE_HOLDS_VALUE(n)) return std::nullopt; + if (json_node_get_value_type(n) != G_TYPE_STRING) return std::nullopt; + const gchar* s = json_node_get_string(n); + return s ? std::string(s) : std::string(); +} + +JsonObject* as_object(JsonNode* n) { + if (!n || !JSON_NODE_HOLDS_OBJECT(n)) return nullptr; + return json_node_get_object(n); +} + +JsonArray* as_array(JsonNode* n) { + if (!n || !JSON_NODE_HOLDS_ARRAY(n)) return nullptr; + return json_node_get_array(n); +} + +JsonNode* object_get(JsonObject* obj, const char* key) { + if (!obj || !json_object_has_member(obj, key)) return nullptr; + return json_object_get_member(obj, key); +} + +std::optional object_get_string(JsonObject* obj, const char* key) { + return as_string(object_get(obj, key)); +} + +std::optional object_get_bool(JsonObject* obj, const char* key) { + return as_bool(object_get(obj, key)); +} + +std::optional object_get_int(JsonObject* obj, const char* key) { + return as_int(object_get(obj, key)); +} + +JsonNode* rpc_ok(JsonNode* id, JsonNode* result) { + JsonObject* obj = object_new(); + json_object_set_string_member(obj, "jsonrpc", "2.0"); + json_object_set_member(obj, "id", take_id_copy(id)); + json_object_set_member(obj, "result", result ? result : null_node()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, obj); + json_object_unref(obj); + return n; +} + +JsonNode* rpc_error(JsonNode* id, int code, const std::string& message) { + JsonObject* err = object_new(); + json_object_set_int_member(err, "code", code); + json_object_set_string_member(err, "message", message.c_str()); + JsonNode* err_node = json_node_alloc(); + json_node_init_object(err_node, err); + json_object_unref(err); + + JsonObject* obj = object_new(); + json_object_set_string_member(obj, "jsonrpc", "2.0"); + json_object_set_member(obj, "id", take_id_copy(id)); + json_object_set_member(obj, "error", err_node); + + JsonNode* n = json_node_alloc(); + json_node_init_object(n, obj); + json_object_unref(obj); + return n; +} + +JsonNode* rpc_notification(const std::string& method, JsonNode* params) { + JsonObject* obj = object_new(); + json_object_set_string_member(obj, "jsonrpc", "2.0"); + json_object_set_string_member(obj, "method", method.c_str()); + json_object_set_member(obj, "params", params ? params : empty_object_node()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, obj); + json_object_unref(obj); + return n; +} + +JsonNode* rpc_request(JsonNode* id, const std::string& method, JsonNode* params) { + JsonObject* obj = object_new(); + json_object_set_string_member(obj, "jsonrpc", "2.0"); + json_object_set_member(obj, "id", take_id_copy(id)); + json_object_set_string_member(obj, "method", method.c_str()); + json_object_set_member(obj, "params", params ? params : empty_object_node()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, obj); + json_object_unref(obj); + return n; +} + +std::string id_key(JsonNode* id) { + if (!id || JSON_NODE_HOLDS_NULL(id)) return "null"; + if (JSON_NODE_HOLDS_VALUE(id)) { + if (json_node_get_value_type(id) == G_TYPE_STRING) { + return std::string("s:") + json_node_get_string(id); + } + if (json_node_get_value_type(id) == G_TYPE_INT64 || json_node_get_value_type(id) == G_TYPE_INT) { + return "n:" + std::to_string(json_node_get_int(id)); + } + if (json_node_get_value_type(id) == G_TYPE_DOUBLE) { + return "n:" + std::to_string(static_cast(json_node_get_double(id))); + } + } + return "x"; +} + +} // namespace jsonutil diff --git a/native/linux/src/json_util.hpp b/native/linux/src/json_util.hpp new file mode 100644 index 0000000..6bf778f --- /dev/null +++ b/native/linux/src/json_util.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include +#include +#include + +namespace jsonutil { + +JsonNode* parse(const std::string& text); +std::string stringify(JsonNode* node); +std::string stringify_object(JsonObject* obj); + +JsonNode* null_node(); +JsonNode* bool_node(bool v); +JsonNode* int_node(gint64 v); +JsonNode* double_node(double v); +JsonNode* string_node(const std::string& s); +JsonNode* array_node(const std::vector& items); +JsonObject* object_new(); + +bool is_null(JsonNode* n); +std::optional as_bool(JsonNode* n); +std::optional as_int(JsonNode* n); +std::optional as_string(JsonNode* n); +JsonObject* as_object(JsonNode* n); +JsonArray* as_array(JsonNode* n); + +JsonNode* object_get(JsonObject* obj, const char* key); +std::optional object_get_string(JsonObject* obj, const char* key); +std::optional object_get_bool(JsonObject* obj, const char* key); +std::optional object_get_int(JsonObject* obj, const char* key); + +// Steal ownership of node into a response envelope; caller owns returned node. +JsonNode* rpc_ok(JsonNode* id, JsonNode* result); +JsonNode* rpc_error(JsonNode* id, int code, const std::string& message); +JsonNode* rpc_notification(const std::string& method, JsonNode* params); +JsonNode* rpc_request(JsonNode* id, const std::string& method, JsonNode* params); + +std::string id_key(JsonNode* id); + +} // namespace jsonutil diff --git a/native/linux/src/main.cpp b/native/linux/src/main.cpp new file mode 100644 index 0000000..e2a9e30 --- /dev/null +++ b/native/linux/src/main.cpp @@ -0,0 +1,33 @@ +#include "config.hpp" +#include "host_controller.hpp" + +#include + +#include +#include + +int main(int argc, char** argv) { + auto config = HostConfig::parse(argc, argv); + + // Prefer software rendering when unset — WebKitGPU/DMA-BUF crashes are common on Xvfb. + if (!g_getenv("WEBKIT_DISABLE_COMPOSITING_MODE")) + g_setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", FALSE); + if (!g_getenv("WEBKIT_DISABLE_DMABUF_RENDERER")) + g_setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", FALSE); + // E2E / CI: bubblewrap sandbox often cannot map workspace paths on runners. + if (config.test_rpc && !g_getenv("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS")) + g_setenv("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1", FALSE); + + gtk_init(); + + auto host = std::make_unique(std::move(config)); + if (!host->start()) { + fprintf(stderr, "failed to start host\n"); + return 1; + } + + GMainLoop* loop = g_main_loop_new(nullptr, FALSE); + g_main_loop_run(loop); + g_main_loop_unref(loop); + return 0; +} diff --git a/native/linux/src/rpc_server.cpp b/native/linux/src/rpc_server.cpp new file mode 100644 index 0000000..8b2c540 --- /dev/null +++ b/native/linux/src/rpc_server.cpp @@ -0,0 +1,254 @@ +#include "rpc_server.hpp" + +#include +#include +#include + +struct ReadCtx { + RpcServer* server; + GInputStream* stream; + guint8* buf; + gsize size; +}; + +RpcServer::RpcServer() = default; + +RpcServer::~RpcServer() { + close_connection(); + if (service_) { + g_socket_service_stop(service_); + if (incoming_handler_id_) g_signal_handler_disconnect(service_, incoming_handler_id_); + g_object_unref(service_); + service_ = nullptr; + } + if (cancellable_) { + g_object_unref(cancellable_); + cancellable_ = nullptr; + } +} + +bool RpcServer::start(const std::string& host, uint16_t port) { + GError* err = nullptr; + service_ = g_socket_service_new(); + + GSocketFamily family = G_SOCKET_FAMILY_IPV4; + if (host.find(':') != std::string::npos) family = G_SOCKET_FAMILY_IPV6; + + GSocket* sock = g_socket_new(family, G_SOCKET_TYPE_STREAM, G_SOCKET_PROTOCOL_TCP, &err); + if (!sock) { + fprintf(stderr, "edw: socket create failed: %s\n", err ? err->message : "unknown"); + if (err) g_error_free(err); + return false; + } + GInetAddress* inet = g_inet_address_new_from_string(host.c_str()); + if (!inet) { + fprintf(stderr, "edw: invalid host %s\n", host.c_str()); + g_object_unref(sock); + return false; + } + GSocketAddress* addr = G_SOCKET_ADDRESS(g_inet_socket_address_new(inet, port)); + g_object_unref(inet); + + if (!g_socket_bind(sock, addr, TRUE, &err)) { + fprintf(stderr, "edw: failed to bind %s:%u: %s\n", host.c_str(), port, + err ? err->message : "unknown"); + if (err) g_error_free(err); + g_object_unref(addr); + g_object_unref(sock); + return false; + } + g_object_unref(addr); + + if (!g_socket_listen(sock, &err)) { + fprintf(stderr, "edw: listen failed: %s\n", err ? err->message : "unknown"); + if (err) g_error_free(err); + g_object_unref(sock); + return false; + } + + GSocketAddress* local = g_socket_get_local_address(sock, nullptr); + if (local && G_IS_INET_SOCKET_ADDRESS(local)) { + port_ = g_inet_socket_address_get_port(G_INET_SOCKET_ADDRESS(local)); + } + if (local) g_object_unref(local); + + if (!g_socket_listener_add_socket(G_SOCKET_LISTENER(service_), sock, nullptr, &err)) { + fprintf(stderr, "edw: add_socket failed: %s\n", err ? err->message : "unknown"); + if (err) g_error_free(err); + g_object_unref(sock); + return false; + } + g_object_unref(sock); + + incoming_handler_id_ = g_signal_connect( + service_, "incoming", + G_CALLBACK(+[](GSocketService*, GSocketConnection* conn, GObject*, gpointer user) -> gboolean { + static_cast(user)->accept(conn); + return TRUE; + }), + this); + + g_socket_service_start(service_); + fprintf(stdout, "listening %u\n", port_); + fflush(stdout); + return true; +} + +void RpcServer::accept(GSocketConnection* conn) { + if (connection_) { + close_connection(); + } + connection_ = G_SOCKET_CONNECTION(g_object_ref(conn)); + buffer_.clear(); + if (!cancellable_) cancellable_ = g_cancellable_new(); + g_cancellable_reset(cancellable_); + + GInputStream* in = g_io_stream_get_input_stream(G_IO_STREAM(connection_)); + on_readable(in); +} + +void RpcServer::on_readable(GInputStream* stream) { + if (!connection_) return; + auto* ctx = new ReadCtx; + ctx->server = this; + ctx->stream = stream; + ctx->size = 64 * 1024; + ctx->buf = new guint8[ctx->size]; + + g_input_stream_read_async( + stream, ctx->buf, ctx->size, G_PRIORITY_DEFAULT, cancellable_, + +[](GObject* source, GAsyncResult* res, gpointer user_data) { + auto* ctx = static_cast(user_data); + GError* err = nullptr; + gssize n = g_input_stream_read_finish(G_INPUT_STREAM(source), res, &err); + if (n > 0) { + ctx->server->buffer_.insert(ctx->server->buffer_.end(), ctx->buf, ctx->buf + n); + ctx->server->drain(); + ctx->server->on_readable(ctx->stream); + } else { + bool cancelled = err && g_error_matches(err, G_IO_ERROR, G_IO_ERROR_CANCELLED); + if (err) g_error_free(err); + if (!cancelled && ctx->server->connection_) { + ctx->server->close_connection(); + if (ctx->server->on_disconnect_) ctx->server->on_disconnect_(); + } + } + delete[] ctx->buf; + delete ctx; + }, + ctx); +} + +void RpcServer::drain() { + while (buffer_.size() >= 4) { + uint32_t len = 0; + memcpy(&len, buffer_.data(), 4); + len = ntohl(len); + if (buffer_.size() < 4u + len) return; + std::string payload(reinterpret_cast(buffer_.data() + 4), len); + buffer_.erase(buffer_.begin(), buffer_.begin() + 4 + static_cast(len)); + handle_payload(payload); + } +} + +void RpcServer::handle_payload(const std::string& payload) { + JsonNode* root = jsonutil::parse(payload); + if (!root) return; + JsonObject* obj = jsonutil::as_object(root); + if (!obj) { + json_node_free(root); + return; + } + + bool has_method = json_object_has_member(obj, "method"); + bool has_id = json_object_has_member(obj, "id"); + bool has_result_or_error = + json_object_has_member(obj, "result") || json_object_has_member(obj, "error"); + + if (has_method && has_id) { + auto method = jsonutil::object_get_string(obj, "method").value_or(""); + JsonNode* id_copy = json_node_copy(json_object_get_member(obj, "id")); + JsonNode* params_copy = json_object_has_member(obj, "params") + ? json_node_copy(json_object_get_member(obj, "params")) + : nullptr; + json_node_free(root); + if (on_request_) { + on_request_(id_copy, method, params_copy, [this](JsonNode* response) { send_node(response); }); + } else { + json_node_free(id_copy); + if (params_copy) json_node_free(params_copy); + } + return; + } + + if (has_method && !has_id) { + json_node_free(root); + return; + } + + if (has_result_or_error && has_id) { + JsonNode* id = json_object_get_member(obj, "id"); + auto key = jsonutil::id_key(id); + auto it = pending_.find(key); + if (it != pending_.end()) { + auto cb = std::move(it->second); + pending_.erase(it); + JsonNode* result = json_object_has_member(obj, "result") + ? json_node_copy(json_object_get_member(obj, "result")) + : nullptr; + json_node_free(root); + cb(result); + if (result) json_node_free(result); + return; + } + } + + json_node_free(root); +} + +void RpcServer::send_node(JsonNode* node) { + if (!node) return; + auto s = jsonutil::stringify(node); + json_node_free(node); + send_raw(s); +} + +void RpcServer::notify(const std::string& method, JsonNode* params) { + send_node(jsonutil::rpc_notification(method, params)); +} + +void RpcServer::request(const std::string& method, JsonNode* params, PendingCallback cb) { + int id_num = next_outbound_id_++; + JsonNode* id = jsonutil::int_node(id_num); + pending_[jsonutil::id_key(id)] = std::move(cb); + // rpc_request copies id; free our temporary after + JsonNode* id_for_req = json_node_copy(id); + send_node(jsonutil::rpc_request(id_for_req, method, params)); + json_node_free(id_for_req); + json_node_free(id); +} + +void RpcServer::send_raw(const std::string& json) { + if (!connection_) return; + uint32_t len = htonl(static_cast(json.size())); + GOutputStream* out = g_io_stream_get_output_stream(G_IO_STREAM(connection_)); + GError* err = nullptr; + gsize written = 0; + if (!g_output_stream_write_all(out, &len, 4, &written, nullptr, &err) || + !g_output_stream_write_all(out, json.data(), json.size(), &written, nullptr, &err)) { + if (err) { + fprintf(stderr, "edw: write failed: %s\n", err->message); + g_error_free(err); + } + } +} + +void RpcServer::close_connection() { + if (cancellable_) g_cancellable_cancel(cancellable_); + if (connection_) { + g_io_stream_close(G_IO_STREAM(connection_), nullptr, nullptr); + g_object_unref(connection_); + connection_ = nullptr; + } + buffer_.clear(); +} diff --git a/native/linux/src/rpc_server.hpp b/native/linux/src/rpc_server.hpp new file mode 100644 index 0000000..5d811de --- /dev/null +++ b/native/linux/src/rpc_server.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include "json_util.hpp" + +#include +#include +#include +#include +#include + +#include + +class RpcServer { + public: + using ReplyFn = std::function; + using RequestHandler = std::function; + using DisconnectHandler = std::function; + using PendingCallback = std::function; + + RpcServer(); + ~RpcServer(); + + bool start(const std::string& host, uint16_t port); + uint16_t port() const { return port_; } + + void set_request_handler(RequestHandler h) { on_request_ = std::move(h); } + void set_disconnect_handler(DisconnectHandler h) { on_disconnect_ = std::move(h); } + + void send_node(JsonNode* node); // takes ownership + void notify(const std::string& method, JsonNode* params); // takes ownership of params + void request(const std::string& method, JsonNode* params, PendingCallback cb); // takes params + void close_connection(); + + private: + void accept(GSocketConnection* conn); + void on_readable(GInputStream* stream); + void drain(); + void handle_payload(const std::string& payload); + void send_raw(const std::string& json); + + GSocketService* service_ = nullptr; + GSocketConnection* connection_ = nullptr; + GCancellable* cancellable_ = nullptr; + std::vector buffer_; + uint16_t port_ = 0; + int next_outbound_id_ = 1; + std::map pending_; + RequestHandler on_request_; + DisconnectHandler on_disconnect_; + gulong incoming_handler_id_ = 0; +}; diff --git a/native/linux/src/web_window.cpp b/native/linux/src/web_window.cpp new file mode 100644 index 0000000..941d2bc --- /dev/null +++ b/native/linux/src/web_window.cpp @@ -0,0 +1,287 @@ +#include "web_window.hpp" + +#include "json_util.hpp" + +#include + +namespace { + +std::string permission_type_name(WebKitPermissionRequest* request) { + if (WEBKIT_IS_USER_MEDIA_PERMISSION_REQUEST(request)) { + auto* um = WEBKIT_USER_MEDIA_PERMISSION_REQUEST(request); + // Prefer microphone if both; WebKit exposes video/audio device flags. + if (webkit_user_media_permission_is_for_video_device(um)) return "camera"; + if (webkit_user_media_permission_is_for_audio_device(um)) return "microphone"; + return "microphone"; + } + if (WEBKIT_IS_DEVICE_INFO_PERMISSION_REQUEST(request)) return "microphone"; + return "microphone"; +} + +std::string origin_from_webview(WebKitWebView* view) { + const gchar* uri = webkit_web_view_get_uri(view); + if (!uri || !*uri) return "http://127.0.0.1"; + GUri* guri = g_uri_parse(uri, G_URI_FLAGS_NONE, nullptr); + if (!guri) return "http://127.0.0.1"; + const gchar* scheme = g_uri_get_scheme(guri); + const gchar* host = g_uri_get_host(guri); + gint port = g_uri_get_port(guri); + std::string out; + if (scheme && host) { + out = std::string(scheme) + "://" + host; + if (port > 0) out += ":" + std::to_string(port); + } else { + out = uri; + } + g_uri_unref(guri); + return out; +} + +} // namespace + +WebWindow::WebWindow(const std::string& window_id, const std::string& webview_id, + const std::string& title, int width, int height) + : window_id_(window_id), webview_id_(webview_id) { + window_ = GTK_WINDOW(gtk_window_new()); + gtk_window_set_title(window_, title.c_str()); + gtk_window_set_default_size(window_, width, height); + + root_ = GTK_BOX(gtk_box_new(GTK_ORIENTATION_VERTICAL, 0)); + gtk_window_set_child(window_, GTK_WIDGET(root_)); + + create_webview(); + + g_signal_connect( + window_, "close-request", G_CALLBACK(+[](GtkWindow*, gpointer user) -> gboolean { + auto* self = static_cast(user); + if (self->on_close_) self->on_close_(self->window_id_); + return TRUE; // veto + }), + this); + + g_signal_connect(window_, "notify::is-active", G_CALLBACK(+[](GObject* obj, GParamSpec*, + gpointer user) { + auto* self = static_cast(user); + gboolean active = FALSE; + g_object_get(obj, "is-active", &active, nullptr); + if (self->on_focus_) self->on_focus_(self->window_id_, active); + }), + this); +} + +WebWindow::~WebWindow() { + if (window_) { + gtk_window_destroy(window_); + window_ = nullptr; + webview_ = nullptr; + root_ = nullptr; + menubar_ = nullptr; + } +} + +void WebWindow::create_webview() { + webview_ = WEBKIT_WEB_VIEW(webkit_web_view_new()); + WebKitSettings* settings = webkit_web_view_get_settings(webview_); + // Avoid GPU/compositor crashes under Xvfb / CI. + webkit_settings_set_hardware_acceleration_policy(settings, + WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER); + gtk_widget_set_hexpand(GTK_WIDGET(webview_), TRUE); + gtk_widget_set_vexpand(GTK_WIDGET(webview_), TRUE); + gtk_box_append(root_, GTK_WIDGET(webview_)); + attach_webview_signals(); +} + +void WebWindow::attach_webview_signals() { + g_signal_connect(webview_, "create", + G_CALLBACK(+[](WebKitWebView*, WebKitNavigationAction* action, + gpointer user) -> GtkWidget* { + auto* self = static_cast(user); + WebKitURIRequest* req = webkit_navigation_action_get_request(action); + const gchar* uri = req ? webkit_uri_request_get_uri(req) : nullptr; + if (uri && self->on_new_window_) { + self->on_new_window_(self->webview_id_, uri); + } + return nullptr; + }), + this); + + g_signal_connect(webview_, "permission-request", + G_CALLBACK(+[](WebKitWebView* view, WebKitPermissionRequest* request, + gpointer user) -> gboolean { + auto* self = static_cast(user); + if (!self->on_permission_) return FALSE; + auto type = permission_type_name(request); + auto origin = origin_from_webview(view); + g_object_ref(request); + self->on_permission_(origin, type, self->webview_id_, request); + return TRUE; + }), + this); + + g_signal_connect(webview_, "load-changed", + G_CALLBACK(+[](WebKitWebView* view, WebKitLoadEvent event, gpointer user) { + if (event != WEBKIT_LOAD_FINISHED) return; + auto* self = static_cast(user); + const gchar* uri = webkit_web_view_get_uri(view); + if (self->on_nav_) self->on_nav_(self->webview_id_, uri ? uri : "", false, ""); + }), + this); + + g_signal_connect(webview_, "load-failed", + G_CALLBACK(+[](WebKitWebView*, WebKitLoadEvent, gchar*, GError* error, + gpointer user) -> gboolean { + auto* self = static_cast(user); + if (self->on_nav_) { + self->on_nav_(self->webview_id_, "", true, + error ? error->message : "load failed"); + } + return FALSE; + }), + this); + + g_signal_connect(webview_, "context-menu", + G_CALLBACK(+[](WebKitWebView*, WebKitContextMenu*, WebKitHitTestResult*, + gpointer user) -> gboolean { + auto* self = static_cast(user); + return self->context_menu_enabled_ ? FALSE : TRUE; + }), + this); + + g_signal_connect(webview_, "decide-policy", + G_CALLBACK(+[](WebKitWebView*, WebKitPolicyDecision* decision, + WebKitPolicyDecisionType type, gpointer user) -> gboolean { + if (type != WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION) return FALSE; + auto* self = static_cast(user); + auto* nav = WEBKIT_NAVIGATION_POLICY_DECISION(decision); + WebKitNavigationAction* action = + webkit_navigation_policy_decision_get_navigation_action(nav); + WebKitURIRequest* req = webkit_navigation_action_get_request(action); + const gchar* uri = req ? webkit_uri_request_get_uri(req) : nullptr; + if (uri && self->on_new_window_) self->on_new_window_(self->webview_id_, uri); + webkit_policy_decision_ignore(decision); + return TRUE; + }), + this); +} + +void WebWindow::set_handlers(CloseHandler on_close, FocusHandler on_focus, + NewWindowHandler on_new_window, NavHandler on_nav, + PermissionHandler on_permission) { + on_close_ = std::move(on_close); + on_focus_ = std::move(on_focus); + on_new_window_ = std::move(on_new_window); + on_nav_ = std::move(on_nav); + on_permission_ = std::move(on_permission); +} + +void WebWindow::load_url(const std::string& url) { + last_url_ = url; + webkit_web_view_load_uri(webview_, url.c_str()); +} + +void WebWindow::reload() { webkit_web_view_reload(webview_); } + +std::string WebWindow::current_url() const { + const gchar* uri = webkit_web_view_get_uri(webview_); + if (uri && *uri) return uri; + return last_url_.value_or(""); +} + +std::string WebWindow::rebuild(const std::string& new_webview_id) { + if (webview_) { + gtk_box_remove(root_, GTK_WIDGET(webview_)); + webview_ = nullptr; + } + webview_id_ = new_webview_id; + create_webview(); + if (last_url_) webkit_web_view_load_uri(webview_, last_url_->c_str()); + return webview_id_; +} + +void WebWindow::set_context_menu_enabled(bool enabled) { context_menu_enabled_ = enabled; } + +void WebWindow::set_title(const std::string& title) { gtk_window_set_title(window_, title.c_str()); } + +void WebWindow::set_min_size(int width, int height) { + gtk_widget_set_size_request(GTK_WIDGET(window_), width, height); +} + +void WebWindow::show() { gtk_window_present(window_); } + +void WebWindow::hide() { gtk_widget_set_visible(GTK_WIDGET(window_), FALSE); } + +void WebWindow::raise() { + gtk_window_present(window_); +} + +void WebWindow::iconize(bool iconize) { + if (iconize) { + gtk_window_minimize(window_); + } else { + gtk_window_unminimize(window_); + } +} + +bool WebWindow::shown() const { return gtk_widget_get_visible(GTK_WIDGET(window_)); } + +bool WebWindow::active() const { return gtk_window_is_active(window_); } + +void WebWindow::set_icon(GdkTexture* texture) { + if (!texture) return; + gtk_window_set_icon_name(window_, nullptr); + // GTK4 prefers paintable via GdkSurface — best-effort default icon name left unset. + (void)texture; +} + +void WebWindow::set_menubar(GMenuModel* model) { + if (menubar_) { + gtk_box_remove(root_, GTK_WIDGET(menubar_)); + menubar_ = nullptr; + } + if (!model) return; + menubar_ = GTK_POPOVER_MENU_BAR(gtk_popover_menu_bar_new_from_model(model)); + gtk_box_prepend(root_, GTK_WIDGET(menubar_)); +} + +void WebWindow::eval_js(const std::string& script, + std::function cb) { + struct EvalCtx { + std::function cb; + }; + auto* ctx = new EvalCtx{std::move(cb)}; + webkit_web_view_evaluate_javascript( + webview_, script.c_str(), -1, nullptr, nullptr, nullptr, + +[](GObject* source, GAsyncResult* res, gpointer user_data) { + auto* ctx = static_cast(user_data); + GError* err = nullptr; + JSCValue* value = + webkit_web_view_evaluate_javascript_finish(WEBKIT_WEB_VIEW(source), res, &err); + JsonNode* out = nullptr; + if (err) { + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "error", err->message); + out = json_node_alloc(); + json_node_init_object(out, o); + json_object_unref(o); + g_error_free(err); + } else if (!value || jsc_value_is_undefined(value) || jsc_value_is_null(value)) { + out = jsonutil::null_node(); + } else if (jsc_value_is_string(value)) { + gchar* s = jsc_value_to_string(value); + out = jsonutil::string_node(s ? s : ""); + g_free(s); + } else if (jsc_value_is_boolean(value)) { + out = jsonutil::bool_node(jsc_value_to_boolean(value)); + } else if (jsc_value_is_number(value)) { + out = jsonutil::double_node(jsc_value_to_double(value)); + } else { + gchar* s = jsc_value_to_string(value); + out = jsonutil::string_node(s ? s : ""); + g_free(s); + } + if (value) g_object_unref(value); + ctx->cb(out); + delete ctx; + }, + ctx); +} diff --git a/native/linux/src/web_window.hpp b/native/linux/src/web_window.hpp new file mode 100644 index 0000000..a643d40 --- /dev/null +++ b/native/linux/src/web_window.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +class HostController; + +class WebWindow { + public: + using CloseHandler = std::function; + using FocusHandler = std::function; + using NewWindowHandler = + std::function; + using NavHandler = std::function; + using PermissionHandler = std::function; + + WebWindow(const std::string& window_id, const std::string& webview_id, const std::string& title, + int width, int height); + ~WebWindow(); + + const std::string& window_id() const { return window_id_; } + const std::string& webview_id() const { return webview_id_; } + GtkWindow* window() const { return window_; } + WebKitWebView* webview() const { return webview_; } + const std::optional& last_url() const { return last_url_; } + + void set_handlers(CloseHandler on_close, FocusHandler on_focus, NewWindowHandler on_new_window, + NavHandler on_nav, PermissionHandler on_permission); + + void load_url(const std::string& url); + void reload(); + std::string current_url() const; + std::string rebuild(const std::string& new_webview_id); + void set_context_menu_enabled(bool enabled); + void set_title(const std::string& title); + void set_min_size(int width, int height); + void show(); + void hide(); + void raise(); + void iconize(bool iconize); + bool shown() const; + bool active() const; + void set_icon(GdkTexture* texture); + void set_menubar(GMenuModel* model); + + void eval_js(const std::string& script, + std::function cb); + + private: + void attach_webview_signals(); + void create_webview(); + + std::string window_id_; + std::string webview_id_; + GtkWindow* window_ = nullptr; + GtkBox* root_ = nullptr; + GtkPopoverMenuBar* menubar_ = nullptr; + WebKitWebView* webview_ = nullptr; + std::optional last_url_; + bool context_menu_enabled_ = false; + + CloseHandler on_close_; + FocusHandler on_focus_; + NewWindowHandler on_new_window_; + NavHandler on_nav_; + PermissionHandler on_permission_; +}; diff --git a/priv/native/linux/.gitkeep b/priv/native/linux/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/build_linux.sh b/scripts/build_linux.sh new file mode 100755 index 0000000..da5260f --- /dev/null +++ b/scripts/build_linux.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT_DIR="$ROOT/priv/native/linux" +BUILD_DIR="$ROOT/native/linux/build" +mkdir -p "$OUT_DIR" "$BUILD_DIR" + +cmake -S "$ROOT/native/linux" -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release +cmake --build "$BUILD_DIR" -j"$(nproc)" + +cp -f "$BUILD_DIR/DesktopWebView" "$OUT_DIR/DesktopWebView" +chmod +x "$OUT_DIR/DesktopWebView" +echo "Built $OUT_DIR/DesktopWebView" +file "$OUT_DIR/DesktopWebView" || true diff --git a/test/e2e/e2e_test.exs b/test/e2e/e2e_test.exs index 671c541..deb66b7 100644 --- a/test/e2e/e2e_test.exs +++ b/test/e2e/e2e_test.exs @@ -7,7 +7,10 @@ defmodule DesktopWebview.E2ETest do setup do unless Binary.available?() do - flunk("DesktopWebView binary missing at #{Binary.path()}; run ./scripts/build_macos.sh") + flunk( + "DesktopWebView binary missing at #{Binary.path()}; " <> + "run ./scripts/build_macos.sh or ./scripts/build_linux.sh" + ) end {:ok, launcher} = Launcher.start(test_rpc: true, lifetime: :reconnect) @@ -30,11 +33,19 @@ defmodule DesktopWebview.E2ETest do {:ok, _} = Transport.start_link([]) assert {:ok, caps} = Transport.connect("127.0.0.1", launcher.listen_port) assert caps["protocol_version"] == 1 - assert caps["platform"] == "macos" + + expected_platform = + case :os.type() do + {:unix, :darwin} -> "macos" + {:win32, _} -> "windows" + {:unix, _} -> "linux" + end + + assert caps["platform"] == expected_platform Transport.set_permission_handler(fn _ -> "allow" end) - %{launcher: launcher, caps: caps} + %{launcher: launcher, caps: caps, platform: expected_platform} end test "test.ping" do @@ -53,11 +64,11 @@ defmodule DesktopWebview.E2ETest do "height" => 480 }) - html = Path.expand("test/fixtures/media.html") - url = "file://" <> html + html = File.read!(Path.expand("test/fixtures/media.html")) + url = "data:text/html;charset=utf-8," <> URI.encode(html) assert {:ok, true} = Transport.call("webview.load_url", %{"webview_id" => vid, "url" => url}) - # Give WKWebView a moment + # Give the engine a moment Process.sleep(500) assert {:ok, true} = Transport.call("webview.reload", %{"webview_id" => vid}) @@ -123,9 +134,11 @@ defmodule DesktopWebview.E2ETest do assert {:ok, %{"window_id" => _wid, "webview_id" => vid}} = Transport.call("window.open", %{"title" => "Media", "width" => 400, "height" => 300}) - html = Path.expand("test/fixtures/media.html") + html = File.read!(Path.expand("test/fixtures/media.html")) + url = "data:text/html;charset=utf-8," <> URI.encode(html) + assert {:ok, true} = - Transport.call("webview.load_url", %{"webview_id" => vid, "url" => "file://" <> html}) + Transport.call("webview.load_url", %{"webview_id" => vid, "url" => url}) Process.sleep(400) @@ -150,11 +163,16 @@ defmodule DesktopWebview.E2ETest do assert result == "EDW Media Fixture" or is_binary(result) end - test "system locale and os_description" do + test "system locale and os_description", %{platform: platform} do assert {:ok, loc} = Transport.call("system.locale", %{}) assert is_binary(loc) assert {:ok, desc} = Transport.call("system.os_description", %{}) - assert desc =~ "macOS" + + case platform do + "macos" -> assert desc =~ "macOS" + "linux" -> assert desc =~ "Linux" + "windows" -> assert desc =~ "Windows" + end end test "multi-window" do