mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-07 20:16:31 +08:00
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:
parent
f9156e7a9f
commit
789a44a7da
13 changed files with 290 additions and 340 deletions
7
.github/scripts/app/bootstrap_mac.sh
vendored
7
.github/scripts/app/bootstrap_mac.sh
vendored
|
@ -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 -
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
]
|
||||
]
|
||||
|
|
|
@ -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: []
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
3
mix.exs
3
mix.exs
|
@ -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
|
||||
]
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
set RELEASE_NODE=livebook
|
||||
set RELEASE_MODE=interactive
|
||||
set LIVEBOOK_SHUTDOWN_ENABLED=true
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue