Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,7 @@ npm-debug.log
/assets/node_modules/

# This is a library
/mix.lock
/mix.lock

# Generated on every mix load (desktop_wx_stub.exs)
/src/desktop_wx.erl
40 changes: 38 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ wxWidgets GUI support requires `libwxgtk-webview3.2-dev` and `xvfb` on headless
| Install deps | `mix deps.get` |
| Compile | `mix compile` |
| Lint (all) | `mix lint` (runs compile --warnings-as-errors, format --check-formatted, credo --ignore refactor, dialyzer) |
| Tests | `xvfb-run mix test` |
| Tests (fast, no wx) | `mix test.fast` |
| Tests (wx only) | `xvfb-run -a mix test.wx` (or `DISPLAY=:99 mix test.wx` if xvfb is already running) |
| Tests (all) | `xvfb-run -a mix test` |
| Regression guard | `mix test.guard` |
| Run code | `xvfb-run mix run -e '<elixir code>'` |
| IEx shell | `xvfb-run iex -S mix` |

Expand All @@ -31,4 +34,37 @@ wxWidgets GUI support requires `libwxgtk-webview3.2-dev` and `xvfb` on headless

### Running without a display

All commands that load the `:wx` application (compile, test, iex) need a display. Use `xvfb-run` prefix on headless servers. The `NO_WX` environment variable can skip wx initialization but will limit functionality.
All commands that load the `:wx` application (compile, test, iex) need a display. Use `xvfb-run` prefix on headless servers. The `NO_WX` environment variable selects the `Desktop.Backend.Browser` platform backend (OS browser fallback, no native window).

### Platform backends

| Backend | When |
|---|---|
| `Desktop.Backend.Wx` | Desktop host targets with OTP `:wx` (default) |
| `Desktop.Backend.Json` | `:mobile_target` compile config or `OS.mobile?/0` — JSON bridge via `BRIDGE_PORT` |
| `Desktop.Backend.Browser` | `NO_WX=1` or `:wx` unavailable |

Override with `config :desktop, :backend, :wx | :json | :browser | :auto`.

### Platform abstraction (do not regress)

Library code is layered:

1. **Public API** — `Desktop`, `Desktop.Window`, `Desktop.OS` (legacy helpers).
2. **Platform facades** — `Desktop.Platform.*` (route to the active backend).
3. **Backends** — `Desktop.Backend.Wx`, `.Json`, `.Browser` (implement behaviour).

**Rules for agents and contributors:**

| Do | Don't |
|---|---|
| Call `Desktop.Platform.System.locale/0`, `.open_external_url/1`, `Desktop.Platform.Window.*`, etc. from library code | Call `Desktop.Backend.*` or `:wx*` modules directly from `Window`, `Menu`, `OS`, etc. |
| Keep `Desktop.OS.launch_default_browser/1` as a thin spawn wrapper to `Platform.System.open_external_url/1` only in `os.ex` | Re-implement browser launch, locale, or wx setup in `OS` with `case OS.type()` / `wx_available?` branches |
| Let `Desktop.Platform.Helpers.with_wx_env/1` (used by `Platform.System` and `Platform.Window`) call `Desktop.Env.wx_use_env/0` before backend work | Call `Desktop.Env.wx_use_env/0` from app/library modules; never guard it with `if Platform.System.wx_available?()` |
| Rely on `wx_use_env/0` being a safe no-op when `Desktop.Env` is down or `wx_env` is nil | Assume wx is loaded on Json/mobile or skip Platform because “it's only wx” |
| On **Json/mobile**, use `Protocol.new/call/connect` with **bridge atoms** (e.g. `[:getSystemLanguage]`) | Put evaluated BEAM calls in RPC args (e.g. `Protocol.new(:wxLocale, [:wxLocale.getSystemLanguage()])` or `:wxLocale.getSystemLanguage/0` in `json.ex`) |
| Put wx-specific logic in the matching **backend** module | Put Android/iOS `:ok` stubs or `Null.wx_call` in `OS` or `Window` |

**Regression guards:** `mix test.guard` runs `guard_boolean_ops.exs` and `guard_platform_abstraction.exs` (forbidden patterns under `lib/`).

**Tests:** `mix test.fast` — no wx; `xvfb-run -a mix test.wx` — wx backend; `mix test.guard` — static rules.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## Changes in 1.6 (unreleased)

- Internal `Desktop.Platform` layer with domain behaviours (Window, Content, Notification, Media, System) and backends (`Desktop.Backend.Wx`, `Desktop.Backend.Json`, `Desktop.Backend.Browser`)
- Bridge JSON/TCP transport embedded as `Desktop.Bridge.Transport` (legacy `[module, method, args]` wire format preserved for native hosts)
- Removed separate `{:wx, hex: :bridge}` dependency on Android/iOS; mobile builds use `Desktop.Backend.Json` directly
- Optional `config :desktop, :backend, :auto | :wx | :json | :browser` override
- Menu adapters: `Desktop.Menu.Adapter.Json` and `Desktop.Menu.Adapter.Browser`
- Public `Desktop.*` APIs unchanged (`Desktop.Window`, `Desktop.Env`, `Desktop.Menu`, etc.)
- Test suite: `mix test.fast`, `xvfb-run mix test.wx`, `mix test.guard` — see `docs/TEST_PLAN.md`
- Compile without OTP `:wx`: conditional `erl_src_paths` and `Desktop.Wx` fallbacks (no `wx.hrl` required)

## Changes in 1.5

- Support for iOS hibernation and wakeup
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,46 @@ Check out the [Getting your Environment Ready Guide](./guides/getting_started.md

This repo’s [`.tool-versions`](./.tool-versions) pins Erlang and Elixir for contributors; [mise](https://mise.jdx.dev/) and [asdf](https://asdf-vm.com/) both understand that file. After activating your toolchain, run `mix desktop.check_toolchain` to confirm the running OTP major and Elixir version match `.tool-versions`.

## Platform backends

`desktop` routes window, webview, menu, and notification calls through `Desktop.Platform` to one of three backends. The default is **automatic** selection (`:auto`); you can override it in config or via environment variables.

| Backend | Typical use |
|---|---|
| `Desktop.Backend.Wx` | Native windows on Windows, macOS, and Linux (OTP `:wx` / wxWidgets) |
| `Desktop.Backend.Json` | Android and iOS — JSON/TCP bridge to your native host app (`BRIDGE_PORT`) |
| `Desktop.Backend.Browser` | Headless CI, servers without wx, or local dev without a GUI (`NO_WX=1`) |

**Automatic selection** (`config :desktop, :backend, :auto` — the default):

1. Mobile target (`config :desktop, :mobile_target, true` at compile time, or `Desktop.OS.mobile?/0` at runtime) → **Json**
2. Else `NO_WX` set or `:wx` not available → **Browser**
3. Else → **Wx**

**Explicit override** in `config/config.exs`:

```elixir
config :desktop, :backend, :wx # force wxWidgets (desktop)
config :desktop, :backend, :json # force JSON bridge (e.g. mobile host)
config :desktop, :backend, :browser # force OS-browser fallback
config :desktop, :backend, :auto # automatic (default)
```

You can also pass a **custom backend module** that implements the `Desktop.Platform.*` behaviour callbacks:

```elixir
config :desktop, :backend, MyApp.DesktopBackend
```

**Environment variables:**

| Variable | Effect |
|---|---|
| `NO_WX=1` | With `:auto`, selects the Browser backend (no native window) |
| `BRIDGE_PORT` | TCP port for the Json backend’s native host (default `0` = in-process mock for tests) |

See the [FAQ](./guides/faq.md) for mobile bridge setup, headless testing, and capability details.

## Status / Roadmap

1. ✅ Run elixir-desktop on Windows/MacOS/Linux
Expand Down
3 changes: 3 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import Config

config :phoenix, :json_library, Jason

# Set at compile time from MIX_TARGET (no Mix at runtime in releases).
config :desktop, :mobile_target, Mix.target() in [:android, :ios]
3 changes: 3 additions & 0 deletions desktop_wx_stub.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Standalone entry for regenerating src/desktop_wx.erl (also invoked from mix.exs).
[{Desktop.WxStub, _}] = Code.compile_file(Path.join(__DIR__, "lib/desktop/wx/stub.ex"))
Desktop.WxStub.write!()
175 changes: 175 additions & 0 deletions docs/TEST_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Desktop Platform — Test Design Plan

This document defines test coverage for the Platform/Backend refactor, based on production regressions found in `desktop-example-app` and the gaps in the current suite (23 tests, mostly parser/toolchain/codec).

## Regressions that must never recur

| ID | Symptom | Root cause | Regression test ID |
|----|---------|------------|-------------------|
| R1 | `{:wx, :unknown_env}` on app start | `language_code/0` → `Wx.locale/0` before `:wx.set_env/1` | T-ENV-01, T-ENV-02 |
| R2 | `function_clause` in `wxEvtHandler.parse_opts` | `connect/3` passed bare `callback:` instead of `[callback:, userData:]` | T-CONN-01, T-CONN-02 |
| R3 | `{:badbool, :and, TodoApp.MenuBar}` | `if menubar and frame` — non-boolean `and` operand | T-BOOL-01, L0-GUARD |
| R4 | `{:badbool, :and, wx_ref}` on window close | `if frame and not is_shown?` | T-BOOL-02, T-WIN-03 |

## Risk patterns to audit (grep / Credo)

- `if <non_boolean> and|or <expr>` where LHS is not a comparison (module atom, wx ref, pid)
- `:wx*` calls without prior `Desktop.Env.wx_use_env/0` or `Backend.Wx.ensure_wx_env/0`
- `Platform.Window.connect/3` not passing `userData: self()`
- `handle_cast` branches assuming `frame` is boolean

Safe patterns (do not “fix”):

- `if caps.window and not OS.mobile?()` — both booleans
- `if title != old and frame != nil` — both comparisons
- `if frame, do:` — `if` accepts truthy values (not `and`/`or`)

---

## Test architecture (4 layers)

```
L0 Static guards → mix test.guard / Credo (no display)
L1 Unit (no wx) → Browser backend, router, pure Window helpers
L2 Unit (mocked) → Bridge.Mock, handle_cast with fake wx_ref
L3 Integration (:wx) → xvfb-run mix test.wx
L4 App smoke → desktop-example-app (optional, manual/CI nightly)
```

| Layer | Command | Catches |
|-------|---------|---------|
| L0 | `mix test.guard` | Future `and`/`or` misuse in Window |
| L1 | `mix test.fast` | Router, Browser contracts, cast logic |
| L2 | `mix test.fast` | Bridge wire format, close_window without GTK |
| L3 | `xvfb-run mix test.wx` | Real wx env, connect, locale, window lifecycle |
| L4 | script / manual | Full TodoApp supervision order |

---

## Test infrastructure

### `test/support/desktop_case.ex`

- `fake_frame/0` → `{:wx_ref, 1, :wxFrame, []}`
- `minimal_window/1` → `%Desktop.Window{...}` for cast tests
- `with_backend/2` — temporary `Application.put_env(:desktop, :backend, ...)`

### `test/support/wx_case.ex`

- `@moduletag :wx`
- Setup: ensure `Desktop.Env` started, `wx_use_env` available
- Document: requires `xvfb-run` on headless Linux

### Mix aliases (`mix.exs`)

```elixir
"test.fast": ["test --exclude wx"],
"test.wx": ["test --only wx"],
"test.guard": ["run test/support/guard_boolean_ops.exs"]
```

---

## Coverage matrix

### Platform router — `test/desktop/platform_test.exs` (extend)

| ID | Test |
|----|------|
| T-PLAT-01 | `backend/0` default Wx on host |
| T-PLAT-02 | `config :desktop, :backend` override `:browser`, `:json` |
| T-PLAT-03 | `NO_WX=1` → Browser |
| T-PLAT-04 | `Menu.adapter(sni: pid)` → DBus on Wx |
| T-PLAT-05 | `capabilities/0` consistent with active backend |
| T-PLAT-06 | `window_server/0` Wx vs Platform.Server |

### Regression — `test/desktop/regression/boolean_ops_test.exs` (new)

| ID | Test |
|----|------|
| T-BOOL-01 | Init guard: `menubar && frame` path does not raise with module + fake_frame |
| T-BOOL-02 | `handle_cast(:close_window)` with fake_frame + taskbar pid — no badbool (R4) |
| T-BOOL-03 | `handle_cast(:close_window)` with `frame: nil` — no crash |
| T-BOOL-04 | `on_close: :hide` branch calls hide, does not shutdown |

### Regression — `test/desktop/regression/wx_connect_test.exs` (new)

| ID | Test |
|----|------|
| T-CONN-01 | `@tag :wx` — `Backend.Wx.connect(frame, :close_window, fn -> :ok end)` does not raise |
| T-CONN-02 | Json + `BRIDGE_PORT=0` — connect RPC JSON third arg is keyword list with `:callback`, `:userData` |

### Locale / Env — `test/desktop/language_code_test.exs`, `env_test.exs` (new)

| ID | Test |
|----|------|
| T-ENV-01 | With Browser backend, `language_code/0` returns without wx (nil or string) |
| T-ENV-02 | `@tag :wx` — after `Env.start_link`, `language_code/0` no `:unknown_env` |
| T-ENV-03 | `wx_use_env/0` when `wx_env` is nil — no raise |
| T-ENV-04 | `@tag :wx` — `init` stores wx + wx_env |

### Window lifecycle — `test/desktop/window_lifecycle_test.exs` (new)

| ID | Test |
|----|------|
| T-WIN-01 | `prepare_url/1` (move/duplicate from `window_test.exs`) |
| T-WIN-02 | `handle_cast :hide` / `:set_title` with nil frame |
| T-WIN-03 | `handle_cast :close_window` all branches (taskbar nil / set, on_close hide/quit) |
| T-WIN-04 | `@tag :wx` — minimal Window `start_link` + cast `:close_window` alive |

### Backend contracts — `test/desktop/backend/*_test.exs` (new)

| File | ID | Scope |
|------|-----|-------|
| `browser_test.exs` | T-BRW-* | Every Window/Content/Notification/Media/System callback — no raise |
| `json_test.exs` | T-JSN-* | Mock transport: new/open/connect/loadURL handle shapes |
| `wx_test.exs` | T-WX-* | `@tag :wx` — open, connect, is_shown?, attach |

### Bridge — `bridge_codec_test.exs` (extend) + `bridge_transport_test.exs` (new)

| ID | Test |
|----|------|
| T-BRG-01 | Codec round-trip (existing) |
| T-BRG-02 | Mock RPC response |
| T-BRG-03 | `subscribe_events` delivers queued ref=0 event |
| T-BRG-04 | Connect cast JSON shape |
| T-BRG-05 | Callback ref=1 invokes registered fun |

### Menu / Fallback — `menu_adapter_test.exs`, `fallback_test.exs` (new)

| ID | Test |
|----|------|
| T-MENU-01 | Browser adapter menubar nil, set_icon ok |
| T-MENU-02 | Json adapter minimal menubar DOM |
| T-FALL-01 | `webview_load` with nil webview does not raise |

### L0 guard — `test/support/guard_boolean_ops.exs` (new)

| ID | Test |
|----|------|
| L0-GUARD | Fail if `lib/desktop/window.ex` matches `if\s+\w+\s+and\s+(not\s+)?` where LHS is not `==` / `!=` |

---

## CI recommendation

```bash
mix test.fast # default PR check
xvfb-run mix test.wx # required for wx-tagged tests
mix test.guard # optional static regression guard
```

Exclude: `test/mix/tasks/desktop.install_test.exs` (pre-existing).

---

## Implementation order

1. Infrastructure (`desktop_case`, `wx_case`, mix aliases)
2. Regression tests (BOOL, CONN) — highest ROI
3. Env + language_code
4. Window lifecycle + close_window
5. Backend contract smoke tests
6. Bridge transport
7. Menu + Fallback
8. L0 guard + AGENTS.md CI docs
Loading
Loading