diff --git a/.github/scripts/app/bootstrap_mac.sh b/.github/scripts/app/bootstrap_mac.sh index 90412e612..8995483c9 100755 --- a/.github/scripts/app/bootstrap_mac.sh +++ b/.github/scripts/app/bootstrap_mac.sh @@ -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 - diff --git a/app_builder/examples/wx_demo/lib/wx_demo.ex b/app_builder/examples/wx_demo/lib/wx_demo.ex index 3ab46eb2a..beab01d5e 100644 --- a/app_builder/examples/wx_demo/lib/wx_demo.ex +++ b/app_builder/examples/wx_demo/lib/wx_demo.ex @@ -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 diff --git a/app_builder/examples/wx_demo/mix.exs b/app_builder/examples/wx_demo/mix.exs index d12373993..1647f3dcd 100644 --- a/app_builder/examples/wx_demo/mix.exs +++ b/app_builder/examples/wx_demo/mix.exs @@ -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 ] ] diff --git a/app_builder/lib/app_builder.ex b/app_builder/lib/app_builder.ex index b4aa840f0..4a246e618 100644 --- a/app_builder/lib/app_builder.ex +++ b/app_builder/lib/app_builder.ex @@ -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: [] diff --git a/app_builder/lib/app_builder/windows.ex b/app_builder/lib/app_builder/windows.ex index 34571faaa..c233a8630 100644 --- a/app_builder/lib/app_builder/windows.ex +++ b/app_builder/lib/app_builder/windows.ex @@ -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 diff --git a/app_builder/lib/app_builder/wx.ex b/app_builder/lib/app_builder/wx.ex deleted file mode 100644 index 32c451c53..000000000 --- a/app_builder/lib/app_builder/wx.ex +++ /dev/null @@ -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 diff --git a/app_builder/lib/templates/macos/Entitlements.plist.eex b/app_builder/lib/templates/macos/Entitlements.plist.eex index 9cbd47fe2..5e8e61cde 100644 --- a/app_builder/lib/templates/macos/Entitlements.plist.eex +++ b/app_builder/lib/templates/macos/Entitlements.plist.eex @@ -8,5 +8,7 @@ com.apple.security.cs.allow-dyld-environment-variables + com.apple.security.cs.disable-library-validation + diff --git a/app_builder/lib/templates/macos/Launcher.swift.eex b/app_builder/lib/templates/macos/Launcher.swift.eex index f3e116d2b..97abc6d60 100644 --- a/app_builder/lib/templates/macos/Launcher.swift.eex +++ b/app_builder/lib/templates/macos/Launcher.swift.eex @@ -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() diff --git a/app_builder/lib/templates/windows/Launcher.vbs.eex b/app_builder/lib/templates/windows/Launcher.vbs.eex index 0fc87bd6d..a11bb0def 100644 --- a/app_builder/lib/templates/windows/Launcher.vbs.eex +++ b/app_builder/lib/templates/windows/Launcher.vbs.eex @@ -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) diff --git a/lib/livebook_app.ex b/lib/livebook_app.ex index f5553b081..f65da6c35 100644 --- a/lib/livebook_app.ex +++ b/lib/livebook_app.ex @@ -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 diff --git a/mix.exs b/mix.exs index 792de8615..474958596 100644 --- a/mix.exs +++ b/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 ] diff --git a/rel/app/env.bat.eex b/rel/app/env.bat.eex index 0e63d90c1..420220840 100644 --- a/rel/app/env.bat.eex +++ b/rel/app/env.bat.eex @@ -1,3 +1,4 @@ +set RELEASE_NODE=livebook set RELEASE_MODE=interactive set LIVEBOOK_SHUTDOWN_ENABLED=true diff --git a/rel/app/env.sh.eex b/rel/app/env.sh.eex index f75c1188c..c199a48d0 100644 --- a/rel/app/env.sh.eex +++ b/rel/app/env.sh.eex @@ -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