Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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