From fc7328703a47f1f18007487d3d7f742d0eb4f226 Mon Sep 17 00:00:00 2001 From: Wojtek Mach Date: Wed, 1 Jun 2022 22:29:54 +0200 Subject: [PATCH] App bundling improvements (#1211) 1. Replace `mix release mac_app|mac_app_dmg|windows_installer` with a single `mix release app` 2. Extract templates (Launcher.swift, Launcher.vbs, etc) into separate files in app_builder/lib/templates 3. Don't verify vc_redist.x64.exe checksum as the Microsoft publishes new releases on the same URL --- .github/scripts/app/bootstrap_mac.sh | 6 +- .github/scripts/app/build_mac.sh | 13 +- .github/scripts/app/build_windows.sh | 4 +- app_builder/examples/wx_demo/example.wxdemo | 1 + app_builder/examples/wx_demo/lib/wx_demo.ex | 6 +- app_builder/examples/wx_demo/mix.exs | 62 ++-- app_builder/lib/app_builder.ex | 29 +- app_builder/lib/app_builder/macos.ex | 323 +++++------------- app_builder/lib/app_builder/utils.ex | 44 ++- app_builder/lib/app_builder/windows.ex | 259 +++----------- .../templates/macos/Entitlements.plist.eex | 12 + .../lib/templates/macos/Info.plist.eex | 67 ++++ .../lib/templates/macos/Launcher.swift.eex | 54 +++ .../lib/templates/windows/Installer.nsi.eex | 82 +++++ .../lib/templates/windows/Launcher.vbs.eex | 48 +++ .../lib/templates/windows/Manifest.xml.eex | 9 + mix.exs | 115 +++---- 17 files changed, 555 insertions(+), 579 deletions(-) create mode 100644 app_builder/examples/wx_demo/example.wxdemo create mode 100644 app_builder/lib/templates/macos/Entitlements.plist.eex create mode 100644 app_builder/lib/templates/macos/Info.plist.eex create mode 100644 app_builder/lib/templates/macos/Launcher.swift.eex create mode 100644 app_builder/lib/templates/windows/Installer.nsi.eex create mode 100644 app_builder/lib/templates/windows/Launcher.vbs.eex create mode 100644 app_builder/lib/templates/windows/Manifest.xml.eex diff --git a/.github/scripts/app/bootstrap_mac.sh b/.github/scripts/app/bootstrap_mac.sh index cbe22520a..0673c70e0 100755 --- a/.github/scripts/app/bootstrap_mac.sh +++ b/.github/scripts/app/bootstrap_mac.sh @@ -17,7 +17,7 @@ main() { fi export PATH=$PWD/tmp/wxwidgets-${wxwidgets_vsn}-$target/bin:$PATH - echo "wx" + echo "checking wx" file `which wxrc` wx-config --version echo @@ -29,7 +29,7 @@ main() { fi export PATH=$PWD/tmp/otp-${otp_vsn}-$target/bin:$PATH - echo "otp" + echo "checking otp" file `which erlc` erl +V erl -noshell -eval 'ok = crypto:start(), io:format("crypto ok~n"), halt().' @@ -41,7 +41,7 @@ main() { fi export PATH=$PWD/tmp/elixir-${elixir_vsn}/bin:$PATH - echo "elixir" + echo "checking elixir" elixir --version cat << EOF > tmp/bootstrap_env.sh diff --git a/.github/scripts/app/build_mac.sh b/.github/scripts/app/build_mac.sh index 61d04a321..e2fb8e236 100755 --- a/.github/scripts/app/build_mac.sh +++ b/.github/scripts/app/build_mac.sh @@ -3,18 +3,11 @@ # Usage: # # $ sh .github/scripts/app/build_mac.sh -# $ open _build/app_prod/rel/Livebook.app +# $ open _build/app_prod/Livebook.app # $ open livebook://github.com/livebook-dev/livebook/blob/main/test/support/notebooks/basic.livemd # $ open ./test/support/notebooks/basic.livemd set -e sh .github/scripts/app/bootstrap_mac.sh -sh tmp/bootstrap_env.sh - -# If CODESIGN_IDENITY is set, let's build the .dmg which would also notarize it. -# Otherwise, let's build just the .app. -if [ -n "$CODESIGN_IDENTITY" ]; then - MIX_ENV=prod MIX_TARGET=app mix release mac_app_dmg --overwrite -else - MIX_ENV=prod MIX_TARGET=app mix release mac_app --overwrite -fi +. tmp/bootstrap_env.sh +MIX_ENV=prod MIX_TARGET=app mix release app --overwrite diff --git a/.github/scripts/app/build_windows.sh b/.github/scripts/app/build_windows.sh index c19d44416..2bb0c545f 100644 --- a/.github/scripts/app/build_windows.sh +++ b/.github/scripts/app/build_windows.sh @@ -3,9 +3,9 @@ # Usage: # # $ sh .github/scripts/app/build_windows.sh -# $ _build/app_prod/rel/LivebookInstall.exe +# $ wscript _build/app_prod/Livebook-win/LivebookLauncher.vbs # $ start livebook://github.com/livebook-dev/livebook/blob/main/test/support/notebooks/basic.livemd # $ start ./test/support/notebooks/basic.livemd set -e -MIX_ENV=prod MIX_TARGET=app mix release windows_installer --overwrite +MIX_ENV=prod MIX_TARGET=app mix release app --overwrite diff --git a/app_builder/examples/wx_demo/example.wxdemo b/app_builder/examples/wx_demo/example.wxdemo new file mode 100644 index 000000000..db4624cd0 --- /dev/null +++ b/app_builder/examples/wx_demo/example.wxdemo @@ -0,0 +1 @@ +An example file to test "open file" feature. diff --git a/app_builder/examples/wx_demo/lib/wx_demo.ex b/app_builder/examples/wx_demo/lib/wx_demo.ex index 9ed8ed1f7..3ab46eb2a 100644 --- a/app_builder/examples/wx_demo/lib/wx_demo.ex +++ b/app_builder/examples/wx_demo/lib/wx_demo.ex @@ -24,7 +24,7 @@ defmodule WxDemo.Window do @wx_id_osx_hide 5250 def start_link(_) do - {:wx_ref, _, _, pid} = :wx_object.start_link(__MODULE__, [], []) + {:wx_ref, _, _, pid} = :wx_object.start_link({:local, __MODULE__}, __MODULE__, [], []) {:ok, pid} end @@ -47,11 +47,9 @@ defmodule WxDemo.Window do @impl true def init(_) do app_name = "WxDemo" - - true = Process.register(self(), __MODULE__) os = os() wx = :wx.new() - frame = :wxFrame.new(wx, -1, app_name, size: {100, 100}) + frame = :wxFrame.new(wx, -1, app_name, size: {400, 400}) if os == :macos do fixup_macos_menubar(frame, app_name) diff --git a/app_builder/examples/wx_demo/mix.exs b/app_builder/examples/wx_demo/mix.exs index 69491af2a..88ec9fea7 100644 --- a/app_builder/examples/wx_demo/mix.exs +++ b/app_builder/examples/wx_demo/mix.exs @@ -26,51 +26,41 @@ defmodule WxDemo.MixProject do end defp releases do - options = [ - name: "WxDemo", - url_schemes: ["wxdemo"], - document_types: [ - %{ - name: "WxDemo", - extensions: ["wxdemo"], - # macos specific - role: "Editor" - } - ] - ] + macos_notarization = macos_notarization() [ - mac_app: [ - include_executables_for: [:unix], - steps: [:assemble, &AppBuilder.build_mac_app(&1, options)] - ], - mac_app_dmg: [ - include_executables_for: [:unix], - steps: [:assemble, &build_mac_app_dmg(&1, options)] - ], - windows_installer: [ - include_executables_for: [:windows], + app: [ steps: [ :assemble, - &AppBuilder.build_windows_installer(&1, [module: WxDemo.Window] ++ options) + &AppBuilder.bundle/1 + ], + app: [ + name: "WxDemo", + url_schemes: ["wxdemo"], + document_types: [ + %{ + name: "WxDemo", + extensions: ["wxdemo"], + macos_role: "Editor" + } + ], + server: WxDemo, + macos_build_dmg: macos_notarization != nil, + macos_notarization: macos_notarization, + windows_build_installer: true ] ] ] end - defp build_mac_app_dmg(release, options) do - options = - [ - codesign: [ - identity: System.fetch_env!("CODESIGN_IDENTITY") - ], - notarize: [ - team_id: System.fetch_env!("NOTARIZE_TEAM_ID"), - apple_id: System.fetch_env!("NOTARIZE_APPLE_ID"), - password: System.fetch_env!("NOTARIZE_PASSWORD") - ] - ] ++ options + defp macos_notarization do + identity = System.get_env("NOTARIZE_IDENTITY") + team_id = System.get_env("NOTARIZE_TEAM_ID") + apple_id = System.get_env("NOTARIZE_APPLE_ID") + password = System.get_env("NOTARIZE_PASSWORD") - AppBuilder.build_mac_app_dmg(release, options) + if identity && team_id && apple_id && password do + [identity: identity, team_id: team_id, apple_id: apple_id, password: password] + end end end diff --git a/app_builder/lib/app_builder.ex b/app_builder/lib/app_builder.ex index 45c47fe3e..16bc14663 100644 --- a/app_builder/lib/app_builder.ex +++ b/app_builder/lib/app_builder.ex @@ -1,9 +1,32 @@ defmodule AppBuilder do - defdelegate build_mac_app(release, options), to: AppBuilder.MacOS + def bundle(release) do + os = os() - defdelegate build_mac_app_dmg(release, options), to: AppBuilder.MacOS + allowed_options = [ + :name, + :server, + icon_path: [ + macos: Application.app_dir(:wx, "examples/demo/erlang.png") + ], + url_schemes: [], + document_types: [], + additional_paths: [], + macos_is_agent_app: false, + macos_build_dmg: false, + macos_notarization: nil, + windows_build_installer: true + ] - defdelegate build_windows_installer(release, options), to: AppBuilder.Windows + options = Keyword.validate!(release.options[:app], allowed_options) + + case os do + :macos -> + AppBuilder.MacOS.bundle(release, options) + + :windows -> + AppBuilder.Windows.bundle(release, options) + end + end def os do case :os.type() do diff --git a/app_builder/lib/app_builder/macos.ex b/app_builder/lib/app_builder/macos.ex index b8d4d4554..666396fdd 100644 --- a/app_builder/lib/app_builder/macos.ex +++ b/app_builder/lib/app_builder/macos.ex @@ -3,74 +3,120 @@ defmodule AppBuilder.MacOS do import AppBuilder.Utils - def build_mac_app_dmg(release, options) do - {codesign, options} = Keyword.pop(options, :codesign) - {notarize, options} = Keyword.pop(options, :notarize) + @templates_path "#{__ENV__.file}/../../templates" - release = build_mac_app(release, options) + def bundle(release, options) do + app_name = options[:name] + app_path = "#{Mix.Project.build_path()}/#{app_name}.app" + File.rm_rf!(app_path) + tmp_dir = "#{Mix.Project.build_path()}/tmp" + contents_path = "#{app_path}/Contents" + resources_path = "#{contents_path}/Resources" + + copy_dir(release.path, "#{resources_path}/rel") + + launcher_eex_path = Path.expand("#{@templates_path}/macos/Launcher.swift.eex") + launcher_src_path = "#{tmp_dir}/Launcher.swift" + launcher_bin_path = "#{contents_path}/MacOS/#{app_name}Launcher" + copy_template(launcher_eex_path, launcher_src_path, release: release, app_options: options) + + File.mkdir!("#{contents_path}/MacOS") + log(:green, :creating, Path.relative_to_cwd(launcher_bin_path)) + + cmd!("swiftc", [ + "-warnings-as-errors", + "-target", + swiftc_target(), + "-o", + launcher_bin_path, + launcher_src_path + ]) + + icon_path = Keyword.fetch!(options, :icon_path) + dest_path = "#{resources_path}/AppIcon.icns" + create_icon(icon_path, dest_path) + + for type <- Keyword.fetch!(options, :document_types) do + if src_path = type[:icon_path] do + dest_path = "#{resources_path}/#{type.name}Icon.icns" + create_icon(src_path, dest_path) + end + end + + copy_template( + Path.expand("#{@templates_path}/macos/Info.plist.eex"), + "#{contents_path}/Info.plist", + release: release, + app_options: options + ) + + if options[:macos_build_dmg] do + build_dmg(release, options) + end + + release + end + + defp build_dmg(release, options) do app_name = Keyword.fetch!(options, :name) - File.rm_rf!("tmp/dmg") - File.mkdir_p!("tmp/dmg") - File.ln_s!("/Applications", "tmp/dmg/Applications") + notarization = Keyword.fetch!(options, :macos_notarization) + + dmg_dir = "#{Mix.Project.build_path()}/dmg" + app_dir = "#{dmg_dir}/#{app_name}.app" + tmp_dir = "#{Mix.Project.build_path()}/tmp" + File.rm_rf!(dmg_dir) + File.mkdir_p!(dmg_dir) + + File.ln_s!("/Applications", "#{dmg_dir}/Applications") File.cp_r!( - Path.join([Mix.Project.build_path(), "rel", "#{app_name}.app"]), - "tmp/dmg/#{app_name}.app" + "#{Mix.Project.build_path()}/#{app_name}.app", + app_dir ) to_sign = - "tmp/dmg/#{app_name}.app/**" + "#{app_dir}/**" |> Path.wildcard() |> Enum.filter(fn file -> stat = File.lstat!(file) Bitwise.band(0o100, stat.mode) != 0 and stat.type == :regular end) - to_sign = to_sign ++ ["tmp/dmg/#{app_name}.app"] + to_sign = to_sign ++ [app_dir] - if codesign do - entitlements_path = "tmp/entitlements.plist" - File.write!(entitlements_path, entitlements()) - codesign(to_sign, "--options=runtime --entitlements=#{entitlements_path}", codesign) - end + entitlements_eex_path = "#{Path.expand(@templates_path)}/macos/Entitlements.plist.eex" + entitlements_plist_path = "#{tmp_dir}/Entitlements.plist" + + copy_template(entitlements_eex_path, entitlements_plist_path, + release: release, + app_options: options + ) + + log(:green, "signing", Path.relative_to_cwd(app_dir)) + codesign(to_sign, "--options=runtime --entitlements=#{entitlements_plist_path}", notarization) arch = :erlang.system_info(:system_architecture) |> to_string |> String.split("-") |> hd() vsn = release.version - basename = "#{app_name}-#{vsn}-#{arch}.dmg" - - tmp_dmg_path = "tmp/#{app_name}.dmg" - dmg_path = "#{Mix.Project.build_path()}/rel/#{basename}" - - File.rm_rf!(tmp_dmg_path) - File.rm_rf!(dmg_path) + dmg_path = "#{Mix.Project.build_path()}/#{app_name}Install-#{vsn}-#{arch}.dmg" + log(:green, "creating", Path.relative_to_cwd(dmg_path)) cmd!( "hdiutil", - ~w(create #{tmp_dmg_path} -ov -volname #{app_name}Install -fs HFS+ -srcfolder tmp/dmg) + ~w(create #{dmg_path} -ov -volname #{app_name}Install -fs HFS+ -srcfolder #{dmg_dir}) ) - cmd!( - "hdiutil", - ~w(convert #{tmp_dmg_path} -format UDZO -o #{dmg_path}) - ) + log(:green, "notarizing", Path.relative_to_cwd(dmg_path)) + notarize(dmg_path, notarization) - if codesign do - codesign([dmg_path], "", codesign) - end - - if notarize do - notarize(dmg_path, notarize) - end - - File.rm!(tmp_dmg_path) release end - defp codesign(paths, args, options) do + defp codesign(paths, extra_flags, options) do identity = Keyword.fetch!(options, :identity) paths = Enum.join(paths, " ") - shell!("codesign --force --timestamp --verbose=4 --sign=\"#{identity}\" #{args} #{paths}") + flags = "--force --timestamp --verbose=4 --sign=\"#{identity}\" #{extra_flags}" + shell!("codesign #{flags} #{paths}") end defp notarize(path, options) do @@ -89,106 +135,8 @@ defmodule AppBuilder.MacOS do """) end - def build_mac_app(release, options) do - options = - Keyword.validate!(options, [ - :name, - :version, - :icon_path, - :info_plist, - :url_schemes, - :document_types, - :additional_paths, - :is_agent_app - ]) - - app_name = Keyword.fetch!(options, :name) - additional_paths = Keyword.get(options, :additional_paths, []) - - app_bundle_path = Path.join([Mix.Project.build_path(), "rel", "#{app_name}.app"]) - File.rm_rf!(app_bundle_path) - File.mkdir_p!(Path.join([app_bundle_path, "Contents", "Resources"])) - File.rename!(release.path, Path.join([app_bundle_path, "Contents", "Resources", "rel"])) - - File.mkdir_p!("tmp") - launcher_src_path = "tmp/Launcher.swift" - File.write!(launcher_src_path, launcher(release, additional_paths)) - launcher_path = Path.join([app_bundle_path, "Contents", "MacOS", app_name <> "Launcher"]) - File.mkdir_p!(Path.dirname(launcher_path)) - - cmd!("swiftc", [ - "-warnings-as-errors", - "-target", - swiftc_target(), - "-o", - launcher_path, - launcher_src_path - ]) - - icon_path = options[:icon_path] || Application.app_dir(:wx, "examples/demo/erlang.png") - dest_path = Path.join([app_bundle_path, "Contents", "Resources", "AppIcon.icns"]) - create_icon(icon_path, dest_path) - - for type <- options[:document_types] || [] do - if src_path = type[:icon_path] do - dest_path = Path.join([app_bundle_path, "Contents", "Resources", "#{type.name}Icon.icns"]) - create_icon(src_path, dest_path) - end - end - - info_plist = options[:info_plist] || info_plist(options) - File.write!(Path.join([app_bundle_path, "Contents", "Info.plist"]), info_plist) - - release - end - - defp launcher(release, additional_paths) do - additional_paths = Enum.map_join(additional_paths, ":", &"\\(resourcePath)#{&1}") - - """ - import Foundation - import Cocoa - - let fm = FileManager.default - let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as! String - let home = NSHomeDirectory() - - let logPath = "\\(home)/Library/Logs/\\(appName).log" - if !fm.fileExists(atPath: logPath) { fm.createFile(atPath: logPath, contents: Data()) } - let logFile = FileHandle(forUpdatingAtPath: logPath) - logFile?.seekToEndOfFile() - - let releaseScriptPath = Bundle.main.path(forResource: "rel/bin/#{release.name}", ofType: "")! - - let resourcePath = Bundle.main.resourcePath ?? "" - let additionalPaths = "#{additional_paths}" - - var environment = ProcessInfo.processInfo.environment - let path = environment["PATH"] ?? "" - - environment["PATH"] = "\\(additionalPaths):\\(path)" - - let task = Process() - task.environment = environment - task.launchPath = releaseScriptPath - task.arguments = ["start"] - task.standardOutput = logFile - task.standardError = logFile - try task.run() - - task.waitUntilExit() - - if task.terminationStatus != 0 { - let alert = NSAlert() - alert.alertStyle = .critical - alert.messageText = "\\(appName) exited with error status \\(task.terminationStatus)." - alert.informativeText = "Logs available at \\(logPath)." - alert.runModal() - } - """ - end - defp create_icon(src_path, dest_path) do + log(:green, :creating, Path.relative_to_cwd(dest_path)) src_path = normalize_icon_path(src_path) if Path.extname(src_path) == ".icns" do @@ -210,7 +158,7 @@ defmodule AppBuilder.MacOS do size = size * scale out = "#{dest_tmp_path}/icon_#{size}x#{size}#{suffix}.png" - cmd!("sips", ~w(-z #{size} #{size} #{src_path} --out #{out})) + cmd!("sips", ~w(-z #{size} #{size} #{src_path} --out #{out}), into: "") end cmd!("iconutil", ~w(-c icns #{dest_tmp_path} -o #{dest_path})) @@ -227,101 +175,4 @@ defmodule AppBuilder.MacOS do "arm64-apple-macosx12" end end - - ## Templates - - require EEx - - defp entitlements do - """ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.cs.allow-unsigned-executable-memory - - com.apple.security.cs.allow-dyld-environment-variables - - - - """ - end - - code = """ - <% - app_name = Keyword.fetch!(options, :name) - app_version = Keyword.fetch!(options, :version) - %> - - - - - CFBundlePackageType - APPL - CFBundleExecutable - <%= app_name %>Launcher - CFBundleName - <%= app_name %> - CFBundleDisplayName - <%= app_name %> - CFBundleShortVersionString - <%= app_version %> - CFBundleVersion - <%= app_version %> - CFBundleIconFile - AppIcon - CFBundleIconName - AppIcon - - <%= if schemes = options[:url_schemes] do %> - CFBundleURLTypes - - <%= for scheme <- schemes do %> - - CFBundleURLName - <%= app_name %> - CFBundleURLSchemes - - <%= scheme %> - - - <% end %> - - <% end %> - - <%= if types = options[:document_types] do %> - CFBundleDocumentTypes - - <%= for type <- types do %> - - CFBundleTypeName - <%= type.name %> - CFBundleTypeRole - <%= type.role %> - CFBundleTypeExtensions - - <%= for ext <- type.extensions do %> - <%= ext %> - <% end %> - - <%= if type[:icon_path] do %> - CFBundleTypeIconFile - <%= type.name %>Icon - <% end %> - - <% end %> - - <% end %> - - <%= if options[:is_agent_app] do %> - LSUIElement - - <% end %> - - - """ - - EEx.function_from_string(:defp, :info_plist, code, [:options], trim: true) end diff --git a/app_builder/lib/app_builder/utils.ex b/app_builder/lib/app_builder/utils.ex index c74792b0d..c49e11496 100644 --- a/app_builder/lib/app_builder/utils.ex +++ b/app_builder/lib/app_builder/utils.ex @@ -5,7 +5,11 @@ defmodule AppBuilder.Utils do def cmd!(bin, args, opts \\ []) do opts = Keyword.put_new(opts, :into, IO.stream()) - {_, 0} = System.cmd(bin, args, opts) + {_, status} = System.cmd(bin, args, opts) + + if status != 0 do + raise "command exited with #{status}" + end end def shell!(command, opts \\ []) do @@ -13,11 +17,17 @@ defmodule AppBuilder.Utils do {_, 0} = System.shell(command, opts) end + def ensure_executable(url) do + ensure_executable(url, :no_verify) + end + def ensure_executable(url, expected_sha256) do tmp_dir = Path.join(System.tmp_dir!(), Path.basename(url, Path.extname(url))) path = Path.join(tmp_dir, Path.basename(url)) - unless File.exists?(path) do + if File.exists?(path) do + verify(File.read!(path), expected_sha256) + else File.mkdir_p!(tmp_dir) body = download_and_verify(url, expected_sha256) File.write!(path, body) @@ -53,6 +63,10 @@ defmodule AppBuilder.Utils do body end + defp verify(data, :no_verify) do + data + end + defp verify(data, expected_sha256) do actual_sha256 = :crypto.hash(:sha256, data) |> Base.encode16(case: :lower) @@ -74,4 +88,30 @@ defmodule AppBuilder.Utils do def normalize_icon_path(path_per_os) when is_list(path_per_os) do Keyword.fetch!(path_per_os, AppBuilder.os()) end + + def copy_dir(from, to, options \\ []) do + File.mkdir_p!(Path.dirname(to)) + log(:green, "creating", Path.relative_to_cwd(to), options) + File.cp_r!(from, to) + end + + def copy_file(source, target, options \\ []) do + create_file(target, File.read!(source), options) + end + + def copy_template(source, target, assigns, options \\ []) do + create_file(target, EEx.eval_file(source, assigns: assigns, trim: true), options) + end + + def create_file(path, contents, options \\ []) when is_binary(path) do + log(:green, :creating, Path.relative_to_cwd(path), options) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, contents) + end + + def log(color, command, message, options \\ []) do + unless options[:quiet] do + Mix.shell().info([color, "* #{command} ", :reset, message]) + end + end end diff --git a/app_builder/lib/app_builder/windows.ex b/app_builder/lib/app_builder/windows.ex index f0acdc926..db3396c61 100644 --- a/app_builder/lib/app_builder/windows.ex +++ b/app_builder/lib/app_builder/windows.ex @@ -2,7 +2,52 @@ defmodule AppBuilder.Windows do @moduledoc false import AppBuilder.Utils - require EEx + + @templates_path "#{__ENV__.file}/../../templates" + + def bundle(release, options) do + app_name = options[:name] + + app_path = "#{Mix.Project.build_path()}/#{app_name}-win" + File.rm_rf!(app_path) + + copy_dir(release.path, "#{app_path}/rel") + + launcher_eex_path = Path.expand("#{@templates_path}/windows/Launcher.vbs.eex") + launcher_bin_path = "#{app_path}/#{app_name}Launcher.vbs" + copy_template(launcher_eex_path, launcher_bin_path, release: release, app_options: options) + File.mkdir!("#{app_path}/Logs") + + manifest_eex_path = Path.expand("#{@templates_path}/windows/Manifest.xml.eex") + manifest_xml_path = "#{app_path}/Manifest.xml" + copy_template(manifest_eex_path, manifest_xml_path, release: release) + + rcedit_path = ensure_rcedit() + erl_exe = "#{app_path}/rel/erts-#{release.erts_version}/bin/erl.exe" + log(:green, :updating, Path.relative_to_cwd(erl_exe)) + cmd!(rcedit_path, ["--application-manifest", manifest_xml_path, erl_exe]) + + vcredist_path = ensure_vcredistx64() + copy_file(vcredist_path, "#{app_path}/vcredist_x64.exe") + + create_icon(options[:icon_path], "#{app_path}/AppIcon.ico") + + for type <- Keyword.fetch!(options, :document_types) do + if src_path = type[:icon_path] do + dest_path = "#{app_path}/#{type.name}Icon.ico" + create_icon(src_path, dest_path) + end + end + + installer_eex_path = Path.expand("#{@templates_path}/windows/Installer.nsi.eex") + installer_nsi_path = "#{app_path}/Installer.nsi" + copy_template(installer_eex_path, installer_nsi_path, release: release, app_options: options) + makensis_path = ensure_makensis() + log(:green, "creating", Path.relative_to_cwd("#{app_path}/#{app_name}Install.exe")) + cmd!(makensis_path, [installer_nsi_path]) + + release + end def __send_events__(server, input) @@ -27,218 +72,9 @@ defmodule AppBuilder.Windows do send(server, {:open_file, path}) end - @doc """ - Creates a Windows installer. - """ - def build_windows_installer(release, options) do - tmp_dir = release.path <> "_tmp" - File.rm_rf(tmp_dir) - File.mkdir_p!(tmp_dir) - - File.cp_r!(release.path, Path.join(tmp_dir, "rel")) - - options = - Keyword.validate!(options, [ - :name, - :version, - :url_schemes, - :document_types, - :icon_path, - :module - ]) - - app_name = Keyword.fetch!(options, :name) - - vcredist_path = ensure_vcredistx64() - File.cp!(vcredist_path, Path.join(tmp_dir, "vcredist_x64.exe")) - - icon_path = options[:icon_path] || Application.app_dir(:wx, "examples/demo/erlang.png") - app_icon_path = Path.join(tmp_dir, "app_icon.ico") - create_icon(icon_path, app_icon_path) - - erl_exe = Path.join([tmp_dir, "rel", "erts-#{release.erts_version}", "bin", "erl.exe"]) - rcedit_path = ensure_rcedit() - cmd!(rcedit_path, ["--set-icon", app_icon_path, erl_exe]) - manifest_path = Path.join(tmp_dir, "manifest.xml") - File.write!(manifest_path, manifest()) - cmd!(rcedit_path, ["--application-manifest", manifest_path, erl_exe]) - - File.write!(Path.join(tmp_dir, "#{app_name}.vbs"), launcher_vbs(release, options)) - nsi_path = Path.join(tmp_dir, "#{app_name}.nsi") - File.write!(nsi_path, nsi(options)) - makensis_path = ensure_makensis() - cmd!(makensis_path, [nsi_path]) - - File.rename!( - Path.join(tmp_dir, "#{app_name}Install.exe"), - Path.join([Mix.Project.build_path(), "rel", "#{app_name}Install.exe"]) - ) - - release - end - - # https://docs.microsoft.com/en-us/windows/win32/hidpi/setting-the-default-dpi-awareness-for-a-process - defp manifest do - """ - - - - - true - PerMonitorV2 - - - - """ - end - - code = """ - <% - app_name = Keyword.fetch!(options, :name) - url_schemes = Keyword.get(options, :url_schemes, []) - %> - !include "MUI2.nsh" - - ;-------------------------------- - ;General - - Name "<%= app_name %>" - OutFile "<%= app_name %>Install.exe" - Unicode True - InstallDir "$LOCALAPPDATA\\<%= app_name %>" - - ; Need admin for registering URL scheme - RequestExecutionLevel admin - - ;-------------------------------- - ;Interface Settings - - !define MUI_ABORTWARNING - - ;-------------------------------- - ;Pages - - ;!insertmacro MUI_PAGE_COMPONENTS - !define MUI_ICON "app_icon.ico" - !insertmacro MUI_PAGE_DIRECTORY - !insertmacro MUI_PAGE_INSTFILES - - !insertmacro MUI_UNPAGE_CONFIRM - !insertmacro MUI_UNPAGE_INSTFILES - - ;-------------------------------- - ;Languages - - !insertmacro MUI_LANGUAGE "English" - - ;-------------------------------- - ;Installer Sections - - Section "Install" - SetOutPath "$INSTDIR" - - File vcredist_x64.exe - ExecWait '"$INSTDIR\\vcredist_x64.exe" /install' - - File /r rel rel - File "<%= app_name %>.vbs" - File "app_icon.ico" - - CreateDirectory "$INSTDIR\\Logs" - WriteUninstaller "$INSTDIR\\<%= app_name %>Uninstall.exe" - - <%= for type <- Keyword.get(options, :document_types, []) do %> - <%= for ext <- type.extensions do %> - WriteRegStr HKCR ".<%= ext %>" "" "<%= app_name %>.<%= type.name %>" - <% end %> - WriteRegStr HKCR "<%= app_name %>.<%= type.name %>" "" "<%= type.name %>" - WriteRegStr HKCR "<%= app_name %>.<%= type.name %>\\DefaultIcon" "" "$INSTDIR\\app_icon.ico" - WriteRegStr HKCR "<%= app_name %>.<%= type.name %>\\shell\\open\\command" "" '$WINDIR\\system32\\wscript.exe "$INSTDIR\\<%= app_name %>.vbs" "open_file:%1"' - <% end %> - - <%= for url_scheme <- url_schemes do %> - DetailPrint "Register <%= url_scheme %> URL Handler" - DeleteRegKey HKCR "<%= url_scheme %>" - WriteRegStr HKCR "<%= url_scheme %>" "" "<%= url_scheme %> Protocol" - WriteRegStr HKCR "<%= url_scheme %>" "URL Protocol" "" - WriteRegStr HKCR "<%= url_scheme %>\\shell" "" "" - WriteRegStr HKCR "<%= url_scheme %>\\shell\\open" "" "" - WriteRegStr HKCR "<%= url_scheme %>\\shell\\open\\command" "" '$WINDIR\\system32\\wscript.exe "$INSTDIR\\<%= app_name %>.vbs" "open_url:%1"' - <% end %> - SectionEnd - - Section "Desktop Shortcut" - CreateShortCut "$DESKTOP\\<%= app_name %>.lnk" "$INSTDIR\\<%= app_name %>.vbs" "" "$INSTDIR\\app_icon.ico" - SectionEnd - - Section "Uninstall" - Delete "$DESKTOP\\<%= app_name %>.lnk" - ; TODO: stop epmd if it was started - RMDir /r "$INSTDIR" - SectionEnd - """ - - EEx.function_from_string(:defp, :nsi, code, [:options], trim: true) - - code = ~S""" - <% - app_name = Keyword.fetch!(options, :name) - module = Keyword.fetch!(options, :module) - %>' This vbs script avoids a flashing cmd window when launching the release bat file - - root = Left(Wscript.ScriptFullName, Len(Wscript.ScriptFullName) - Len(Wscript.ScriptName)) - script = root & "rel\bin\<%= release.name %>.bat" - - Set shell = CreateObject("WScript.Shell") - - ' Below we run two commands: - ' - ' 1. bin/release rpc - ' 2. bin/release start - ' - ' The first one will only succeed when the app is already running. The second one when it is not. - ' It's ok for either to fail because we run them asynchronously. - - Set env = shell.Environment("Process") - env("PATH") = ".\rel\vendor\elixir\bin;.\rel\erts-<%= release.erts_version %>\bin;" & env("PATH") - - If WScript.Arguments.Count > 0 Then - input = WScript.Arguments(0) - Else - input = "reopen_app" - End If - - ' Below, we're basically doing: - ' - ' $ bin/release rpc 'AppBuilder.Windows.__send_events__(MyApp, input)' - ' - ' We send the input through IO, as opposed using the rpc expression, to avoid RCE. - cmd = "echo " & input & " | \""" & script & \""" rpc ""AppBuilder.Windows.__send_events__(<%= inspect(module) %>, String.trim(IO.read(:line)))\""" - code = shell.Run("cmd /c " & cmd, 0) - - ' Below, we're basically doing: - ' - ' $ bin/release start - ' - ' We send the input through the environment variable as we can't easily access argv - ' when booting through the release script. - - If WScript.Arguments.Count > 0 Then - env("APP_BUILDER_INPUT") = WScript.Arguments(0) - Else - env("APP_BUILDER_INPUT") = "new_file" - End If - - cmd = \"""" & script & \""" start" - code = shell.Run("cmd /c " & cmd & " >> " & root & "\Logs\<%= app_name %>.log 2>&1", 0) - """ - - EEx.function_from_string(:defp, :launcher_vbs, code, [:release, :options], trim: true) - defp ensure_vcredistx64 do url = "https://aka.ms/vs/17/release/vc_redist.x64.exe" - sha256 = "426a34c6f10ea8f7da58a8c976b586ad84dd4bab42a0cfdbe941f1763b7755e5" - AppBuilder.Utils.ensure_executable(url, sha256) + AppBuilder.Utils.ensure_executable(url) end defp ensure_makensis do @@ -259,6 +95,7 @@ defmodule AppBuilder.Windows do end defp create_icon(src_path, dest_path) do + log(:green, "creating", Path.relative_to_cwd(dest_path)) src_path = normalize_icon_path(src_path) if Path.extname(src_path) == ".ico" do diff --git a/app_builder/lib/templates/macos/Entitlements.plist.eex b/app_builder/lib/templates/macos/Entitlements.plist.eex new file mode 100644 index 000000000..9cbd47fe2 --- /dev/null +++ b/app_builder/lib/templates/macos/Entitlements.plist.eex @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/app_builder/lib/templates/macos/Info.plist.eex b/app_builder/lib/templates/macos/Info.plist.eex new file mode 100644 index 000000000..7d7d40c1a --- /dev/null +++ b/app_builder/lib/templates/macos/Info.plist.eex @@ -0,0 +1,67 @@ + + + + + CFBundlePackageType + APPL + CFBundleExecutable + <%= @app_options[:name] %>Launcher + CFBundleName + <%= @app_options[:name] %> + CFBundleDisplayName + <%= @app_options[:name] %> + CFBundleShortVersionString + <%= @release.version %> + CFBundleVersion + <%= @release.version %> + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon + +<%= if schemes = @app_options[:url_schemes] do %> + CFBundleURLTypes + + <%= for scheme <- schemes do %> + + CFBundleURLName + <%= @app_options[:name] %> + CFBundleURLSchemes + + <%= scheme %> + + + <% end %> + +<% end %> + +<%= if types = @app_options[:document_types] do %> + CFBundleDocumentTypes + + <%= for type <- types do %> + + CFBundleTypeName + <%= type.name %> + CFBundleTypeRole + <%= type.macos_role %> + CFBundleTypeExtensions + + <%= for ext <- type.extensions do %> + <%= ext %> + <% end %> + + <%= if type[:icon_path] do %> + CFBundleTypeIconFile + <%= type.name %>Icon + <% end %> + + <% end %> + +<% end %> + +<%= if @app_options[:macos_is_agent_app] do %> + LSUIElement + +<% end %> + + diff --git a/app_builder/lib/templates/macos/Launcher.swift.eex b/app_builder/lib/templates/macos/Launcher.swift.eex new file mode 100644 index 000000000..f3e116d2b --- /dev/null +++ b/app_builder/lib/templates/macos/Launcher.swift.eex @@ -0,0 +1,54 @@ +<% + +additional_paths = [ + "rel/erts-#{@release.erts_version}/bin" +] ++ @app_options[:additional_paths] + +additional_paths = Enum.map_join(additional_paths, ":", &"\\(resourcePath)/#{&1}") + +%> +import Foundation +import Cocoa + +func log(_ line: String) { + logFile.write("\(line)\n".data(using: .utf8)!) +} + +let fm = FileManager.default +let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as! String +let home = NSHomeDirectory() +let logPath = "\(home)/Library/Logs/\(appName).log" +if !fm.fileExists(atPath: logPath) { fm.createFile(atPath: logPath, contents: Data()) } +let logFile = FileHandle(forUpdatingAtPath: logPath)! +logFile.seekToEndOfFile() + +let releaseScriptPath = Bundle.main.path(forResource: "rel/bin/<%= @release.name %>", ofType: "")! + +let resourcePath = Bundle.main.resourcePath ?? "" +let additionalPaths = "<%= additional_paths %>" + +var environment = ProcessInfo.processInfo.environment +let path = environment["PATH"] ?? "" +environment["PATH"] = "\(additionalPaths):\(path)" + +let task = Process() +task.environment = environment +task.launchPath = releaseScriptPath +task.arguments = ["start"] +task.standardOutput = logFile +task.standardError = logFile + +log("[\(appName)Launcher] starting release") +try task.run() +log("[\(appName)Launcher] pid: \(task.processIdentifier)") + +task.waitUntilExit() +log("[\(appName)Launcher] release exited with \(task.terminationStatus)") + +if task.terminationStatus != 0 { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "\(appName) exited with error status \(task.terminationStatus)." + alert.informativeText = "Logs available at: \(logPath)" + alert.runModal() +} diff --git a/app_builder/lib/templates/windows/Installer.nsi.eex b/app_builder/lib/templates/windows/Installer.nsi.eex new file mode 100644 index 000000000..6452d6658 --- /dev/null +++ b/app_builder/lib/templates/windows/Installer.nsi.eex @@ -0,0 +1,82 @@ +<% +app_name = Keyword.fetch!(@app_options, :name) +%> +!include "MUI2.nsh" + +;-------------------------------- +;General + +Name "<%= app_name %>" +OutFile "<%= app_name %>Install.exe" +Unicode True +InstallDir "$LOCALAPPDATA\<%= app_name %>" + +; Need admin for registering URL scheme +RequestExecutionLevel admin + +;-------------------------------- +;Interface Settings + +!define MUI_ABORTWARNING + +;-------------------------------- +;Pages + +;!insertmacro MUI_PAGE_COMPONENTS +!define MUI_ICON "AppIcon.ico" +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES + +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES + +;-------------------------------- +;Languages + +!insertmacro MUI_LANGUAGE "English" + +;-------------------------------- +;Installer Sections + +Section "Install" + SetOutPath "$INSTDIR" + + File vcredist_x64.exe + ExecWait '"$INSTDIR\vcredist_x64.exe" /install' + + File /r rel rel + File "<%= app_name %>Launcher.vbs" + File "AppIcon.ico" + + CreateDirectory "$INSTDIR\Logs" + WriteUninstaller "$INSTDIR\<%= app_name %>Uninstall.exe" + +<%= for type <- Keyword.fetch!(@app_options, :document_types) do %> +<%= for ext <- type.extensions do %> + WriteRegStr HKCR ".<%= ext %>" "" "<%= app_name %>.<%= type.name %>" +<% end %> + WriteRegStr HKCR "<%= app_name %>.<%= type.name %>" "" "<%= type.name %>" + WriteRegStr HKCR "<%= app_name %>.<%= type.name %>\DefaultIcon" "" "$INSTDIR\<%= type.name %>Icon.ico" + WriteRegStr HKCR "<%= app_name %>.<%= type.name %>\shell\open\command" "" '$WINDIR\system32\wscript.exe "$INSTDIR\<%= app_name %>Launcher.vbs" "open_file:%1"' +<% end %> + +<%= for url_scheme <- Keyword.fetch!(@app_options, :url_schemes) do %> + DetailPrint "Register <%= url_scheme %> URL Handler" + DeleteRegKey HKCR "<%= url_scheme %>" + WriteRegStr HKCR "<%= url_scheme %>" "" "<%= url_scheme %> Protocol" + WriteRegStr HKCR "<%= url_scheme %>" "URL Protocol" "" + WriteRegStr HKCR "<%= url_scheme %>\shell" "" "" + WriteRegStr HKCR "<%= url_scheme %>\shell\open" "" "" + WriteRegStr HKCR "<%= url_scheme %>\shell\open\command" "" '$WINDIR\system32\wscript.exe "$INSTDIR\<%= app_name %>Launcher.vbs" "open_url:%1"' +<% end %> +SectionEnd + +Section "Desktop Shortcut" + CreateShortCut "$DESKTOP\<%= app_name %>.lnk" "$INSTDIR\<%= app_name %>Launcher.vbs" "" "$INSTDIR\AppIcon.ico" +SectionEnd + +Section "Uninstall" + Delete "$DESKTOP\<%= app_name %>.lnk" + ; TODO: stop epmd if it was started + RMDir /r "$INSTDIR" +SectionEnd diff --git a/app_builder/lib/templates/windows/Launcher.vbs.eex b/app_builder/lib/templates/windows/Launcher.vbs.eex new file mode 100644 index 000000000..0fc87bd6d --- /dev/null +++ b/app_builder/lib/templates/windows/Launcher.vbs.eex @@ -0,0 +1,48 @@ +' This vbs script avoids a flashing cmd window when launching the release bat file + +root = Left(Wscript.ScriptFullName, Len(Wscript.ScriptFullName) - Len(Wscript.ScriptName)) +script = root & "rel\bin\<%= @release.name %>.bat" + +Set shell = CreateObject("WScript.Shell") + +' Below we run two commands: +' +' 1. bin/release rpc +' 2. bin/release start +' +' The first one will only succeed when the app is already running. The second one when it is not. +' It's ok for either to fail because we run them asynchronously. + +Set env = shell.Environment("Process") +env("PATH") = "<%= Enum.map_join(@app_options[:additional_paths], ";", &String.replace(&1, "/", "\\")) %>" & env("PATH") + +If WScript.Arguments.Count > 0 Then + input = WScript.Arguments(0) +Else + input = "reopen_app" +End If + +' Below, we're basically doing: +' +' $ bin/release rpc 'AppBuilder.Windows.__send_events__(MyApp, input)' +' +' We send the input through IO, as opposed using the rpc expression, to avoid RCE. +cmd = "echo " & input & " | """ & script & """ rpc ""AppBuilder.Windows.__send_events__(<%= inspect(@app_options[:server]) %>, String.trim(IO.read(:line)))""" +code = shell.Run("cmd /c " & cmd, 0) + +' Below, we're basically doing: +' +' $ bin/release start +' +' We send the input through the environment variable as we can't easily access argv +' when booting through the release script. + +If WScript.Arguments.Count > 0 Then + env("APP_BUILDER_INPUT") = WScript.Arguments(0) +Else + env("APP_BUILDER_INPUT") = "new_file" +End If + +cmd = """" & script & """ start" +code = shell.Run("cmd /c " & cmd & " >> " & root & "\Logs\<%= @app_options[:name] %>.log 2>&1", 0) + diff --git a/app_builder/lib/templates/windows/Manifest.xml.eex b/app_builder/lib/templates/windows/Manifest.xml.eex new file mode 100644 index 000000000..f2708ecb1 --- /dev/null +++ b/app_builder/lib/templates/windows/Manifest.xml.eex @@ -0,0 +1,9 @@ + + + + + true + PerMonitorV2 + + + diff --git a/mix.exs b/mix.exs index 29afb3901..f37c42c97 100644 --- a/mix.exs +++ b/mix.exs @@ -126,6 +126,8 @@ defmodule Livebook.MixProject do ## Releases defp releases do + macos_notarization = macos_notarization() + [ livebook: [ include_executables_for: [:unix], @@ -133,32 +135,58 @@ defmodule Livebook.MixProject do rel_templates_path: "rel/server", steps: [:assemble, &remove_cookie/1] ], - mac_app: [ - include_executables_for: [:unix], - include_erts: false, - rel_templates_path: "rel/app", - steps: [:assemble, &remove_cookie/1, &standalone_erlang_elixir/1, &build_mac_app/1] - ], - mac_app_dmg: [ - include_executables_for: [:unix], - include_erts: false, - rel_templates_path: "rel/app", - steps: [:assemble, &remove_cookie/1, &standalone_erlang_elixir/1, &build_mac_app_dmg/1] - ], - windows_installer: [ - include_executables_for: [:windows], + app: [ include_erts: false, rel_templates_path: "rel/app", steps: [ :assemble, &remove_cookie/1, &standalone_erlang_elixir/1, - &build_windows_installer/1 + &AppBuilder.bundle/1 + ], + app: [ + name: "Livebook", + icon_path: [ + macos: "rel/app/icon-macos.png", + windows: "rel/app/icon.ico" + ], + url_schemes: ["livebook"], + document_types: [ + %{ + name: "LiveMarkdown", + extensions: ["livemd"], + icon_path: [ + macos: "rel/app/icon.png", + windows: "rel/app/icon.ico" + ], + macos_role: "Editor" + } + ], + additional_paths: [ + "rel/erts-#{:erlang.system_info(:version)}/bin", + "rel/vendor/elixir/bin" + ], + server: LivebookApp, + macos_is_agent_app: true, + macos_build_dmg: macos_notarization != nil, + macos_notarization: macos_notarization, + windows_build_installer: true ] ] ] end + defp macos_notarization do + identity = System.get_env("NOTARIZE_IDENTITY") + team_id = System.get_env("NOTARIZE_TEAM_ID") + apple_id = System.get_env("NOTARIZE_APPLE_ID") + password = System.get_env("NOTARIZE_PASSWORD") + + if identity && team_id && apple_id && password do + [identity: identity, team_id: team_id, apple_id: apple_id, password: password] + end + end + defp remove_cookie(release) do File.rm!(Path.join(release.path, "releases/COOKIE")) release @@ -171,61 +199,4 @@ defmodule Livebook.MixProject do |> Standalone.copy_otp() |> Standalone.copy_elixir(@app_elixir_version) end - - @app_options [ - name: "Livebook", - version: @version, - icon_path: [ - macos: "rel/app/icon-macos.png", - windows: "rel/app/icon.ico" - ], - additional_paths: [ - "/rel/erts-#{:erlang.system_info(:version)}/bin", - "/rel/vendor/elixir/bin" - ], - url_schemes: ["livebook"], - document_types: [ - %{ - name: "LiveMarkdown", - extensions: ["livemd"], - icon_path: [ - macos: "rel/app/icon.png", - windows: "rel/app/icon.ico" - ], - # macos specific - role: "Editor" - } - ] - ] - - defp build_mac_app(release) do - options = - [ - is_agent_app: true - ] ++ @app_options - - AppBuilder.build_mac_app(release, options) - end - - defp build_mac_app_dmg(release) do - options = - [ - is_agent_app: true, - codesign: [ - identity: System.fetch_env!("CODESIGN_IDENTITY") - ], - notarize: [ - team_id: System.fetch_env!("NOTARIZE_TEAM_ID"), - apple_id: System.fetch_env!("NOTARIZE_APPLE_ID"), - password: System.fetch_env!("NOTARIZE_PASSWORD") - ] - ] ++ @app_options - - AppBuilder.build_mac_app_dmg(release, options) - end - - defp build_windows_installer(release) do - options = Keyword.drop(@app_options, [:additional_paths]) ++ [module: LivebookApp] - AppBuilder.build_windows_installer(release, options) - end end