Convert Mac launcher to a Cocoa app (#1263)

1. Add `:event_handler` app option. It is a process that will receive
   the app events, the `:open_app`, `{:open_url, url}`, and
   `{:open_file, path}` messages.

2. Add `AppBuilder.init/0`. This reads the `APP_BUILDER_INPUT` env
   variable which contains the app event the release was started with.

3. Use <https://github.com/wojtekmach/otp/tree/wm-WX_MACOS_NON_GUI_APP>
   branch which contains wx fix that hasn't been merged yet.
This commit is contained in:
Wojtek Mach 2022-07-06 19:27:06 +02:00 committed by GitHub
parent f9156e7a9f
commit 789a44a7da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 290 additions and 340 deletions

View file

@ -6,8 +6,8 @@ main() {
wxwidgets_repo="wxWidgets/wxWidgets"
wxwidgets_ref="v3.1.7"
otp_repo="erlang/otp"
otp_ref="OTP-25.0.2"
otp_repo="wojtekmach/otp"
otp_ref="wm-WX_MACOS_NON_GUI_APP"
elixir_vsn="1.13.4"
target=$(target)
@ -78,7 +78,8 @@ build_wxwidgets() {
--with-libpng=builtin \
--with-liblzma=builtin \
--with-zlib=builtin \
--with-expat=builtin
--with-expat=builtin \
--with-regex=builtin
make
make install
cd -

View file

@ -16,38 +16,21 @@ end
defmodule WxDemo.Window do
@moduledoc false
use GenServer, restart: :transient
@behaviour :wx_object
def start_link(arg) do
GenServer.start_link(__MODULE__, arg, name: __MODULE__)
end
# https://github.com/erlang/otp/blob/OTP-24.1.2/lib/wx/include/wx.hrl#L1314
@wx_id_exit 5006
@wx_id_osx_hide 5250
def start_link(_) do
{:wx_ref, _, _, pid} = :wx_object.start_link({:local, __MODULE__}, __MODULE__, [], [])
{:ok, pid}
end
def child_spec(init_arg) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [init_arg]},
restart: :transient
}
end
def windows_connected(url) do
url
|> String.trim()
|> String.trim_leading("\"")
|> String.trim_trailing("\"")
|> windows_to_wx()
end
@impl true
def init(_) do
AppBuilder.init()
app_name = "WxDemo"
os = os()
os = AppBuilder.os()
wx = :wx.new()
frame = :wxFrame.new(wx, -1, app_name, size: {400, 400})
@ -59,33 +42,24 @@ defmodule WxDemo.Window do
:wxFrame.connect(frame, :command_menu_selected, skip: true)
:wxFrame.connect(frame, :close_window, skip: true)
case os do
:macos ->
:wx.subscribe_events()
:windows ->
windows_to_wx(System.get_env("WXDEMO_URL") || "")
end
state = %{frame: frame}
{frame, state}
{:ok, %{frame: frame}}
end
@impl true
def handle_event({:wx, @wx_id_exit, _, _, _}, state) do
def handle_info({:wx, @wx_id_exit, _, _, _}, state) do
:init.stop()
{:stop, :shutdown, state}
end
@impl true
def handle_event({:wx, _, _, _, {:wxClose, :close_window}}, state) do
def handle_info({:wx, _, _, _, {:wxClose, :close_window}}, state) do
:init.stop()
{:stop, :shutdown, state}
end
# ignore other menu events
@impl true
def handle_event({:wx, _, _, _, {:wxCommand, :command_menu_selected, _, _, _}}, state) do
def handle_info({:wx, _, _, _, {:wxCommand, :command_menu_selected, _, _, _}}, state) do
{:noreply, state}
end
@ -118,23 +92,4 @@ defmodule WxDemo.Window do
|> :wxMenu.findItem(@wx_id_exit)
|> :wxMenuItem.setItemLabel("Quit #{app_name}\tCtrl+Q")
end
defp os() do
case :os.type() do
{:unix, :darwin} -> :macos
{:win32, _} -> :windows
end
end
defp windows_to_wx("") do
send(__MODULE__, {:new_file, ''})
end
defp windows_to_wx("wxdemo://" <> _ = url) do
send(__MODULE__, {:open_url, String.to_charlist(url)})
end
defp windows_to_wx(path) do
send(__MODULE__, {:open_file, String.to_charlist(path)})
end
end

View file

@ -46,12 +46,12 @@ defmodule WxDemo.MixProject do
]
]
],
event_handler: WxDemo.Window,
macos: [
build_dmg: macos_notarization != nil,
notarization: macos_notarization
],
windows: [
server: WxDemo,
build_installer: true
]
]

View file

@ -18,6 +18,36 @@ defmodule AppBuilder do
end
end
def init do
if input = System.get_env("APP_BUILDER_INPUT") do
__rpc__(self(), input)
end
end
def __rpc__(event_handler) do
input = IO.read(:line) |> String.trim()
__rpc__(event_handler, input)
end
def __rpc__(event_handler, "open_app") do
send(event_handler, :open_app)
end
def __rpc__(event_handler, "open_url:" <> url) do
send(event_handler, {:open_url, url})
end
def __rpc__(event_handler, "open_file:" <> path) do
path =
if os() == :windows do
String.replace(path, "\\", "/")
else
path
end
send(event_handler, {:open_file, path})
end
defp validate_options(options) do
os = os()
@ -25,6 +55,7 @@ defmodule AppBuilder do
all: [
:name,
:icon_path,
:event_handler,
url_schemes: [],
document_types: [],
additional_paths: []

View file

@ -53,27 +53,14 @@ defmodule AppBuilder.Windows do
release
end
def __send_events__(server, input)
def handle_event(module, input)
def __send_events__(server, "new_file") do
send(server, {:new_file, ''})
def handle_event(module, "open_url:" <> url) do
module.open_url(url)
end
def __send_events__(server, "reopen_app") do
send(server, {:reopen_app, ''})
end
def __send_events__(server, "open_url:" <> url) do
send(server, {:open_url, String.to_charlist(url)})
end
def __send_events__(server, "open_file:" <> path) do
path =
path
|> String.replace("\\", "/")
|> String.to_charlist()
send(server, {:open_file, path})
def handle_event(module, "open_file:" <> path) do
module.open_file(String.replace(path, "\\", "/"))
end
defp ensure_vcredistx64 do

View file

@ -1,39 +0,0 @@
defmodule AppBuilder.Wx do
require Logger
@doc """
Subscribe the given `server` to application events.
The possible events are:
* `{:new_file, []}`
* `{:reopen_app, []}`
* `{:open_file, path}`
* `{:open_url, url}`
On macOS, we simply call `:wx.subscribe_events()`. On Windows,
we emulate it.
"""
def subscribe_to_app_events(server) do
unless Code.ensure_loaded?(:wx) do
Logger.error("""
wx is not available.
Please add it to your extra applications:
extra_applications: [:wx]
""")
raise "wx is not available"
end
case :os.type() do
{:unix, :darwin} ->
:wx.subscribe_events()
{:win32, _} ->
input = System.get_env("APP_BUILDER_INPUT", "new_file")
AppBuilder.Windows.__send_events__(server, input)
end
end
end

View file

@ -8,5 +8,7 @@
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

View file

@ -1,17 +1,121 @@
<%
additional_paths = [
"rel/erts-#{@release.erts_version}/bin"
] ++ @app_options[:additional_paths]
event_handler = @app_options |> Keyword.fetch!(:event_handler) |> inspect()
additional_paths = Enum.map_join(additional_paths, ":", &"\\(resourcePath)/#{&1}")
additional_paths =
["rel/erts-#{@release.erts_version}/bin"] ++ @app_options[:additional_paths]
|> Enum.map_join(":", &"\\(resourcePath)/#{&1}")
%>
import Foundation
import Cocoa
class AppDelegate: NSObject, NSApplicationDelegate {
var releaseTask: Process!
var isRunning = false
var initialInput = "open_app"
func applicationDidFinishLaunching(_ aNotification: Notification) {
if !isRunning {
releaseTask = startRelease(initialInput)
isRunning = true
}
}
func applicationWillTerminate(_ n: Notification) {
if (releaseTask.isRunning == true) {
log("terminating release task")
releaseTask.terminate()
}
}
func application(_ app: NSApplication, open urls: [URL]) {
for url in urls {
var input : String
if url.isFileURL {
input = "open_file:\(url.path)"
} else {
input = "open_url:\(url)"
}
if isRunning {
rpc(input)
} else {
initialInput = input
}
}
}
}
func startRelease(_ input : String) -> Process {
let task = buildReleaseTask()
task.environment!["APP_BUILDER_INPUT"] = input
task.arguments = ["start"]
task.terminationHandler = {(t: Process) in
if t.terminationStatus == 0 {
log("release exited with: \(t.terminationStatus)")
} else {
runAlert(messageText: "\(appName) exited with error status \(t.terminationStatus).")
}
NSApp.terminate(nil)
}
try! task.run()
log("release pid: \(task.processIdentifier)")
DispatchQueue.global(qos: .userInteractive).async {
task.waitUntilExit()
}
return task
}
func rpc(_ event: String) {
let input = Pipe()
let task = buildReleaseTask()
task.standardInput = input
input.fileHandleForWriting.write("\(event)\n".data(using: .utf8)!)
task.arguments = ["rpc", "AppBuilder.__rpc__(<%= event_handler %>)"]
try! task.run()
task.waitUntilExit()
if task.terminationStatus != 0 {
runAlert(messageText: "Something went wrong")
}
}
func buildReleaseTask() -> Process {
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.standardOutput = logFile
task.standardError = logFile
return task
}
func runAlert(messageText: String) {
DispatchQueue.main.async {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = messageText
alert.informativeText = "Logs available at: \(logPath)"
alert.runModal()
}
}
func log(_ line: String) {
logFile.write("\(line)\n".data(using: .utf8)!)
logFile.write("[\(appName)Launcher] \(line)\n".data(using: .utf8)!)
}
let fm = FileManager.default
@ -22,33 +126,7 @@ if !fm.fileExists(atPath: logPath) { fm.createFile(atPath: logPath, contents: Da
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()
}
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()

View file

@ -1,48 +1,32 @@
' This vbs script avoids a flashing cmd window when launching the release bat file
<%
event_handler = @app_options |> Keyword.fetch!(:event_handler) |> inspect()
additional_paths =
["rel/erts-#{@release.erts_version}/bin"] ++ @app_options[:additional_paths]
|> Enum.map(&("root & \"" <> String.replace(&1, "/", "\\") <> ";\" & "))
%>
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"
input = "open_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)
Set shell = CreateObject("WScript.Shell")
Set env = shell.Environment("Process")
env("PATH") = <%= additional_paths %>env("PATH")
' 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
' try release rpc, if release is down, this will fail but that's ok.
cmd = "echo " & input & " | """ & script & """ rpc ""AppBuilder.__rpc__(<%= event_handler %>)"""
status = shell.Run("cmd /c " & cmd, 0, true)
' try release start, if release is up, this will fail but that's ok.
env("APP_BUILDER_INPUT") = input
cmd = """" & script & """ start"
code = shell.Run("cmd /c " & cmd & " >> " & root & "\Logs\<%= @app_options[:name] %>.log 2>&1", 0)
status = shell.Run("cmd /c " & cmd, 0)

View file

@ -1,12 +1,105 @@
if Mix.target() == :app do
defmodule LivebookApp do
@moduledoc false
@name __MODULE__
@wxID_OPEN 5000
@wxID_EXIT 5006
@wxBITMAP_TYPE_PNG 15
use GenServer
def start_link(arg) do
GenServer.start_link(__MODULE__, arg, name: @name)
end
taskbar_icon_path = "rel/app/icon.png"
@external_resource taskbar_icon_path
@taskbar_icon File.read!(taskbar_icon_path)
@impl true
def init(_) do
AppBuilder.init()
os = AppBuilder.os()
:wx.new()
# TODO: instead of embedding the icon and copying to tmp, copy the file known location.
# It's a bit tricky because it needs to support all the ways of running the app:
# 1. MIX_TARGET=app mix phx.server
# 2. mix app
# 3. mix release app
taskbar_icon_path = Path.join(System.tmp_dir!(), "icon.png")
File.write!(taskbar_icon_path, @taskbar_icon)
icon = :wxIcon.new(taskbar_icon_path, type: @wxBITMAP_TYPE_PNG)
menu_items = [
{"Open Browser", key: "ctrl+o", id: @wxID_OPEN},
{"Quit", key: "ctrl+q", id: @wxID_EXIT}
]
taskbar = WxUtils.taskbar("Livebook", icon, menu_items)
if os == :windows do
:wxTaskBarIcon.connect(taskbar, :taskbar_left_down,
callback: fn _, _ ->
open_browser()
end
)
end
{:ok, nil}
end
@impl true
def handle_info(:open_app, state) do
open_browser()
{:noreply, state}
end
@impl true
def handle_info({:open_file, path}, state) do
path
|> Livebook.Utils.notebook_open_url()
|> open_browser()
{:noreply, state}
end
@impl true
def handle_info({:open_url, "livebook://" <> rest}, state) do
"https://#{rest}"
|> Livebook.Utils.notebook_import_url()
|> open_browser()
{:noreply, state}
end
@impl true
def handle_info({:wx, @wxID_EXIT, _, _, _}, _state) do
System.stop(0)
end
@impl true
def handle_info({:wx, @wxID_OPEN, _, _, _}, state) do
open_browser()
{:noreply, state}
end
if Mix.env() == :dev do
@impl true
def handle_info(event, state) do
IO.inspect(event)
{:noreply, state}
end
end
defp open_browser(url \\ LivebookWeb.Endpoint.access_url()) do
Livebook.Utils.browser_open(url)
end
end
defmodule WxUtils do
@moduledoc false
defmacro wxID_ANY, do: -1
defmacro wxID_OPEN, do: 5000
defmacro wxID_EXIT, do: 5006
defmacro wxID_OSX_HIDE, do: 5250
defmacro wxBITMAP_TYPE_PNG, do: 15
@wxID_ANY -1
def taskbar(title, icon, menu_items) do
pid = self()
@ -66,7 +159,7 @@ if Mix.target() == :app do
Enum.each(items, fn
{title, options} ->
id = Keyword.get(options, :id, wxID_ANY())
id = Keyword.get(options, :id, @wxID_ANY)
title =
case Keyword.fetch(options, :key) do
@ -82,149 +175,5 @@ if Mix.target() == :app do
menu
end
def menubar(app_name, menus) do
menubar = :wxMenuBar.new()
if AppBuilder.os() == :macos, do: fixup_macos_menubar(menubar, app_name)
for {title, menu_items} <- menus do
true = :wxMenuBar.append(menubar, menu(menu_items), title)
end
menubar
end
defp fixup_macos_menubar(menubar, app_name) do
menu = :wxMenuBar.oSXGetAppleMenu(menubar)
menu
|> :wxMenu.findItem(wxID_OSX_HIDE())
|> :wxMenuItem.setItemLabel("Hide #{app_name}\tCtrl+H")
menu
|> :wxMenu.findItem(wxID_EXIT())
|> :wxMenuItem.setItemLabel("Quit #{app_name}\tCtrl+Q")
end
end
defmodule LivebookApp do
@moduledoc false
@name __MODULE__
use GenServer
import WxUtils
def start_link(arg) do
GenServer.start_link(__MODULE__, arg, name: @name)
end
taskbar_icon_path = "rel/app/icon.png"
@external_resource taskbar_icon_path
@taskbar_icon File.read!(taskbar_icon_path)
@impl true
def init(_) do
os = AppBuilder.os()
wx = :wx.new()
AppBuilder.Wx.subscribe_to_app_events(@name)
menu_items = [
{"Open Browser", key: "ctrl+o", id: wxID_OPEN()},
{"Quit", key: "ctrl+q", id: wxID_EXIT()}
]
if os == :macos do
:wxMenuBar.setAutoWindowMenu(false)
menubar =
menubar("Livebook", [
{"File", menu_items}
])
:ok = :wxMenuBar.connect(menubar, :command_menu_selected, skip: true)
# TODO: use :wxMenuBar.macSetCommonMenuBar/1 when OTP 25 is out
frame = :wxFrame.new(wx, -1, "", size: {0, 0})
:wxFrame.show(frame)
:wxFrame.setMenuBar(frame, menubar)
end
# TODO: instead of embedding the icon and copying to tmp, copy the file known location.
# It's a bit tricky because it needs to support all the ways of running the app:
# 1. MIX_TARGET=app mix phx.server
# 2. mix app
# 3. mix release app
taskbar_icon_path = Path.join(System.tmp_dir!(), "icon.png")
File.write!(taskbar_icon_path, @taskbar_icon)
icon = :wxIcon.new(taskbar_icon_path, type: wxBITMAP_TYPE_PNG())
taskbar = taskbar("Livebook", icon, menu_items)
if os == :windows do
:wxTaskBarIcon.connect(taskbar, :taskbar_left_down,
callback: fn _, _ ->
open_browser()
end
)
end
{:ok, nil}
end
@impl true
def handle_info({:wx, wxID_EXIT(), _, _, _}, _state) do
System.stop(0)
end
@impl true
def handle_info({:wx, wxID_OPEN(), _, _, _}, state) do
open_browser()
{:noreply, state}
end
# This event is triggered when the application is opened for the first time
@impl true
def handle_info({:new_file, ''}, state) do
open_browser()
{:noreply, state}
end
@impl true
def handle_info({:reopen_app, _}, state) do
open_browser()
{:noreply, state}
end
@impl true
def handle_info({:open_file, path}, state) do
path
|> List.to_string()
|> Livebook.Utils.notebook_open_url()
|> open_browser()
{:noreply, state}
end
@impl true
def handle_info({:open_url, 'livebook://' ++ rest}, state) do
"https://#{rest}"
|> Livebook.Utils.notebook_import_url()
|> open_browser()
{:noreply, state}
end
if Mix.env() == :dev do
@impl true
def handle_info(event, state) do
IO.inspect(event)
{:noreply, state}
end
end
defp open_browser(url \\ LivebookWeb.Endpoint.access_url()) do
Livebook.Utils.browser_open(url)
end
end
end

View file

@ -160,8 +160,8 @@ defmodule Livebook.MixProject do
]
]
],
event_handler: LivebookApp,
additional_paths: [
"rel/erts-#{:erlang.system_info(:version)}/bin",
"rel/vendor/elixir/bin"
],
macos: [
@ -171,7 +171,6 @@ defmodule Livebook.MixProject do
notarization: macos_notarization
],
windows: [
server: LivebookApp,
icon_path: "rel/app/icon.ico",
build_installer: true
]

View file

@ -1,3 +1,4 @@
set RELEASE_NODE=livebook
set RELEASE_MODE=interactive
set LIVEBOOK_SHUTDOWN_ENABLED=true

View file

@ -1,5 +1,7 @@
export RELEASE_NODE=livebook
export RELEASE_MODE=interactive
export LIVEBOOK_SHUTDOWN_ENABLED=true
export WX_MACOS_NON_GUI_APP=1
cookie_path="${RELEASE_ROOT}/releases/COOKIE"
if [ ! -f $cookie_path ]; then