fix: set NO_WX before dependency boot and detect headless sessions per platform so the TUI fallback actually works (issue #33)

This commit is contained in:
2026-07-16 18:13:15 +02:00
parent 381deb417d
commit 59333ac920
4 changed files with 106 additions and 16 deletions

View File

@@ -1,5 +1,11 @@
import Config 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 if config_env() == :prod do
database_path = database_path =
System.get_env("BDS_DATABASE_PATH") || System.get_env("BDS_DATABASE_PATH") ||

View File

@@ -30,27 +30,52 @@ defmodule BDS.Server do
@doc """ @doc """
The mode the app actually boots in: the resolved `BDS_MODE`, except that 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 desktop mode falls back to the TUI when no graphical session is available
(issue #33). Starting the app on a display-less Linux server then still (issue #33). Starting the app anywhere then still opens a UI — graphical
opens a UI — the terminal one — instead of crashing wx. when possible, the terminal one otherwise.
Only non-Darwin unix systems fall back: they need `DISPLAY` or Per platform: non-Darwin unix needs `DISPLAY` or `WAYLAND_DISPLAY` to run
`WAYLAND_DISPLAY` to run wx, while macOS and Windows always have a wx — that check also covers plain ssh sessions, while `ssh -X` with a
graphical session for a launched app. 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()}, %{ @spec effective_mode(:desktop | :server | :tui, {atom(), atom()}, %{
optional(String.t()) => String.t() optional(String.t()) => String.t()
}) :: :desktop | :server | :tui }) :: :desktop | :server | :tui
def effective_mode(mode \\ mode(), os_type \\ :os.type(), env \\ System.get_env()) def effective_mode(mode \\ mode(), os_type \\ :os.type(), env \\ System.get_env())
def effective_mode(:desktop, {:unix, os}, env) when os != :darwin do def effective_mode(:desktop, {:unix, :darwin}, env) do
if Enum.any?(["DISPLAY", "WAYLAND_DISPLAY"], &(env[&1] not in [nil, ""])), if env_any?(env, ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]), do: :tui, else: :desktop
do: :desktop, end
else: :tui
def effective_mode(:desktop, {:unix, _os}, env) do
if env_any?(env, ["DISPLAY", "WAYLAND_DISPLAY"]), do: :desktop, else: :tui
end end
def effective_mode(mode, _os_type, _env), do: mode 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 """ @doc """
Whether the HTTP endpoint requires the desktop webview auth token. Only Whether the HTTP endpoint requires the desktop webview auth token. Only
desktop mode does: in server/tui mode the endpoint stays loopback-only desktop mode does: in server/tui mode the endpoint stays loopback-only

View File

@@ -31,11 +31,17 @@ surface ServerRuntimeSurface {
rule ModeResolution { rule ModeResolution {
when: ApplicationStarted(mode) when: ApplicationStarted(mode)
-- BDS_MODE environment variable decides the mode once per boot. -- BDS_MODE environment variable decides the mode once per boot.
-- Fallback (issue #33): resolved desktop mode on a non-Darwin unix -- Fallback (issue #33): resolved desktop mode boots as tui when no
-- system without DISPLAY/WAYLAND_DISPLAY boots as tui instead, so a -- graphical session is available, so the app always opens a UI
-- display-less Linux server still opens a UI rather than crashing wx; -- rather than crashing wx; a log line records the switch. Headless
-- a log line records the switch. Explicit server/tui modes and -- means: non-Darwin unix without DISPLAY/WAYLAND_DISPLAY (plain ssh
-- macOS/Windows are never rewritten. -- 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) ensures: BootMode.created(kind: mode)
} }

View File

@@ -37,9 +37,35 @@ defmodule BDS.ServerTest do
assert Server.effective_mode(:desktop, {:unix, :freebsd}, %{"WAYLAND_DISPLAY" => ""}) == :tui assert Server.effective_mode(:desktop, {:unix, :freebsd}, %{"WAYLAND_DISPLAY" => ""}) == :tui
end 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}, %{}) == :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}, %{}) == :desktop
assert Server.effective_mode(:desktop, {:win32, :nt}, %{"SSH_CONNECTION" => "10.0.0.5"}) ==
:desktop
end end
test "explicit server and tui modes are untouched" do test "explicit server and tui modes are untouched" do
@@ -49,6 +75,33 @@ defmodule BDS.ServerTest do
end end
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 describe "ensure_ssh_dir!/1" do
setup do setup do
dir = Path.join(System.tmp_dir!(), "bds-ssh-test-#{System.unique_integer([:positive])}") dir = Path.join(System.tmp_dir!(), "bds-ssh-test-#{System.unique_integer([:positive])}")