diff --git a/config/runtime.exs b/config/runtime.exs index 22cdfcb..b16a4c1 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -1,5 +1,11 @@ import Config +# Headless boots (BDS_MODE=server/tui, or desktop mode without a graphical +# display — issue #33) must neuter wx before the :desktop dependency +# application starts it and crashes the VM. Runtime config is the only hook +# that runs before dependency applications start. +BDS.Server.prepare_boot_env() + if config_env() == :prod do database_path = System.get_env("BDS_DATABASE_PATH") || diff --git a/lib/bds/server.ex b/lib/bds/server.ex index 86cd99d..1c5daa4 100644 --- a/lib/bds/server.ex +++ b/lib/bds/server.ex @@ -30,27 +30,52 @@ defmodule BDS.Server do @doc """ The mode the app actually boots in: the resolved `BDS_MODE`, except that - desktop mode falls back to the TUI when no graphical display is available - (issue #33). Starting the app on a display-less Linux server then still - opens a UI — the terminal one — instead of crashing wx. + desktop mode falls back to the TUI when no graphical session is available + (issue #33). Starting the app anywhere then still opens a UI — graphical + when possible, the terminal one otherwise. - Only non-Darwin unix systems fall back: they need `DISPLAY` or - `WAYLAND_DISPLAY` to run wx, while macOS and Windows always have a - graphical session for a launched app. + Per platform: non-Darwin unix needs `DISPLAY` or `WAYLAND_DISPLAY` to run + wx — that check also covers plain ssh sessions, while `ssh -X` with a + forwarded display keeps the GUI. macOS never sets `DISPLAY`, so there an + ssh session (`SSH_CONNECTION`/`SSH_CLIENT`/`SSH_TTY`) is the headless + signal and a local launch always has a graphical session. Windows always + boots the GUI. """ @spec effective_mode(:desktop | :server | :tui, {atom(), atom()}, %{ optional(String.t()) => String.t() }) :: :desktop | :server | :tui def effective_mode(mode \\ mode(), os_type \\ :os.type(), env \\ System.get_env()) - def effective_mode(:desktop, {:unix, os}, env) when os != :darwin do - if Enum.any?(["DISPLAY", "WAYLAND_DISPLAY"], &(env[&1] not in [nil, ""])), - do: :desktop, - else: :tui + def effective_mode(:desktop, {:unix, :darwin}, env) do + if env_any?(env, ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]), do: :tui, else: :desktop + end + + def effective_mode(:desktop, {:unix, _os}, env) do + if env_any?(env, ["DISPLAY", "WAYLAND_DISPLAY"]), do: :desktop, else: :tui end def effective_mode(mode, _os_type, _env), do: mode + defp env_any?(env, vars), do: Enum.any?(vars, &(env[&1] not in [nil, ""])) + + @doc """ + Prepares the OS environment for the given effective boot mode and returns + it. The `:desktop` dependency application boots wx inside its own + `Application.start/2` — before any BDS supervisor runs — and that wx load + crashes the whole VM on a display-less system. For every non-desktop mode + this sets `NO_WX=1`, elixir-desktop's own switch that turns all of its wx + calls into no-ops, so the dependency starts inert and the TUI fallback + (issue #33) can actually boot. + + Called from `config/runtime.exs`: runtime config is the only hook that + runs before dependency applications start, in both `mix run` and releases. + """ + @spec prepare_boot_env(:desktop | :server | :tui) :: :desktop | :server | :tui + def prepare_boot_env(mode \\ effective_mode()) do + if mode != :desktop, do: System.put_env("NO_WX", "1") + mode + end + @doc """ Whether the HTTP endpoint requires the desktop webview auth token. Only desktop mode does: in server/tui mode the endpoint stays loopback-only diff --git a/specs/server.allium b/specs/server.allium index 205e106..d04996a 100644 --- a/specs/server.allium +++ b/specs/server.allium @@ -31,11 +31,17 @@ surface ServerRuntimeSurface { rule ModeResolution { when: ApplicationStarted(mode) -- BDS_MODE environment variable decides the mode once per boot. - -- Fallback (issue #33): resolved desktop mode on a non-Darwin unix - -- system without DISPLAY/WAYLAND_DISPLAY boots as tui instead, so a - -- display-less Linux server still opens a UI rather than crashing wx; - -- a log line records the switch. Explicit server/tui modes and - -- macOS/Windows are never rewritten. + -- Fallback (issue #33): resolved desktop mode boots as tui when no + -- graphical session is available, so the app always opens a UI + -- rather than crashing wx; a log line records the switch. Headless + -- means: non-Darwin unix without DISPLAY/WAYLAND_DISPLAY (plain ssh + -- included; ssh -X with a forwarded display keeps the GUI), or macOS + -- inside an ssh session (SSH_CONNECTION/SSH_CLIENT/SSH_TTY — macOS + -- never sets DISPLAY). Explicit server/tui modes and Windows are + -- never rewritten. Because the :desktop dependency boots wx in its + -- own application start, config/runtime.exs sets NO_WX=1 for every + -- non-desktop effective mode (BDS.Server.prepare_boot_env) so the + -- dependency starts inert before any BDS supervisor runs. ensures: BootMode.created(kind: mode) } diff --git a/test/bds/server_test.exs b/test/bds/server_test.exs index b370683..1a774ae 100644 --- a/test/bds/server_test.exs +++ b/test/bds/server_test.exs @@ -37,9 +37,35 @@ defmodule BDS.ServerTest do assert Server.effective_mode(:desktop, {:unix, :freebsd}, %{"WAYLAND_DISPLAY" => ""}) == :tui end - test "macOS and Windows never fall back" do + test "macOS keeps the GUI locally but falls back to the TUI over ssh" do + # macOS has no DISPLAY variable; a local launch always has a + # graphical session, an ssh session never does. assert Server.effective_mode(:desktop, {:unix, :darwin}, %{}) == :desktop + + assert Server.effective_mode(:desktop, {:unix, :darwin}, %{ + "SSH_CONNECTION" => "10.0.0.5 52000 10.0.0.9 22" + }) == :tui + + assert Server.effective_mode(:desktop, {:unix, :darwin}, %{"SSH_TTY" => "/dev/ttys003"}) == + :tui + + assert Server.effective_mode(:desktop, {:unix, :darwin}, %{"SSH_CLIENT" => "10.0.0.5"}) == + :tui + + assert Server.effective_mode(:desktop, {:unix, :darwin}, %{"SSH_CONNECTION" => ""}) == + :desktop + end + + test "linux over ssh with a forwarded display keeps the GUI" do + env = %{"SSH_CONNECTION" => "10.0.0.5 52000 10.0.0.9 22", "DISPLAY" => "localhost:10.0"} + assert Server.effective_mode(:desktop, {:unix, :linux}, env) == :desktop + end + + test "Windows never falls back" do assert Server.effective_mode(:desktop, {:win32, :nt}, %{}) == :desktop + + assert Server.effective_mode(:desktop, {:win32, :nt}, %{"SSH_CONNECTION" => "10.0.0.5"}) == + :desktop end test "explicit server and tui modes are untouched" do @@ -49,6 +75,33 @@ defmodule BDS.ServerTest do end end + describe "prepare_boot_env/1" do + setup do + original = System.get_env("NO_WX") + System.delete_env("NO_WX") + + on_exit(fn -> + if original, do: System.put_env("NO_WX", original), else: System.delete_env("NO_WX") + end) + + :ok + end + + test "non-desktop modes disable wx so the :desktop dep boots inert" do + assert Server.prepare_boot_env(:tui) == :tui + assert System.get_env("NO_WX") == "1" + + System.delete_env("NO_WX") + assert Server.prepare_boot_env(:server) == :server + assert System.get_env("NO_WX") == "1" + end + + test "desktop mode leaves wx enabled" do + assert Server.prepare_boot_env(:desktop) == :desktop + assert System.get_env("NO_WX") == nil + end + end + describe "ensure_ssh_dir!/1" do setup do dir = Path.join(System.tmp_dir!(), "bds-ssh-test-#{System.unique_integer([:positive])}")