From ec1105283659caae9ab888477d94107fecdf0cff Mon Sep 17 00:00:00 2001 From: Adam Wight Date: Tue, 28 Oct 2025 21:37:13 -0700 Subject: [PATCH 1/8] Stub igniter installer --- lib/mix/tasks/desktop.install.ex | 47 +++++++++++++++++++++++++ mix.exs | 3 +- test/mix/tasks/desktop.install_test.exs | 12 +++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 lib/mix/tasks/desktop.install.ex create mode 100644 test/mix/tasks/desktop.install_test.exs diff --git a/lib/mix/tasks/desktop.install.ex b/lib/mix/tasks/desktop.install.ex new file mode 100644 index 0000000..1aef54e --- /dev/null +++ b/lib/mix/tasks/desktop.install.ex @@ -0,0 +1,47 @@ +defmodule Mix.Tasks.Desktop.Install do + @shortdoc "Add Elixir Desktop support to a project" + + @moduledoc """ + #{@shortdoc} + """ + + use Igniter.Mix.Task + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + # Groups allow for overlapping arguments for tasks by the same author + # See the generators guide for more. + group: :desktop, + # *other* dependencies to add + # i.e `{:foo, "~> 2.0"}` + adds_deps: [], + # *other* dependencies to add and call their associated installers, if they exist + # i.e `{:foo, "~> 2.0"}` + installs: [], + # An example invocation + # example: __MODULE__.Docs.example(), + # A list of environments that this should be installed in. + only: nil, + # a list of positional arguments, i.e `[:file]` + positional: [], + # Other tasks your task composes using `Igniter.compose_task`, passing in the CLI argv + # This ensures your option schema includes options from nested tasks + composes: [], + # `OptionParser` schema + schema: [], + # Default values for the options in the `schema` + defaults: [], + # CLI aliases + aliases: [], + # A list of options in the schema that are required + required: [] + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + igniter + |> Igniter.compose_task("igniter.add", ["desktop"]) + end +end diff --git a/mix.exs b/mix.exs index 8a7b2e7..f6d7cfb 100644 --- a/mix.exs +++ b/mix.exs @@ -86,7 +86,8 @@ defmodule Desktop.MixProject do {:phoenix, "> 1.7.10"}, {:phoenix_live_view, "> 1.0.0"}, {:plug, "> 1.0.0"}, - {:gettext, "> 0.10.0"} + {:gettext, "> 0.10.0"}, + {:igniter, "~> 0.6", only: [:dev, :test]} ] if Mix.target() in [:android, :ios] do diff --git a/test/mix/tasks/desktop.install_test.exs b/test/mix/tasks/desktop.install_test.exs new file mode 100644 index 0000000..366b0b4 --- /dev/null +++ b/test/mix/tasks/desktop.install_test.exs @@ -0,0 +1,12 @@ +defmodule Mix.Tasks.Desktop.InstallTest do + use ExUnit.Case, async: true + import Igniter.Test + + test "it installs desktop dependency" do + test_project() + |> Igniter.compose_task("desktop.install", []) + |> assert_has_patch("mix.exs", """ + + | {:desktop, + """) + end +end From 8188cc290bea708209d8f301e0c58f2bbd049738 Mon Sep 17 00:00:00 2001 From: Adam Wight Date: Tue, 28 Oct 2025 22:34:10 -0700 Subject: [PATCH 2/8] Add Desktop.Window child application --- lib/mix/tasks/desktop.install.ex | 23 +++++++++++++++++++++++ mix.exs | 4 +++- test/mix/tasks/desktop.install_test.exs | 19 +++++++++++++++++-- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/lib/mix/tasks/desktop.install.ex b/lib/mix/tasks/desktop.install.ex index 1aef54e..c30deff 100644 --- a/lib/mix/tasks/desktop.install.ex +++ b/lib/mix/tasks/desktop.install.ex @@ -41,7 +41,30 @@ defmodule Mix.Tasks.Desktop.Install do @impl Igniter.Mix.Task def igniter(igniter) do + app = Igniter.Project.Application.app_name(igniter) + endpoint = Igniter.Libs.Phoenix.web_module_name(igniter, "Endpoint") + igniter |> Igniter.compose_task("igniter.add", ["desktop"]) + |> Igniter.Project.Application.add_new_child( + { + Desktop.Window, + [ + app: app, + id: Igniter.Project.Module.module_name(igniter, MainWindow), + # FIXME: configurable + title: to_string(app), + size: {600, 500}, + icon: "icon.png", + menubar: Igniter.Project.Module.module_name(igniter, MenuBar), + icon_menu: Igniter.Project.Module.module_name(igniter, Menu), + url: {endpoint, :url, []} + ] + }, + after: [endpoint] + ) + + # TODO: detect and warn if the project assumes pgsql + # TODO: create MyApp.MenuBar end end diff --git a/mix.exs b/mix.exs index f6d7cfb..3a2ee09 100644 --- a/mix.exs +++ b/mix.exs @@ -87,7 +87,9 @@ defmodule Desktop.MixProject do {:phoenix_live_view, "> 1.0.0"}, {:plug, "> 1.0.0"}, {:gettext, "> 0.10.0"}, - {:igniter, "~> 0.6", only: [:dev, :test]} + # FIXME: dependency should not be needed in production + # {:igniter, "~> 0.6", only: [:dev, :test]} + {:igniter, "~> 0.6"} ] if Mix.target() in [:android, :ios] do diff --git a/test/mix/tasks/desktop.install_test.exs b/test/mix/tasks/desktop.install_test.exs index 366b0b4..2761d84 100644 --- a/test/mix/tasks/desktop.install_test.exs +++ b/test/mix/tasks/desktop.install_test.exs @@ -2,11 +2,26 @@ defmodule Mix.Tasks.Desktop.InstallTest do use ExUnit.Case, async: true import Igniter.Test - test "it installs desktop dependency" do - test_project() + test "it installs desktop dependency and window" do + phx_test_project(app_name: :my_app) |> Igniter.compose_task("desktop.install", []) |> assert_has_patch("mix.exs", """ + | {:desktop, """) + |> assert_has_patch("lib/my_app/application.ex", """ + - | MyAppWeb.Endpoint + + | MyAppWeb.Endpoint, + + | {Desktop.Window, + + | [ + + | app: :my_app, + + | id: MyApp.MainWindow, + + | title: "my_app", + + | size: {600, 500}, + + | icon: "icon.png", + + | menubar: MyApp.MenuBar, + + | icon_menu: MyApp.Menu, + + | url: {MyAppWeb.Endpoint, :url, []} + + | ]} + """) end end From bdc5c31c9d09ad91cc1af7a5d889dbc0c3c91e1d Mon Sep 17 00:00:00 2001 From: Adam Wight Date: Wed, 29 Oct 2025 07:19:05 -0700 Subject: [PATCH 3/8] Glue up enough installer to launch a desktop window to the app Bootstrapping needs to be documented, and will be easier once the installer is included in a published release of elixir-desktop. For now, add the {:desktop, path: to_dev_directory} dependency manually and then run `mix desktop.install` in the target project. --- lib/mix/tasks/desktop.install.ex | 89 +++++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 7 deletions(-) diff --git a/lib/mix/tasks/desktop.install.ex b/lib/mix/tasks/desktop.install.ex index c30deff..1ea085b 100644 --- a/lib/mix/tasks/desktop.install.ex +++ b/lib/mix/tasks/desktop.install.ex @@ -43,28 +43,103 @@ defmodule Mix.Tasks.Desktop.Install do def igniter(igniter) do app = Igniter.Project.Application.app_name(igniter) endpoint = Igniter.Libs.Phoenix.web_module_name(igniter, "Endpoint") + menu = Igniter.Project.Module.module_name(igniter, "Menu") + menubar = Igniter.Project.Module.module_name(igniter, "MenuBar") + gettext = Igniter.Libs.Phoenix.web_module_name(igniter, "Gettext") + main_window = Igniter.Project.Module.module_name(igniter, MainWindow) igniter |> Igniter.compose_task("igniter.add", ["desktop"]) + |> Igniter.Project.Module.create_module(menubar, """ + @moduledoc """ + Menu bar that is shown as part of the main window on Windows/Linux. In + MacOS this menu bar appears at the very top of the screen. + \""" + use Gettext, backend: #{gettext} + use Desktop.Menu + alias Desktop.Window + + def render(assigns) do + ~H""" + + + {gettext "Quit"} + + + {gettext "Show Observer"} + {gettext "Open Browser"} + + + \""" + end + + def handle_event("quit", menu) do + Window.quit() + {:noreply, menu} + end + + def handle_event("observer", menu) do + :observer.start() + {:noreply, menu} + end + + def handle_event("browser", menu) do + Window.prepare_url(#{endpoint}.url()) + |> :wx_misc.launchDefaultBrowser() + + {:noreply, menu} + end + + def mount(menu) do + {:ok, menu} + end + """) + |> Igniter.Project.Module.create_module(menu, """ + @moduledoc """ + Menu that is shown when a user clicks on the taskbar icon of the #{app} + \""" + use Gettext, backend: #{gettext} + use Desktop.Menu + + def render(assigns) do + ~H""" + + {gettext "Open"} +
+ {gettext "Quit"} +
+ \""" + end + + def handle_event(command, menu) do + case command do + <<"quit">> -> Desktop.Window.quit() + <<"edit">> -> Desktop.Window.show(#{main_window}) + end + + {:noreply, menu} + end + + def mount(menu) do + {:ok, menu} + end + """) |> Igniter.Project.Application.add_new_child( { Desktop.Window, [ app: app, id: Igniter.Project.Module.module_name(igniter, MainWindow), - # FIXME: configurable title: to_string(app), size: {600, 500}, - icon: "icon.png", - menubar: Igniter.Project.Module.module_name(igniter, MenuBar), - icon_menu: Igniter.Project.Module.module_name(igniter, Menu), - url: {endpoint, :url, []} + # icon: "icon.png", # TODO: ship an example taskbar icon here + menubar: menubar, + icon_menu: menu, + url: fn -> apply(endpoint, :url, []) end ] }, after: [endpoint] ) - # TODO: detect and warn if the project assumes pgsql - # TODO: create MyApp.MenuBar end end From fd25c88ed0361e53a9d500e68a06cdd69ac1a73a Mon Sep 17 00:00:00 2001 From: Adam Wight Date: Thu, 30 Oct 2025 06:07:38 -0700 Subject: [PATCH 4/8] Fix quoting; simplify and clean up code Use quote/unquote to wire dynamic values into generated code. Make the installer idempotent, safe to run twice. Remove unused bits. --- lib/mix/tasks/desktop.install.ex | 225 ++++++++++++++++--------------- 1 file changed, 117 insertions(+), 108 deletions(-) diff --git a/lib/mix/tasks/desktop.install.ex b/lib/mix/tasks/desktop.install.ex index 1ea085b..b54b60a 100644 --- a/lib/mix/tasks/desktop.install.ex +++ b/lib/mix/tasks/desktop.install.ex @@ -12,30 +12,7 @@ defmodule Mix.Tasks.Desktop.Install do %Igniter.Mix.Task.Info{ # Groups allow for overlapping arguments for tasks by the same author # See the generators guide for more. - group: :desktop, - # *other* dependencies to add - # i.e `{:foo, "~> 2.0"}` - adds_deps: [], - # *other* dependencies to add and call their associated installers, if they exist - # i.e `{:foo, "~> 2.0"}` - installs: [], - # An example invocation - # example: __MODULE__.Docs.example(), - # A list of environments that this should be installed in. - only: nil, - # a list of positional arguments, i.e `[:file]` - positional: [], - # Other tasks your task composes using `Igniter.compose_task`, passing in the CLI argv - # This ensures your option schema includes options from nested tasks - composes: [], - # `OptionParser` schema - schema: [], - # Default values for the options in the `schema` - defaults: [], - # CLI aliases - aliases: [], - # A list of options in the schema that are required - required: [] + group: :desktop } end @@ -50,96 +27,128 @@ defmodule Mix.Tasks.Desktop.Install do igniter |> Igniter.compose_task("igniter.add", ["desktop"]) - |> Igniter.Project.Module.create_module(menubar, """ - @moduledoc """ - Menu bar that is shown as part of the main window on Windows/Linux. In - MacOS this menu bar appears at the very top of the screen. - \""" - use Gettext, backend: #{gettext} - use Desktop.Menu - alias Desktop.Window - - def render(assigns) do - ~H""" - - - {gettext "Quit"} - - - {gettext "Show Observer"} - {gettext "Open Browser"} - - + |> add_menu_bar(menubar, gettext, endpoint) + |> add_menu(menu, gettext, app, main_window) + |> Igniter.Project.Application.add_new_child( + { + Desktop.Window, + {:code, + quote do + [ + app: unquote(app), + id: unquote(Igniter.Project.Module.module_name(igniter, MainWindow)), + title: unquote(to_string(app)), + size: {600, 500}, + # icon: "icon.png", # TODO: ship an example taskbar icon here + menubar: unquote(menubar), + icon_menu: unquote(menu), + url: &unquote(endpoint).url/0 + ] + end} + }, + after: [endpoint] + ) + + # TODO: Add runtime_tools observer if available, or optionall add the dependency + end + + defp add_menu_bar(igniter, menubar, gettext, endpoint) do + Igniter.Project.Module.find_and_update_or_create_module( + igniter, + menubar, + """ + @moduledoc \""" + Menu bar that is shown as part of the main window on Windows/Linux. In + MacOS this menu bar appears at the very top of the screen. \""" - end - - def handle_event("quit", menu) do - Window.quit() - {:noreply, menu} - end - - def handle_event("observer", menu) do - :observer.start() - {:noreply, menu} - end - - def handle_event("browser", menu) do - Window.prepare_url(#{endpoint}.url()) - |> :wx_misc.launchDefaultBrowser() - - {:noreply, menu} - end - - def mount(menu) do - {:ok, menu} - end - """) - |> Igniter.Project.Module.create_module(menu, """ - @moduledoc """ - Menu that is shown when a user clicks on the taskbar icon of the #{app} - \""" - use Gettext, backend: #{gettext} - use Desktop.Menu - - def render(assigns) do - ~H""" - - {gettext "Open"} -
- {gettext "Quit"} -
+ use Gettext, backend: #{inspect(gettext)} + use Desktop.Menu + alias Desktop.Window + + @impl true + def render(assigns) do + ~H\""" + + + {gettext "Quit"} + + + {gettext "Open Browser"} + + + \""" + end + + @impl true + def handle_event("quit", menu) do + Window.quit() + {:noreply, menu} + end + + def handle_event("browser", menu) do + Window.prepare_url(#{inspect(endpoint)}.url()) + |> :wx_misc.launchDefaultBrowser() + + {:noreply, menu} + end + + @impl true + def handle_info(_, menu) do + {:noreply, menu} + end + + @impl true + def mount(menu) do + {:ok, menu} + end + """, + fn zipper -> {:ok, zipper} end + ) + end + + defp add_menu(igniter, menu, gettext, app, main_window) do + Igniter.Project.Module.find_and_update_or_create_module( + igniter, + menu, + """ + @moduledoc \""" + Menu that is shown when a user clicks on the taskbar icon of the #{app} \""" - end + use Gettext, backend: #{inspect(gettext)} + use Desktop.Menu - def handle_event(command, menu) do - case command do - <<"quit">> -> Desktop.Window.quit() - <<"edit">> -> Desktop.Window.show(#{main_window}) + @impl true + def render(assigns) do + ~H\""" + + {gettext "Open"} +
+ {gettext "Quit"} +
+ \""" end - {:noreply, menu} - end + @impl true + def handle_event(command, menu) do + case command do + <<"quit">> -> Desktop.Window.quit() + <<"edit">> -> Desktop.Window.show(#{inspect(main_window)}) + end - def mount(menu) do - {:ok, menu} - end - """) - |> Igniter.Project.Application.add_new_child( - { - Desktop.Window, - [ - app: app, - id: Igniter.Project.Module.module_name(igniter, MainWindow), - title: to_string(app), - size: {600, 500}, - # icon: "icon.png", # TODO: ship an example taskbar icon here - menubar: menubar, - icon_menu: menu, - url: fn -> apply(endpoint, :url, []) end - ] - }, - after: [endpoint] + {:noreply, menu} + end + + @impl true + def handle_info(_, menu) do + {:noreply, menu} + end + + @impl true + def mount(menu) do + {:ok, menu} + end + """, + fn zipper -> {:ok, zipper} end ) - # TODO: detect and warn if the project assumes pgsql end end From e2fe3be58392610b07b83f763835b1b223e41d3f Mon Sep 17 00:00:00 2001 From: Adam Wight Date: Thu, 30 Oct 2025 06:16:04 -0700 Subject: [PATCH 5/8] Update tests --- test/mix/tasks/desktop.install_test.exs | 27 +++++++++++++------------ 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/test/mix/tasks/desktop.install_test.exs b/test/mix/tasks/desktop.install_test.exs index 2761d84..f978373 100644 --- a/test/mix/tasks/desktop.install_test.exs +++ b/test/mix/tasks/desktop.install_test.exs @@ -9,19 +9,20 @@ defmodule Mix.Tasks.Desktop.InstallTest do + | {:desktop, """) |> assert_has_patch("lib/my_app/application.ex", """ - - | MyAppWeb.Endpoint - + | MyAppWeb.Endpoint, - + | {Desktop.Window, - + | [ - + | app: :my_app, - + | id: MyApp.MainWindow, - + | title: "my_app", - + | size: {600, 500}, - + | icon: "icon.png", - + | menubar: MyApp.MenuBar, - + | icon_menu: MyApp.Menu, - + | url: {MyAppWeb.Endpoint, :url, []} - + | ]} + - | MyAppWeb.Endpoint + + | MyAppWeb.Endpoint, + + | {Desktop.Window, + + | [ + + | app: :my_app, + + | id: MyApp.MainWindow, + + | title: "my_app", + + | size: {600, 500}, + + | menubar: MyApp.MenuBar, + + | icon_menu: MyApp.Menu, + + | url: &MyAppWeb.Endpoint.url/0 + + | ]} """) + |> Igniter.Test.assert_creates("lib/my_app/menu.ex") + |> Igniter.Test.assert_creates("lib/my_app/menu_bar.ex") end end From abada31f8650f39da66bdce187ebd83d3b62cae6 Mon Sep 17 00:00:00 2001 From: Adam Wight Date: Thu, 30 Oct 2025 06:29:29 -0700 Subject: [PATCH 6/8] Add minimal docs --- lib/mix/tasks/desktop.install.ex | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/mix/tasks/desktop.install.ex b/lib/mix/tasks/desktop.install.ex index b54b60a..8ed5e09 100644 --- a/lib/mix/tasks/desktop.install.ex +++ b/lib/mix/tasks/desktop.install.ex @@ -1,8 +1,32 @@ defmodule Mix.Tasks.Desktop.Install do - @shortdoc "Add Elixir Desktop support to a project" + @shortdoc "Add Elixir Desktop support to an existing project" @moduledoc """ #{@shortdoc} + + This mix task adds the minimal glue to launch an Elixir Desktop + main window for your application. + + To use this task, you'll either need to install Igniter globally, or + manually add the desktop dependency to your project's mix.exs: + + ``` + {:desktop, "~> 1.0"} + ``` + + ## Examples + + Add desktop support to a project: + + ```bash + mix desktop.install + ``` + + Create a new project with desktop support: + + ```bash + mix igniter.new --install desktop --with phx.new + ``` """ use Igniter.Mix.Task From 675f8dea629a38f7d5e0ef6089e07e248f01c192 Mon Sep 17 00:00:00 2001 From: Adam Wight Date: Thu, 30 Oct 2025 06:37:46 -0700 Subject: [PATCH 7/8] Finish documentation for full project generation Note that this won't work until the mix task is in a published release of desktop. --- lib/mix/tasks/desktop.install.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/mix/tasks/desktop.install.ex b/lib/mix/tasks/desktop.install.ex index 8ed5e09..332bb91 100644 --- a/lib/mix/tasks/desktop.install.ex +++ b/lib/mix/tasks/desktop.install.ex @@ -25,7 +25,9 @@ defmodule Mix.Tasks.Desktop.Install do Create a new project with desktop support: ```bash - mix igniter.new --install desktop --with phx.new + mix archive.install hex igniter_new + + mix igniter.new my_app --install desktop --with phx.new ``` """ From c4285365633278fb67c7a6eb02f43375fa01c618 Mon Sep 17 00:00:00 2001 From: Adam Wight Date: Thu, 30 Oct 2025 09:06:02 -0700 Subject: [PATCH 8/8] Include igniter but not in production This is untested, the hope is that `mix desktop.install` still works but the dependency doesn't carry through. --- mix.exs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index 3a2ee09..ff3bc0d 100644 --- a/mix.exs +++ b/mix.exs @@ -87,9 +87,7 @@ defmodule Desktop.MixProject do {:phoenix_live_view, "> 1.0.0"}, {:plug, "> 1.0.0"}, {:gettext, "> 0.10.0"}, - # FIXME: dependency should not be needed in production - # {:igniter, "~> 0.6", only: [:dev, :test]} - {:igniter, "~> 0.6"} + {:igniter, "~> 0.6", optional: true} ] if Mix.target() in [:android, :ios] do