diff --git a/README.md b/README.md index 6d6de94..545af58 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,35 @@ end ### Windows -> NSIS -All builds (specifically NIFs) are built using msys2, because it's mostly linux compatible but runs natively on windows without any helper libraries. +**Default packaging uses host-first layout** (native host binary owns the process; Mix release is a child under `beam/`): + +``` +MyApp/ # NSIS $INSTDIR + MyApp.exe # native host (package.name.exe / package.host_executable) + MyApp.ini # generated host config (.ini) + beam/ # Mix release (package.release_subdir, default "beam") + bin/... + erts-.../ + lib/... + releases/... + MicrosoftEdgeWebview2Setup.exe + vcredist_x64.exe +``` + +Locate the host binary via (first match wins): + +1. `package.host_binary` +2. `DESKTOP_HOST_BINARY` (or legacy `DESKTOP_WEBVIEW_BINARY`) +3. Convention discovery: any Mix dependency that ships `priv/native/windows/.exe` + +The installed host filename defaults to `package.name` + `.exe` (override with +`package.host_executable`). Deployment does not hardcode a particular webview +package. Apps that use elixir-desktop/webview simply ship that binary under the +convention above (or set `host_binary`). + +Set `windows_layout: :release_first` in `package()` to keep the legacy flat Mix release with `run.vbs`/`run.bat` + heart (OTP `:wx`). Deprecated alias: `webview_backend: :wx`. + +All builds (specifically NIFs) are built using msys2, because it's mostly linux compatible but runs natively on windows without any helper libraries. 0) Installing prerequsites - msys2.org @@ -65,11 +93,13 @@ All builds (specifically NIFs) are built using msys2, because it's mostly linux 1) `mix deployment` will generate the release binaries -To support windows code signing the user has to create two certificate files `app_key.pem` and `app_key.pem` (e.g. get from sectigo) and put them into the `rel/win32/` subdirectory. +Windows PE metadata (default icon, manifest, version info, checksum) is applied with **libpe** (`mix pe.update`). ImageMagick (`magick convert`) still resizes `package.icon` (PNG) to `icon.ico` before embedding. WinRun4J `rcedit.exe` is no longer used. + +To support windows code signing the user has to create two certificate files `app_key.pem` and `app_key.pem` (e.g. get from sectigo) and put them into the `rel/win32/` subdirectory. #### Known Issues / Comments -* The `.vbs` file is used as indirection for the `.bat` file as it avoid creating a black terminal screen that otherwise flashes shortly when launchin a `.bat` file directly +* For `:release_first`, the `.vbs` file is used as indirection for the `.bat` file as it avoid creating a black terminal screen that otherwise flashes shortly when launchin a `.bat` file directly * The `.nsis` file currently registers a `app://` protocol handler, this is example use and can be removed for other apps. diff --git a/lib/deployment.ex b/lib/deployment.ex index c90aea1..1fc6852 100644 --- a/lib/deployment.ex +++ b/lib/deployment.ex @@ -29,24 +29,31 @@ defmodule Desktop.Deployment do defp expand_package_opts(opts) when is_list(opts) do opts = Map.new(opts) - layout = + default_layout = + case Map.get(opts, :webview_backend) do + :wx -> :release_first + :desktop_webview -> :host_first + _ -> :host_first + end + + macos_layout = case Map.fetch(opts, :macos_layout) do - {:ok, layout} -> - layout - - :error -> - case Map.get(opts, :webview_backend) do - :wx -> :release_first - :desktop_webview -> :host_first - _ -> :host_first - end + {:ok, layout} -> layout + :error -> default_layout + end + + windows_layout = + case Map.fetch(opts, :windows_layout) do + {:ok, layout} -> layout + :error -> default_layout end host_binary = Map.get(opts, :host_binary) || Map.get(opts, :webview_binary) opts |> Map.drop([:webview_backend, :webview_binary]) - |> Map.put(:macos_layout, layout) + |> Map.put(:macos_layout, macos_layout) + |> Map.put(:windows_layout, windows_layout) |> Map.put(:host_binary, host_binary) |> Map.to_list() end diff --git a/lib/package.ex b/lib/package.ex index 621cb2f..05295c4 100644 --- a/lib/package.ex +++ b/lib/package.ex @@ -25,13 +25,21 @@ defmodule Desktop.Deployment.Package do # Contents/Resources// (e.g. beam/) # :release_first — Mix release under Contents/Resources/, optional MacOS/run macos_layout: :host_first, + # Windows NSIS $INSTDIR layout: + # :host_first — native host exe + ini at package root, Mix release under + # / (e.g. beam/); no run.vbs/heart + # :release_first — flat Mix release with run.vbs/run.bat + heart (legacy wx) + windows_layout: :host_first, # Absolute path to the native host binary (host_first). Prefer # package.host_binary or DESKTOP_HOST_BINARY; otherwise any Mix dep that - # ships priv/native/macos/ is discovered by convention. + # ships priv/native/macos/ or priv/native/windows/.exe is + # discovered by convention. host_binary: nil, - # Basename to install as Contents/MacOS/ (defaults to host basename) + # Basename to install as MacOS/ or .exe at package root + # (defaults to host basename) host_executable: nil, - # Subdirectory under Contents/Resources for the Mix release (host_first) + # Subdirectory for the Mix release under Resources/ (macOS) or package + # root (Windows) when using host_first release_subdir: "beam", # OTP app name written into the host config for starting the release beam_app_name: nil, @@ -103,15 +111,22 @@ defmodule Desktop.Deployment.Package do file_replace(bin, "Erlang", binary_part(pkg.name <> <<0, 0, 0, 0, 0, 0>>, 0, 6)) cmd!(toolpath("rel/win32/rcedit.exe"), ["/I", bin, icon]) - :ok = - Mix.Tasks.Pe.Update.run( + # Host-first: keep the CUI subsystem so OTP 26's user/logger get a console when + # DesktopWebView spawns the release (GUI subsystem causes nouser / invalid handle). + # Release-first: mark GUI so launching erl.exe does not flash a console window. + pe_args = + if pkg.windows_layout == :host_first do + ["--set-manifest", Path.join(build_root, "app.exe.manifest")] + else [ "--set-subsystem", "IMAGE_SUBSYSTEM_WINDOWS_GUI", "--set-manifest", Path.join(build_root, "app.exe.manifest") - ] ++ info ++ [bin] - ) + ] + end + + :ok = Mix.Tasks.Pe.Update.run(pe_args ++ info ++ [bin]) end [elixir] = wildcard(rel, "**/elixir.bat") @@ -146,9 +161,16 @@ defmodule Desktop.Deployment.Package do File.rm!(name) end - cp!(toolpath("rel/win32/run.vbs"), rel_path) - content = eval_eex(toolpath("rel/win32/run.bat.eex"), rel, pkg) - File.write!(Path.join(rel_path, "run.bat"), content) + # :release_first uses run.vbs/run.bat + heart; :host_first owns the process. + pkg = + if pkg.windows_layout == :release_first do + cp!(toolpath("rel/win32/run.vbs"), rel_path) + content = eval_eex(toolpath("rel/win32/run.bat.eex"), rel, pkg) + File.write!(Path.join(rel_path, "run.bat"), content) + pkg + else + pkg + end pkg end @@ -187,36 +209,8 @@ defmodule Desktop.Deployment.Package do case os() do MacOS -> Package.MacOS.release(pkg) Linux -> linux_release(pkg) - Windows -> windows_release(pkg) - end - end - - defp windows_release(%Package{release: %Mix.Release{path: rel_path, version: vsn} = rel} = pkg) do - build_root = Path.join([rel_path, "..", ".."]) |> Path.expand() - signfun = win32_sign_function(pkg) - - if signfun == nil do - Mix.Shell.IO.info("Not signing secret detected. Skipping signing") - else - win32_codesign(signfun, build_root) + Windows -> Package.Windows.release(pkg) end - - nsi_file = toolpath("rel/win32/app.nsi.eex") - {:ok, cur} = :file.get_cwd() - :file.set_cwd(String.to_charlist(rel_path)) - - content = eval_eex(nsi_file, rel, pkg) - File.write!(Path.join(build_root, "app.nsi"), content) - cmd!("makensis", ["-NOCD", "-DVERSION=#{vsn}", Path.join(build_root, "app.nsi")]) - :file.set_cwd(cur) - outfile = "#{pkg.name}-#{vsn}.exe" - - if signfun != nil do - path = Path.join([build_root, outfile]) - signfun.(path) - end - - %{pkg | priv: Map.put(pkg.priv, :installer_name, outfile)} end defp linux_release(%Package{release: %Mix.Release{path: rel_path, version: vsn} = rel} = pkg) do diff --git a/lib/package/windows.ex b/lib/package/windows.ex new file mode 100644 index 0000000..b096803 --- /dev/null +++ b/lib/package/windows.ex @@ -0,0 +1,245 @@ +defmodule Desktop.Deployment.Package.Windows do + @moduledoc """ + Windows specific deployment functions. + """ + import Desktop.Deployment.Tooling + alias Desktop.Deployment.Package + + @redistributables [ + "MicrosoftEdgeWebview2Setup.exe", + "vcredist_x64.exe" + ] + + def release(%Package{} = pkg) do + case pkg.windows_layout do + :host_first -> + release_host_first(pkg) + + :release_first -> + release_release_first(pkg) + + other -> + raise "Unknown windows_layout #{inspect(other)}. Use :host_first or :release_first." + end + end + + defp release_host_first(%Package{release: %Mix.Release{path: path, version: vsn} = rel} = pkg) do + build_root = Path.join([path, "..", ".."]) |> Path.expand() + package_root = Path.join(build_root, pkg.name) + File.rm_rf(package_root) + File.mkdir_p!(package_root) + + release_subdir = pkg.release_subdir || "beam" + beam_root = Path.join(package_root, release_subdir) + File.mkdir_p!(beam_root) + + host_bin = resolve_host_binary!(pkg) + host_name = host_install_name(pkg, host_bin) + File.cp!(host_bin, Path.join(package_root, host_name)) + IO.puts("Host-first Windows/#{host_name} <- #{host_bin}") + + write_host_ini!(package_root, host_name, release_subdir, windows_beam_app_name(pkg)) + copy_release_tree!(path, beam_root) + move_redistributables!(beam_root, package_root) + + icon_rel = Path.join(["lib", "#{pkg.app_name}-#{vsn}", "priv", "icon.ico"]) + + pkg = + pkg + |> put_priv(:windows_layout, :host_first) + |> put_priv(:host_executable, host_name) + |> put_priv(:release_subdir, release_subdir) + |> put_priv(:icon_rel, Path.join(release_subdir, icon_rel)) + |> put_priv(:package_root, package_root) + |> put_priv(:nsi_outfile, "../#{pkg.name}-#{vsn}.exe") + |> put_priv(:scheme_launcher, "\"${TARGET}\" \"%1\"") + + finalize_windows_installer(pkg, rel, package_root) + end + + # Destination name in $INSTDIR. Prefer an explicit host_executable, then the + # package name (so taskbar pins / shortcuts to dDrive.exe keep working), then + # the source basename (e.g. DesktopWebView.exe). + defp host_install_name(pkg, host_bin) do + cond do + is_binary(pkg.host_executable) and pkg.host_executable != "" -> + pkg.host_executable + + is_binary(pkg.name) and to_string(pkg.name) != "" -> + to_string(pkg.name) <> ".exe" + + true -> + Path.basename(host_bin) + end + end + + # Windows Mix releases ship `bin/.bat` alongside the Unix `bin/` + # shell script. Prefer the batch name in the ini so hosts without the + # ".bat preference" fix still spawn correctly. + defp windows_beam_app_name(pkg) do + beam_app = pkg.beam_app_name || to_string(pkg.app_name || Mix.Project.config()[:app]) + + if match?({:win32, _}, :os.type()) and not String.ends_with?(beam_app, ".bat") and + not String.ends_with?(beam_app, ".cmd") do + beam_app <> ".bat" + else + beam_app + end + end + + defp write_host_ini!(package_root, host_name, release_subdir, beam_app) do + ini = """ + [beam] + path = #{release_subdir} + app_name = #{beam_app} + args = start + working_dir = #{release_subdir} + + [network] + host = 127.0.0.1 + port = 0 + + [lifetime] + mode = reconnect + # Host-driven BEAM restart (replaces the heart watchdog). The native + # host watches its child BEAM process and respawns it on any exit; + # `system.prepare_quit` from Elixir suppresses respawn during a + # user-initiated quit. The Erlang release does not need -heart here. + restart_beam = true + restart_max_attempts = 0 + restart_backoff_ms = 500 + """ + + File.write!(Path.join(package_root, "#{Path.rootname(host_name)}.ini"), ini) + end + + defp copy_release_tree!(path, beam_root) do + File.ls!(path) + |> Enum.each(fn file -> + File.cp_r!(Path.join(path, file), Path.join(beam_root, file), fn src, dst -> + file_md5(src) != file_md5(dst) + end) + end) + end + + defp move_redistributables!(beam_root, package_root) do + for redist <- @redistributables do + src = Path.join(beam_root, redist) + + if File.exists?(src) do + File.rename!(src, Path.join(package_root, redist)) + end + end + end + + defp release_release_first( + %Package{release: %Mix.Release{path: path, version: vsn} = rel} = pkg + ) do + icon_rel = Path.join(["lib", "#{pkg.app_name}-#{vsn}", "priv", "icon.ico"]) + + pkg = + pkg + |> put_priv(:windows_layout, :release_first) + |> put_priv(:icon_rel, icon_rel) + |> put_priv(:package_root, path) + |> put_priv(:nsi_outfile, "../../#{pkg.name}-#{vsn}.exe") + |> put_priv( + :scheme_launcher, + "\"$WINDIR\\system32\\wscript.exe\" \"${TARGET}\" \"%1\"" + ) + + finalize_windows_installer(pkg, rel, path) + end + + defp finalize_windows_installer(%Package{release: %Mix.Release{version: vsn}} = pkg, rel, cwd) do + build_root = Path.join([rel.path, "..", ".."]) |> Path.expand() + signfun = Package.win32_sign_function(pkg) + + if signfun == nil do + Mix.Shell.IO.info("Not signing secret detected. Skipping signing") + else + Package.win32_codesign(signfun, build_root) + end + + nsi_file = Package.toolpath("rel/win32/app.nsi.eex") + {:ok, cur} = :file.get_cwd() + :file.set_cwd(String.to_charlist(cwd)) + + content = eval_eex(nsi_file, rel, pkg) + File.write!(Path.join(build_root, "app.nsi"), content) + cmd!("makensis", ["-NOCD", "-DVERSION=#{vsn}", Path.join(build_root, "app.nsi")]) + :file.set_cwd(cur) + + outfile = "#{pkg.name}-#{vsn}.exe" + + if signfun != nil do + path = Path.join([build_root, outfile]) + signfun.(path) + end + + %{pkg | priv: Map.put(pkg.priv, :installer_name, outfile)} + end + + defp resolve_host_binary!(%Package{} = pkg) do + explicit = + [ + pkg.host_binary, + System.get_env("DESKTOP_HOST_BINARY"), + # Back-compat alias used by existing build scripts + System.get_env("DESKTOP_WEBVIEW_BINARY") + ] + |> Enum.filter(&(is_binary(&1) and &1 != "")) + |> Enum.map(&Path.expand/1) + |> Enum.filter(&File.exists?/1) + + case explicit do + [path | _] -> + path + + [] -> + case discover_host_binaries(pkg) do + [path] -> + path + + [] -> + raise """ + Native host binary not found for Windows :host_first packaging. + + Set package.host_binary, DESKTOP_HOST_BINARY, or ship a binary under a + Mix dependency at priv/native/windows/.exe (discovered by convention). + """ + + paths -> + raise """ + Multiple host binaries found for Windows :host_first packaging: + + #{Enum.map_join(paths, "\n", &" #{&1}")} + + Set package.host_binary to select one. package.host_executable only + controls the installed filename (defaults to package.name.exe). + """ + end + end + end + + # Convention only: any Mix dep that ships priv/native/windows/.exe. + # Deployment never names a particular OTP application (e.g. desktop_webview). + # `package.host_executable` is the *installed* name (may differ from the source + # basename, e.g. DesktopWebView.exe → dDrive.exe) and is not used to filter + # discovery here. + defp discover_host_binaries(%Package{} = _pkg) do + dep_bins = + for {_app, path} <- Mix.Project.deps_paths(), + file <- Path.wildcard(Path.join(path, "priv/native/windows/*.exe")), + File.regular?(file), + do: Path.expand(file) + + preferred = Enum.filter(dep_bins, &(Path.basename(&1) == "DesktopWebView.exe")) + bins = if preferred != [], do: preferred, else: dep_bins + Enum.uniq(bins) + end + + defp put_priv(pkg, key, value) do + %{pkg | priv: Map.put(pkg.priv, key, value)} + end +end diff --git a/rel/win32/app.nsi.eex b/rel/win32/app.nsi.eex index 9cd2800..2f7de47 100644 --- a/rel/win32/app.nsi.eex +++ b/rel/win32/app.nsi.eex @@ -8,7 +8,7 @@ Var STARTMENU_FOLDER Var MYTEMP ; General -OutFile "../../<%= @package.name %>-${VERSION}.exe" +OutFile "<%= @package.priv.nsi_outfile %>" SetCompressor /SOLID lzma SetCompressorDictSize 64 SetDatablockOptimize ON @@ -22,9 +22,17 @@ InstallDir "$PROGRAMFILES64\<%= @package.name_long || @package.name %>" !define MY_STARTMENUPAGE_REGISTRY_VALUENAME "Start Menu Folder" ; Launch options +<%= if @package.priv[:windows_layout] == :host_first do %> +!define TARGET "$INSTDIR\<%= @package.priv.host_executable %>" +!define TARGET_PARAMS "" +!define TARGET_ICON "$INSTDIR\<%= @package.priv.icon_rel %>" +!define KILL_HOST "<%= @package.priv.host_executable %>" +<% else %> !define TARGET "$INSTDIR\run.vbs" !define TARGET_PARAMS "" -!define TARGET_ICON "$INSTDIR\lib\<%= @package.app_name %>-${VERSION}\priv\icon.ico" +!define TARGET_ICON "$INSTDIR\<%= @package.priv.icon_rel %>" +!define KILL_HOST "heart.exe" +<% end %> ; Remember install folder InstallDirRegKey HKLM "${MY_STARTMENUPAGE_REGISTRY_KEY}" "" @@ -33,8 +41,8 @@ InstallDirRegKey HKLM "${MY_STARTMENUPAGE_REGISTRY_KEY}" "" !define MUI_STARTMENUPAGE_DEFAULTFOLDER "<%= @package.name_long || @package.name %>" -!define MUI_ICON "<%= @release.path %>\lib\<%= @package.app_name %>-${VERSION}\priv\icon.ico" -!define MUI_UNICON "<%= @release.path %>\lib\<%= @package.app_name %>-${VERSION}\priv\icon.ico" +!define MUI_ICON "<%= @package.priv.package_root %>\<%= @package.priv.icon_rel %>" +!define MUI_UNICON "<%= @package.priv.package_root %>\<%= @package.priv.icon_rel %>" ;-------------------------------- ;Modern UI Configuration @@ -77,7 +85,7 @@ Section "Base" SecDriveBase SectionIn 1 2 3 RO SetOutPath "$INSTDIR" -ExecShellWait "" "$SysDir\taskkill.exe" "/f /im heart.exe" SW_HIDE +ExecShellWait "" "$SysDir\taskkill.exe" "/f /im ${KILL_HOST}" SW_HIDE ExecShellWait "" "$SysDir\taskkill.exe" "/f /im epmd.exe" SW_HIDE File /r *.* @@ -97,7 +105,7 @@ WriteRegStr HKCR \ "URL Protocol" "" WriteRegStr HKCR \ "<%= scheme %>\shell\open\command" \ - "" '"$WINDIR\system32\wscript.exe" "${TARGET}" "%1"' + "" '<%= @package.priv.scheme_launcher %>' <% end %> ; Create uninstaller before shortcuts @@ -204,7 +212,7 @@ Delete "$QUICKLAUNCH\<%= @package.name_long || @package.name %>.lnk" SetShellVarContext current Delete "$DESKTOP\<%= @package.name_long || @package.name %>.lnk" Delete "$QUICKLAUNCH\<%= @package.name_long || @package.name %>.lnk" -ExecShellWait "" "$SysDir\taskkill.exe" "/f /im heart.exe" SW_HIDE +ExecShellWait "" "$SysDir\taskkill.exe" "/f /im ${KILL_HOST}" SW_HIDE ExecShellWait "" "$SysDir\taskkill.exe" "/f /im epmd.exe" SW_HIDE ExecShellWait "" "$SysDir\taskkill.exe" "/f /im <%= @package.name %>.exe" SW_HIDE RMDir /r /REBOOTOK "$INSTDIR" @@ -237,7 +245,7 @@ noshortcuts: DeleteRegKey /ifempty HKLM "${MY_STARTMENUPAGE_REGISTRY_KEY}" DeleteRegKey /ifempty HKCU "${MY_STARTMENUPAGE_REGISTRY_KEY}" DeleteRegKey HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\<%= @package.name %>" -DeleteRegKey HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\<%= @package.name %>" +DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\<%= @package.name %>" DeleteRegValue HKLM "Software\Microsoft\Windows\CurrentVersion\Run" "<%= @package.name %>" DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "<%= @package.name %>"