mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 01:55:56 +08:00
Rebuild Livebook Desktop (#1641)
This commit is contained in:
parent
4f6ce86e2b
commit
596df882fc
36
.github/scripts/app/bootstrap_mac.sh
vendored
36
.github/scripts/app/bootstrap_mac.sh
vendored
|
@ -1,36 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -e pipefail
|
||||
|
||||
main() {
|
||||
export MAKEFLAGS=-j$(getconf _NPROCESSORS_ONLN)
|
||||
elixir_vsn="${elixir_vsn:-1.14.2}"
|
||||
|
||||
mkdir -p tmp/cache
|
||||
. .github/scripts/app/bootstrap_otp_mac.sh
|
||||
|
||||
elixir_dir="$PWD/tmp/cache/elixir-${elixir_vsn}"
|
||||
if [ ! -d "${elixir_dir}" ]; then
|
||||
build_elixir $elixir_vsn $elixir_dir
|
||||
fi
|
||||
|
||||
export PATH="${elixir_dir}/bin:$PATH"
|
||||
echo "checking elixir"
|
||||
elixir --version
|
||||
echo "elixir ok"
|
||||
}
|
||||
|
||||
# build_elixir $vsn $dest_dir
|
||||
build_elixir() {
|
||||
vsn=$1
|
||||
dest_dir=$2
|
||||
otp_release=$(erl -noshell -eval 'io:format("~s", [erlang:system_info(otp_release)]), halt().')
|
||||
|
||||
cd tmp
|
||||
url=https://repo.hex.pm/builds/elixir/v${vsn}-otp-${otp_release}.zip
|
||||
curl --fail -LO $url
|
||||
mkdir -p $dest_dir
|
||||
unzip -q v${vsn}-otp-${otp_release}.zip -d $dest_dir
|
||||
cd - > /dev/null
|
||||
}
|
||||
|
||||
main
|
103
.github/scripts/app/bootstrap_otp_mac.sh
vendored
103
.github/scripts/app/bootstrap_otp_mac.sh
vendored
|
@ -1,103 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -e pipefail
|
||||
|
||||
main() {
|
||||
export MAKEFLAGS=-j$(getconf _NPROCESSORS_ONLN)
|
||||
|
||||
wxwidgets_repo="${wxwidgets_repo:-wxWidgets/wxWidgets}"
|
||||
wxwidgets_ref="${wxwidgets_ref:-v3.1.7}"
|
||||
otp_repo="${otp_repo:-wojtekmach/otp}"
|
||||
otp_ref="${otp_ref:-wm-WX_MACOS_NON_GUI_APP}"
|
||||
|
||||
mkdir -p tmp/cache
|
||||
|
||||
openssl_dir=$(brew --prefix openssl@1.1)
|
||||
|
||||
otp_dir="$PWD/tmp/cache/${otp_repo}-${otp_ref}"
|
||||
if [ ! -d $otp_dir ]; then
|
||||
wxwidgets_dir="$PWD/tmp/cache/${wxwidgets_repo}-${wxwidgets_ref}"
|
||||
if [ ! -d $wxwidgets_dir ]; then
|
||||
build_wxwidgets $wxwidgets_repo $wxwidgets_ref $wxwidgets_dir
|
||||
fi
|
||||
|
||||
export PATH="${wxwidgets_dir}/bin:$PATH"
|
||||
echo "checking wx"
|
||||
file `which wxrc`
|
||||
wx-config --version
|
||||
echo "wx ok"
|
||||
echo
|
||||
build_otp $otp_repo $otp_ref $openssl_dir $otp_dir
|
||||
fi
|
||||
|
||||
export PATH="${otp_dir}/bin:$PATH"
|
||||
echo "checking otp"
|
||||
cd $otp_dir
|
||||
./Install -sasl $PWD
|
||||
erl -noshell -eval 'io:format("root_dir=~p~n", [code:root_dir()]), halt().'
|
||||
erl -noshell -eval 'io:format("~s", [erlang:system_info(system_version)]), halt().'
|
||||
erl -noshell -eval 'io:format("~s~n", [erlang:system_info(system_architecture)]), halt().'
|
||||
erl -noshell -eval 'ok = crypto:start(), io:format("crypto ok~n"), halt().'
|
||||
erl -noshell -eval '{wx_ref,_,_,_} = wx:new(), io:format("wx ok~n"), halt().'
|
||||
cd - > /dev/null
|
||||
echo "otp ok"
|
||||
echo
|
||||
}
|
||||
|
||||
# build_wxwidgets $repo $ref $dest_dir
|
||||
build_wxwidgets() {
|
||||
repo=$1
|
||||
ref=$2
|
||||
dest_dir=$3
|
||||
src_dir=tmp/$repo-$ref-src
|
||||
|
||||
if [ ! -d $src_dir ]; then
|
||||
echo cloning $repo $ref
|
||||
git clone --branch $ref --depth 1 --recursive https://github.com/$repo $src_dir
|
||||
fi
|
||||
|
||||
cd $src_dir
|
||||
./configure \
|
||||
--disable-shared \
|
||||
--prefix=$dest_dir \
|
||||
--with-cocoa \
|
||||
--with-macosx-version-min=10.15 \
|
||||
--disable-sys-libs
|
||||
make
|
||||
make install
|
||||
cd - > /dev/null
|
||||
}
|
||||
|
||||
|
||||
# build_otp $repo $ref $openssl_dir $dest_dir
|
||||
build_otp() {
|
||||
repo=$1
|
||||
ref=$2
|
||||
openssl_dir=$3
|
||||
dest_dir=$4
|
||||
|
||||
src_dir=tmp/otp-$repo-$ref-src
|
||||
if [ ! -d $src_dir ]; then
|
||||
echo cloning $repo $ref
|
||||
git clone --branch $ref --depth 1 --recursive https://github.com/$repo $src_dir
|
||||
fi
|
||||
|
||||
export RELEASE_ROOT=$dest_dir
|
||||
|
||||
cd $src_dir
|
||||
export ERL_TOP=`pwd`
|
||||
export ERLC_USE_SERVER=true
|
||||
./otp_build configure \
|
||||
--disable-dynamic-ssl-lib \
|
||||
--with-ssl=$openssl_dir \
|
||||
--without-odbc
|
||||
|
||||
./otp_build boot -a
|
||||
./otp_build release -a $RELEASE_ROOT
|
||||
|
||||
if [ -z "$skip_docs" ]; then
|
||||
make release_docs DOC_TARGETS=chunks
|
||||
fi
|
||||
cd - > /dev/null
|
||||
}
|
||||
|
||||
main
|
15
.github/scripts/app/build_mac.sh
vendored
15
.github/scripts/app/build_mac.sh
vendored
|
@ -1,15 +0,0 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# $ sh .github/scripts/app/build_mac.sh
|
||||
# $ 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
|
||||
|
||||
. .github/scripts/app/bootstrap_mac.sh
|
||||
mix local.hex --force --if-missing
|
||||
mix local.rebar --force --if-missing
|
||||
MIX_ENV=prod MIX_TARGET=app mix deps.get --only prod
|
||||
MIX_ENV=prod MIX_TARGET=app mix release app --overwrite
|
63
.github/scripts/app/build_macos.sh
vendored
Executable file
63
.github/scripts/app/build_macos.sh
vendored
Executable file
|
@ -0,0 +1,63 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# $ .github/scripts/app/build_macos.sh
|
||||
# $ open rel/app/macos/.build/LivebookInstall.dmg
|
||||
# $ open livebook://github.com/livebook-dev/livebook/blob/main/test/support/notebooks/basic.livemd
|
||||
# $ open ./test/support/notebooks/basic.livemd
|
||||
#
|
||||
# Note: This script builds the Mac installer. If you just want to test the Mac app locally, run:
|
||||
#
|
||||
# $ cd rel/app/macos && ./run.sh
|
||||
#
|
||||
# See rel/app/macos/README.md for more information.
|
||||
set -euo pipefail
|
||||
|
||||
main() {
|
||||
bootstrap_otp
|
||||
download_elixir
|
||||
build_app
|
||||
}
|
||||
|
||||
bootstrap_otp() {
|
||||
dir=$PWD
|
||||
cd elixirkit/otp_bootstrap
|
||||
. ./build_macos_universal.sh $OTP_VERSION "1.1.1s"
|
||||
cd $dir
|
||||
}
|
||||
|
||||
download_elixir() {
|
||||
dir=$PWD
|
||||
elixir_dir=$PWD/_build/elixir-$ELIXIR_VERSION
|
||||
|
||||
if [ ! -d $elixir_dir ]; then
|
||||
otp_release=$(erl -noshell -eval 'io:format("~s", [erlang:system_info(otp_release)]), halt().')
|
||||
elixir_zip=v${ELIXIR_VERSION}-otp-${otp_release}.zip
|
||||
url=https://repo.hex.pm/builds/elixir/$elixir_zip
|
||||
echo downloading $url
|
||||
curl --fail -LO $url
|
||||
mkdir -p $elixir_dir
|
||||
unzip -q $elixir_zip -d $elixir_dir
|
||||
rm $elixir_zip
|
||||
fi
|
||||
|
||||
export PATH="$elixir_dir/bin:$PATH"
|
||||
cd $dir
|
||||
}
|
||||
|
||||
build_app() {
|
||||
mix local.hex --force --if-missing
|
||||
mix local.rebar --force --if-missing
|
||||
|
||||
export MIX_ENV=prod
|
||||
export MIX_TARGET=app
|
||||
export ELIXIRKIT_BUILD_ARGS="--configuration release --arch x86_64 --arch arm64"
|
||||
|
||||
mix deps.get --only prod
|
||||
|
||||
cd rel/app/macos
|
||||
./build_dmg.sh
|
||||
}
|
||||
|
||||
main
|
19
.github/scripts/app/build_windows.sh
vendored
19
.github/scripts/app/build_windows.sh
vendored
|
@ -3,12 +3,25 @@
|
|||
# Usage:
|
||||
#
|
||||
# $ sh .github/scripts/app/build_windows.sh
|
||||
# $ wscript _build/app_prod/Livebook-win/LivebookLauncher.vbs
|
||||
# $ rel/app/windows/bin/LivebookInstall.exe
|
||||
# $ start livebook://github.com/livebook-dev/livebook/blob/main/test/support/notebooks/basic.livemd
|
||||
# $ start ./test/support/notebooks/basic.livemd
|
||||
#
|
||||
# Note: This script builds the Windows installer. If you just want to test the Windows app locally, run:
|
||||
#
|
||||
# $ cd rel/app/windows && ./run.sh
|
||||
#
|
||||
# See rel/app/windows/README.md for more information.
|
||||
set -e
|
||||
|
||||
mix local.hex --force --if-missing
|
||||
mix local.rebar --force --if-missing
|
||||
MIX_ENV=prod MIX_TARGET=app mix deps.get --only prod
|
||||
MIX_ENV=prod MIX_TARGET=app mix release app --overwrite
|
||||
|
||||
export MIX_ENV=prod
|
||||
export MIX_TARGET=app
|
||||
export ELIXIRKIT_CONFIGURATION=Release
|
||||
|
||||
mix deps.get --only prod
|
||||
|
||||
cd rel/app/windows
|
||||
./build_installer.sh
|
||||
|
|
20
.github/workflows/test.yml
vendored
20
.github/workflows/test.yml
vendored
|
@ -5,7 +5,7 @@ on:
|
|||
branches:
|
||||
- main
|
||||
env:
|
||||
otp: "25.0"
|
||||
otp: "25.0.4"
|
||||
elixir: "1.14.2"
|
||||
jobs:
|
||||
main:
|
||||
|
@ -101,12 +101,18 @@ jobs:
|
|||
with:
|
||||
path: deps
|
||||
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
|
||||
- name: Cache Bootstrap
|
||||
- name: Cache Bootstrap OTP
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: tmp
|
||||
key: ${{ runner.os }}-app-${{ hashFiles('.github/scripts/app/bootstrap_mac.sh') }}
|
||||
- name: Bootstrap
|
||||
run: bash .github/scripts/app/bootstrap_mac.sh
|
||||
path: elixirkit/otp_bootstrap/_build/otp-rel-${{ env.otp }}-openssl-1.1.1s-macos-universal
|
||||
key: ${{ runner.os }}-bootstrap-${{ env.otp }}-${{ hashFiles('elixirkit/otp_bootstrap/build.sh') }}
|
||||
- name: Cache Elixir
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: _build/elixir-${{ env.elixir }}
|
||||
key: ${{ runner.os }}-elixir-${{ env.elixir }}
|
||||
- name: Build the app
|
||||
run: bash .github/scripts/app/build_mac.sh
|
||||
run: .github/scripts/app/build_macos.sh
|
||||
env:
|
||||
OTP_VERSION: ${{ env.otp }}
|
||||
ELIXIR_VERSION: ${{ env.elixir }}
|
||||
|
|
22
README.md
22
README.md
|
@ -252,6 +252,28 @@ MIX_ENV=prod mix escript.build
|
|||
./livebook server
|
||||
```
|
||||
|
||||
### Livebook Desktop
|
||||
|
||||
For macOS, run:
|
||||
|
||||
```shell
|
||||
# Test macOS app locally
|
||||
(cd rel/app/macos && ./run.sh)
|
||||
|
||||
# Build macOS installer
|
||||
.github/scripts/app/build_macos.sh
|
||||
```
|
||||
|
||||
For Windows, run:
|
||||
|
||||
```shell
|
||||
# Test Windows app locally
|
||||
(cd rel/app/windows && ./run.sh)
|
||||
|
||||
# Build Windows installer
|
||||
.github/scripts/app/build_windows.sh
|
||||
```
|
||||
|
||||
## Sponsors
|
||||
|
||||
Livebook development is sponsored by:
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
# AppBundler
|
|
@ -1 +0,0 @@
|
|||
# Demo
|
|
@ -1 +0,0 @@
|
|||
An example file to test "open file" feature.
|
|
@ -1,95 +0,0 @@
|
|||
defmodule Demo.Application do
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
Demo.Window
|
||||
]
|
||||
|
||||
opts = [strategy: :one_for_one, name: Demo.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Demo.Window do
|
||||
@moduledoc false
|
||||
use GenServer, restart: :transient
|
||||
|
||||
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
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
AppBundler.init()
|
||||
app_name = "Demo"
|
||||
os = AppBundler.os()
|
||||
wx = :wx.new()
|
||||
frame = :wxFrame.new(wx, -1, app_name, size: {400, 400})
|
||||
|
||||
if os == :macos do
|
||||
fixup_macos_menubar(frame, app_name)
|
||||
end
|
||||
|
||||
:wxFrame.show(frame)
|
||||
:wxFrame.connect(frame, :command_menu_selected, skip: true)
|
||||
:wxFrame.connect(frame, :close_window, skip: true)
|
||||
|
||||
{:ok, %{frame: frame}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:wx, @wx_id_exit, _, _, _}, state) do
|
||||
:init.stop()
|
||||
{:stop, :shutdown, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:wx, _, _, _, {:wxClose, :close_window}}, state) do
|
||||
:init.stop()
|
||||
{:stop, :shutdown, state}
|
||||
end
|
||||
|
||||
# ignore other menu events
|
||||
@impl true
|
||||
def handle_info({:wx, _, _, _, {:wxCommand, :command_menu_selected, _, _, _}}, state) do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(event, state) do
|
||||
show_dialog(state, inspect(event))
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
# Helpers
|
||||
|
||||
defp show_dialog(state, data) do
|
||||
:wxMessageDialog.new(state.frame, data)
|
||||
|> :wxDialog.showModal()
|
||||
end
|
||||
|
||||
# 1. WxeApp attaches event handler to "Quit" menu item that does nothing (to not accidentally bring
|
||||
# down the VM). Let's create a fresh menu bar without that caveat.
|
||||
# 2. Fix app name
|
||||
defp fixup_macos_menubar(frame, app_name) do
|
||||
menubar = :wxMenuBar.new()
|
||||
:wxFrame.setMenuBar(frame, menubar)
|
||||
menu = :wxMenuBar.oSXGetAppleMenu(menubar)
|
||||
|
||||
menu
|
||||
|> :wxMenu.findItem(@wx_id_osx_hide)
|
||||
|> :wxMenuItem.setItemLabel("Hide #{app_name}\tCtrl+H")
|
||||
|
||||
menu
|
||||
|> :wxMenu.findItem(@wx_id_exit)
|
||||
|> :wxMenuItem.setItemLabel("Quit #{app_name}\tCtrl+Q")
|
||||
end
|
||||
end
|
|
@ -1,71 +0,0 @@
|
|||
defmodule Demo.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :demo,
|
||||
version: "0.1.0",
|
||||
elixir: "~> 1.13",
|
||||
start_permanent: Mix.env() == :prod,
|
||||
deps: deps(),
|
||||
releases: releases()
|
||||
]
|
||||
end
|
||||
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:wx, :logger],
|
||||
mod: {Demo.Application, []}
|
||||
]
|
||||
end
|
||||
|
||||
defp deps do
|
||||
[
|
||||
{:app_bundler, path: ".."}
|
||||
]
|
||||
end
|
||||
|
||||
defp releases do
|
||||
macos_notarization = macos_notarization()
|
||||
|
||||
[
|
||||
app: [
|
||||
steps: [
|
||||
:assemble,
|
||||
&AppBundler.bundle/1
|
||||
],
|
||||
app: [
|
||||
name: "Demo",
|
||||
url_schemes: ["wxdemo"],
|
||||
document_types: [
|
||||
[
|
||||
name: "Demo",
|
||||
extensions: ["wxdemo"],
|
||||
macos: [
|
||||
role: "Editor"
|
||||
]
|
||||
]
|
||||
],
|
||||
macos: [
|
||||
build_dmg: macos_notarization != nil,
|
||||
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
|
||||
end
|
|
@ -1,132 +0,0 @@
|
|||
defmodule AppBundler do
|
||||
def bundle(release) do
|
||||
options = validate_options(release.options[:app] || [])
|
||||
|
||||
case os() do
|
||||
:macos ->
|
||||
AppBundler.MacOS.bundle(release, options)
|
||||
|
||||
:windows ->
|
||||
AppBundler.Windows.bundle(release, options)
|
||||
end
|
||||
end
|
||||
|
||||
def os do
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> :macos
|
||||
{:win32, _} -> :windows
|
||||
end
|
||||
end
|
||||
|
||||
def init do
|
||||
{:ok, _} = Registry.register(AppBundler.Registry, "app_event_subscribers", [])
|
||||
|
||||
if input = System.get_env("APP_BUILDER_INPUT") do
|
||||
__rpc__(input)
|
||||
end
|
||||
end
|
||||
|
||||
def __rpc__ do
|
||||
IO.read(:line)
|
||||
|> String.trim()
|
||||
|> __rpc__()
|
||||
end
|
||||
|
||||
def __rpc__("open_app") do
|
||||
dispatch(:open_app)
|
||||
end
|
||||
|
||||
def __rpc__("open_url:" <> url) do
|
||||
dispatch({:open_url, url})
|
||||
end
|
||||
|
||||
def __rpc__("open_file:" <> path) do
|
||||
path =
|
||||
if os() == :windows do
|
||||
String.replace(path, "\\", "/")
|
||||
else
|
||||
path
|
||||
end
|
||||
|
||||
dispatch({:open_file, path})
|
||||
end
|
||||
|
||||
defp dispatch(message) do
|
||||
Registry.dispatch(AppBundler.Registry, "app_event_subscribers", fn entries ->
|
||||
for {pid, _} <- entries, do: send(pid, message)
|
||||
end)
|
||||
end
|
||||
|
||||
defp validate_options(options) do
|
||||
os = os()
|
||||
|
||||
root_allowed_options = %{
|
||||
all: [
|
||||
:name,
|
||||
:icon_path,
|
||||
url_schemes: [],
|
||||
document_types: [],
|
||||
additional_paths: []
|
||||
],
|
||||
macos: [
|
||||
app_type: :regular,
|
||||
build_dmg: false,
|
||||
notarization: nil
|
||||
],
|
||||
windows: [
|
||||
:server,
|
||||
build_installer: false
|
||||
]
|
||||
}
|
||||
|
||||
document_type_allowed_options = %{
|
||||
all: [
|
||||
:name,
|
||||
:extensions,
|
||||
:icon_path
|
||||
],
|
||||
macos: [
|
||||
:role
|
||||
],
|
||||
windows: []
|
||||
}
|
||||
|
||||
options
|
||||
|> validate_options(root_allowed_options, os)
|
||||
|> Keyword.put_new_lazy(:name, &default_name/0)
|
||||
|> Keyword.update!(:document_types, fn document_types ->
|
||||
Enum.map(document_types, fn options ->
|
||||
validate_options(options, document_type_allowed_options, os)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp default_name do
|
||||
Mix.Project.config()[:app] |> to_string |> Macro.camelize()
|
||||
end
|
||||
|
||||
defp validate_options(options, allowed, os) do
|
||||
{macos_options, options} = Keyword.pop(options, :macos, [])
|
||||
{windows_options, options} = Keyword.pop(options, :windows, [])
|
||||
|
||||
options_per_os = %{
|
||||
macos: macos_options,
|
||||
windows: windows_options
|
||||
}
|
||||
|
||||
options = Keyword.validate!(options, allowed.all)
|
||||
options_for_os = Map.fetch!(options_per_os, os)
|
||||
|
||||
allowed_without_defaults =
|
||||
for option <- allowed.all do
|
||||
case option do
|
||||
atom when is_atom(atom) -> atom
|
||||
{atom, _default} when is_atom(atom) -> atom
|
||||
end
|
||||
end
|
||||
|
||||
allowed_for_os = allowed_without_defaults ++ Map.fetch!(allowed, os)
|
||||
os_options = Keyword.validate!(options_for_os, allowed_for_os)
|
||||
Keyword.merge(options, os_options)
|
||||
end
|
||||
end
|
|
@ -1,13 +0,0 @@
|
|||
defmodule AppBundler.Application do
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
{Registry, keys: :duplicate, name: AppBundler.Registry}
|
||||
]
|
||||
|
||||
Supervisor.start_link(children, strategy: :one_for_one, name: AppBundler.Supervisor)
|
||||
end
|
||||
end
|
|
@ -1,189 +0,0 @@
|
|||
defmodule AppBundler.MacOS do
|
||||
@moduledoc false
|
||||
|
||||
import AppBundler.Utils
|
||||
|
||||
@templates_path "#{__ENV__.file}/../../templates"
|
||||
|
||||
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_x86_64_path = "#{tmp_dir}/#{app_name}Launcher-x86_64"
|
||||
launcher_aarch64_path = "#{tmp_dir}/#{app_name}Launcher-aarch64"
|
||||
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, launcher_x86_64_path)
|
||||
|
||||
cmd!("swiftc", [
|
||||
"-warnings-as-errors",
|
||||
"-target",
|
||||
"x86_64-apple-macosx10.15",
|
||||
"-o",
|
||||
launcher_x86_64_path,
|
||||
launcher_src_path
|
||||
])
|
||||
|
||||
log(:green, :creating, launcher_aarch64_path)
|
||||
|
||||
cmd!("swiftc", [
|
||||
"-warnings-as-errors",
|
||||
"-target",
|
||||
"arm64-apple-macosx12",
|
||||
"-o",
|
||||
launcher_aarch64_path,
|
||||
launcher_src_path
|
||||
])
|
||||
|
||||
log(:green, :creating, Path.relative_to_cwd(launcher_bin_path))
|
||||
|
||||
cmd!("lipo", [
|
||||
launcher_x86_64_path,
|
||||
launcher_aarch64_path,
|
||||
"-create",
|
||||
"-output",
|
||||
launcher_bin_path
|
||||
])
|
||||
|
||||
icon_path =
|
||||
Keyword.get(options, :icon_path, Application.app_dir(:wx, "examples/demo/erlang.png"))
|
||||
|
||||
dest_path = "#{resources_path}/AppIcon.icns"
|
||||
create_icon(icon_path, dest_path)
|
||||
|
||||
for type <- Keyword.fetch!(options, :document_types) do
|
||||
if src_path = Keyword.get(type, :icon_path, 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[:build_dmg] do
|
||||
build_dmg(release, options)
|
||||
end
|
||||
|
||||
release
|
||||
end
|
||||
|
||||
defp build_dmg(release, options) do
|
||||
app_name = Keyword.fetch!(options, :name)
|
||||
notarization = Keyword.fetch!(options, :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!(
|
||||
"#{Mix.Project.build_path()}/#{app_name}.app",
|
||||
app_dir
|
||||
)
|
||||
|
||||
to_sign =
|
||||
"find #{app_dir} -perm +111 -type f -exec sh -c \"file {} | grep --silent Mach-O\" \\; -print"
|
||||
|> shell!(into: "")
|
||||
|> String.split("\n", trim: true)
|
||||
|
||||
to_sign = to_sign ++ [app_dir]
|
||||
|
||||
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)
|
||||
|
||||
dmg_path = "#{Mix.Project.build_path()}/#{app_name}Install.dmg"
|
||||
log(:green, "creating", Path.relative_to_cwd(dmg_path))
|
||||
|
||||
cmd!(
|
||||
"hdiutil",
|
||||
~w(create #{dmg_path} -ov -volname #{app_name}Install -fs HFS+ -srcfolder #{dmg_dir})
|
||||
)
|
||||
|
||||
log(:green, "notarizing", Path.relative_to_cwd(dmg_path))
|
||||
notarize(dmg_path, notarization)
|
||||
|
||||
release
|
||||
end
|
||||
|
||||
defp codesign(paths, extra_flags, options) do
|
||||
identity = Keyword.fetch!(options, :identity)
|
||||
paths = Enum.join(paths, " ")
|
||||
flags = "--force --timestamp --verbose=4 --sign=\"#{identity}\" #{extra_flags}"
|
||||
shell!("codesign #{flags} #{paths}")
|
||||
end
|
||||
|
||||
defp notarize(path, options) do
|
||||
team_id = Keyword.fetch!(options, :team_id)
|
||||
apple_id = Keyword.fetch!(options, :apple_id)
|
||||
password = Keyword.fetch!(options, :password)
|
||||
|
||||
shell!("""
|
||||
xcrun notarytool submit \
|
||||
--team-id "#{team_id}" \
|
||||
--apple-id "#{apple_id}" \
|
||||
--password "#{password}" \
|
||||
--progress \
|
||||
--wait \
|
||||
#{path}
|
||||
""")
|
||||
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
|
||||
File.cp!(src_path, dest_path)
|
||||
else
|
||||
name = Path.basename(dest_path, ".icns")
|
||||
dest_tmp_path = "tmp/#{name}.iconset"
|
||||
File.rm_rf!(dest_tmp_path)
|
||||
File.mkdir_p!(dest_tmp_path)
|
||||
|
||||
sizes = for(i <- [16, 32, 64, 128], j <- [1, 2], do: {i, j}) ++ [{512, 1}]
|
||||
|
||||
for {size, scale} <- sizes do
|
||||
suffix =
|
||||
case scale do
|
||||
1 -> ""
|
||||
2 -> "@2x"
|
||||
end
|
||||
|
||||
size = size * scale
|
||||
out = "#{dest_tmp_path}/icon_#{size}x#{size}#{suffix}.png"
|
||||
cmd!("sips", ~w(-z #{size} #{size} #{src_path} --out #{out}), into: "")
|
||||
end
|
||||
|
||||
cmd!("iconutil", ~w(-c icns #{dest_tmp_path} -o #{dest_path}))
|
||||
File.rm_rf!(dest_tmp_path)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,118 +0,0 @@
|
|||
defmodule AppBundler.Utils do
|
||||
@moduledoc false
|
||||
|
||||
require Logger
|
||||
|
||||
def cmd!(bin, args, opts \\ []) do
|
||||
opts = Keyword.put_new(opts, :into, IO.stream())
|
||||
{_, status} = System.cmd(bin, args, opts)
|
||||
|
||||
if status != 0 do
|
||||
raise "command exited with #{status}"
|
||||
end
|
||||
end
|
||||
|
||||
def shell!(command, opts \\ []) do
|
||||
opts = Keyword.put_new(opts, :into, IO.stream())
|
||||
{output, 0} = System.shell(command, opts)
|
||||
output
|
||||
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))
|
||||
|
||||
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)
|
||||
|
||||
if Path.extname(path) == ".zip" do
|
||||
{:ok, _} = :zip.extract(String.to_charlist(path), cwd: tmp_dir)
|
||||
else
|
||||
File.chmod!(path, 0o600)
|
||||
end
|
||||
end
|
||||
|
||||
path
|
||||
end
|
||||
|
||||
def ensure_executable(url, expected_sha256, basename) do
|
||||
path = ensure_executable(url, expected_sha256)
|
||||
Path.join(Path.dirname(path), basename)
|
||||
end
|
||||
|
||||
defp download_and_verify(url, expected_sha256) do
|
||||
url
|
||||
|> download_unverified()
|
||||
|> verify(expected_sha256)
|
||||
end
|
||||
|
||||
defp download_unverified(url) do
|
||||
Logger.debug("downloading #{url}")
|
||||
http_options = [ssl: [verify: :verify_none]]
|
||||
options = []
|
||||
headers = []
|
||||
request = {url, headers}
|
||||
{:ok, {{_, 200, _}, _, body}} = :httpc.request(:get, request, http_options, options)
|
||||
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)
|
||||
|
||||
if expected_sha256 == actual_sha256 do
|
||||
data
|
||||
else
|
||||
raise """
|
||||
sha256 mismatch
|
||||
expected: #{expected_sha256}
|
||||
got: #{actual_sha256}\
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_icon_path(path) when is_binary(path) do
|
||||
path
|
||||
end
|
||||
|
||||
def normalize_icon_path(path_per_os) when is_list(path_per_os) do
|
||||
Keyword.fetch!(path_per_os, AppBundler.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
|
|
@ -1,144 +0,0 @@
|
|||
defmodule AppBundler.Windows do
|
||||
@moduledoc false
|
||||
|
||||
import AppBundler.Utils
|
||||
|
||||
@templates_path "#{__ENV__.file}/../../templates"
|
||||
|
||||
def bundle(release, options) do
|
||||
{:ok, _} = Application.ensure_all_started(:ssl)
|
||||
{:ok, _} = Application.ensure_all_started(:inets)
|
||||
|
||||
log(:green, :killing, "epmd.exe")
|
||||
System.cmd("taskkill.exe", ~w(/F /IM epmd.exe))
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
[erl_exe | _] = Path.wildcard("#{app_path}/**/erl.exe")
|
||||
log(:green, :updating, Path.relative_to_cwd(erl_exe))
|
||||
Mix.Task.run("pe.update", ["--set-manifest", manifest_xml_path, erl_exe])
|
||||
|
||||
vcredist_path = ensure_vcredistx64()
|
||||
copy_file(vcredist_path, "#{app_path}/vcredist_x64.exe")
|
||||
|
||||
icon_path = options[:icon_path]
|
||||
|
||||
if icon_path do
|
||||
create_icon(icon_path, "#{app_path}/AppIcon.ico")
|
||||
end
|
||||
|
||||
tmp_dir = "#{Mix.Project.build_path()}/tmp"
|
||||
File.mkdir_p!(tmp_dir)
|
||||
launcher_eex_path = Path.expand("#{@templates_path}/windows/Launcher.vb.eex")
|
||||
launcher_src_path = "#{tmp_dir}/Launcher.vb"
|
||||
launcher_bin_path = "#{app_path}/#{app_name}Launcher.exe"
|
||||
copy_template(launcher_eex_path, launcher_src_path, release: release, app_options: options)
|
||||
File.mkdir!("#{app_path}/Logs")
|
||||
|
||||
args = [
|
||||
path(launcher_src_path),
|
||||
"/out:" <> path(launcher_bin_path),
|
||||
"/nologo",
|
||||
"/target:winexe",
|
||||
"/win32manifest:" <> path(manifest_xml_path)
|
||||
]
|
||||
|
||||
extra_args =
|
||||
if icon_path do
|
||||
["/win32icon:" <> path("#{app_path}/AppIcon.ico")]
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
vbc_path = ensure_vbc()
|
||||
|
||||
cmd!(vbc_path, args ++ extra_args)
|
||||
|
||||
for type <- Keyword.fetch!(options, :document_types) do
|
||||
if src_path = Keyword.get(type, :icon_path, icon_path) do
|
||||
dest_path = "#{app_path}/#{type[:name]}Icon.ico"
|
||||
create_icon(src_path, dest_path)
|
||||
end
|
||||
end
|
||||
|
||||
if Keyword.fetch!(options, :build_installer) do
|
||||
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])
|
||||
end
|
||||
|
||||
release
|
||||
end
|
||||
|
||||
defp path(path), do: String.replace(path, "/", "\\")
|
||||
|
||||
def handle_event(module, input)
|
||||
|
||||
def handle_event(module, "open_url:" <> url) do
|
||||
module.open_url(url)
|
||||
end
|
||||
|
||||
def handle_event(module, "open_file:" <> path) do
|
||||
module.open_file(String.replace(path, "\\", "/"))
|
||||
end
|
||||
|
||||
defp ensure_vcredistx64 do
|
||||
url = "https://aka.ms/vs/17/release/vc_redist.x64.exe"
|
||||
AppBundler.Utils.ensure_executable(url)
|
||||
end
|
||||
|
||||
defp ensure_makensis do
|
||||
url = "https://downloads.sourceforge.net/project/nsis/NSIS%203/3.08/nsis-3.08.zip"
|
||||
sha256 = "1bb9fc85ee5b220d3869325dbb9d191dfe6537070f641c30fbb275c97051fd0c"
|
||||
AppBundler.Utils.ensure_executable(url, sha256, "nsis-3.08/makensis.exe")
|
||||
end
|
||||
|
||||
defp ensure_magick do
|
||||
System.find_executable("magick.exe") ||
|
||||
raise "couldn't find magick.exe in PATH to automatically convert images to .ico"
|
||||
end
|
||||
|
||||
def ensure_vbc do
|
||||
case System.shell("dir %WINDIR%\\Microsoft.NET\\Framework64\\vbc.exe /s/b") do
|
||||
{paths, 0} ->
|
||||
paths |> String.split("\r\n", trim: true) |> List.last()
|
||||
|
||||
{_, 1} ->
|
||||
raise "cannot find vbc.exe. You need to install Visual Studio."
|
||||
end
|
||||
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
|
||||
File.cp!(src_path, dest_path)
|
||||
else
|
||||
magick_path = ensure_magick()
|
||||
sizes = [16, 32, 48, 64, 128]
|
||||
|
||||
for i <- sizes do
|
||||
cmd!(magick_path, [src_path, "-resize", "#{i}x#{i}", sized_path(dest_path, i)])
|
||||
end
|
||||
|
||||
sized_paths = Enum.map(sizes, &sized_path(dest_path, &1))
|
||||
cmd!(magick_path, sized_paths ++ [dest_path])
|
||||
end
|
||||
end
|
||||
|
||||
defp sized_path(path, size) do
|
||||
String.replace_trailing(path, ".ico", ".#{size}.ico")
|
||||
end
|
||||
end
|
|
@ -1,70 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string><%= @app_options[:name] %>Launcher</string>
|
||||
<key>CFBundleName</key>
|
||||
<string><%= @app_options[:name] %></string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string><%= @app_options[:name] %></string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string><%= @release.version %></string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string><%= @release.version %></string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
|
||||
<%= if schemes = @app_options[:url_schemes] do %>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<%= for scheme <- schemes do %>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string><%= @app_options[:name] %></string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string><%= scheme %></string>
|
||||
</array>
|
||||
</dict>
|
||||
<% end %>
|
||||
</array>
|
||||
<% end %>
|
||||
|
||||
<%= if types = @app_options[:document_types] do %>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<%= for type <- types do %>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string><%= type[:name] %></string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string><%= type[:role] %></string>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<%= for ext <- type[:extensions] do %>
|
||||
<string><%= ext %></string>
|
||||
<% end %>
|
||||
</array>
|
||||
<%= if type[:icon_path] do %>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string><%= type[:name] %>Icon</string>
|
||||
<% end %>
|
||||
</dict>
|
||||
<% end %>
|
||||
</array>
|
||||
<% end %>
|
||||
|
||||
<%= if @app_options[:app_type] == :agent do %>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<% end %>
|
||||
|
||||
<key>LSRequiresNativeExecution</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
|
@ -1,134 +0,0 @@
|
|||
<%
|
||||
|
||||
additional_paths =
|
||||
Enum.map_join(@app_options[:additional_paths], ":", fn path ->
|
||||
if String.starts_with?(path, "/") do
|
||||
path
|
||||
else
|
||||
"\\(resourcePath)/#{path}"
|
||||
end
|
||||
end)
|
||||
|
||||
%>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", "AppBundler.__rpc__()"]
|
||||
try! task.run()
|
||||
task.waitUntilExit()
|
||||
|
||||
if task.terminationStatus != 0 {
|
||||
runAlert(messageText: "Something went wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func buildReleaseTask() -> Process {
|
||||
let task = Process()
|
||||
task.launchPath = Bundle.main.path(forResource: "rel/bin/<%= @release.name %>", ofType: "")!
|
||||
task.environment = ProcessInfo.processInfo.environment
|
||||
|
||||
<%= if additional_paths != "" do %>
|
||||
let resourcePath = Bundle.main.resourcePath ?? ""
|
||||
_ = resourcePath
|
||||
let additionalPaths = "<%= additional_paths %>"
|
||||
let path = task.environment!["PATH"] ?? ""
|
||||
task.environment!["PATH"] = "\(additionalPaths):\(path)"
|
||||
<% end %>
|
||||
|
||||
task.standardOutput = logFile
|
||||
task.standardError = logFile
|
||||
return task
|
||||
}
|
||||
|
||||
func runAlert(messageText: String) {
|
||||
DispatchQueue.main.sync {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = messageText
|
||||
alert.informativeText = "Logs available at: \(logPath)"
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
|
||||
func log(_ line: String) {
|
||||
logFile.write("[\(appName)Launcher] \(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 app = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
app.delegate = delegate
|
||||
app.run()
|
|
@ -1,90 +0,0 @@
|
|||
<%
|
||||
app_name = Keyword.fetch!(@app_options, :name)
|
||||
%>
|
||||
!include "MUI2.nsh"
|
||||
|
||||
;--------------------------------
|
||||
;General
|
||||
|
||||
Name "<%= app_name %>"
|
||||
ManifestDPIAware true
|
||||
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
|
||||
<%= if @app_options[:icon_path] do %>
|
||||
!define MUI_ICON "AppIcon.ico"
|
||||
<% end %>
|
||||
!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 /quiet /norestart'
|
||||
|
||||
File /r rel rel
|
||||
File "<%= app_name %>Launcher.exe"
|
||||
<%= if @app_options[:icon_path] do %>
|
||||
File "AppIcon.ico"
|
||||
<% end %>
|
||||
|
||||
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] %>"
|
||||
<%= if type[:icon_path] || @app_options[:icon_path] do %>
|
||||
File "<%= type[:name] %>Icon.ico"
|
||||
WriteRegStr HKCR "<%= app_name %>.<%= type[:name] %>\DefaultIcon" "" "$INSTDIR\<%= type[:name] %>Icon.ico"
|
||||
<% end %>
|
||||
WriteRegStr HKCR "<%= app_name %>.<%= type[:name] %>\shell\open\command" "" '"$INSTDIR\<%= app_name %>Launcher.exe" "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" "" '"$INSTDIR\<%= app_name %>Launcher.exe" "open_url:%1"'
|
||||
<% end %>
|
||||
SectionEnd
|
||||
|
||||
Section "Desktop Shortcut"
|
||||
CreateShortCut "$DESKTOP\<%= app_name %>.lnk" "$INSTDIR\<%= app_name %>Launcher.exe" "" <%= if @app_options[:icon_path] do %> "$INSTDIR\AppIcon.ico" <% end %>
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
Delete "$DESKTOP\<%= app_name %>.lnk"
|
||||
; TODO: stop epmd if it was started
|
||||
RMDir /r "$INSTDIR"
|
||||
SectionEnd
|
|
@ -1,61 +0,0 @@
|
|||
<%
|
||||
|
||||
app_name = Keyword.fetch!(@app_options, :name)
|
||||
|
||||
additional_paths =
|
||||
for path <- Keyword.fetch!(@app_options, :additional_paths), into: "" do
|
||||
"root & \"\\" <> String.replace(path, "/", "\\") <> ";\" & "
|
||||
end
|
||||
|
||||
%>
|
||||
|
||||
Imports System
|
||||
|
||||
Module Launcher
|
||||
Sub Main(args As String())
|
||||
Dim root = My.Application.Info.DirectoryPath
|
||||
Dim script = root & "\rel\bin\<%= @release.name %>.bat"
|
||||
|
||||
Dim input as String
|
||||
If args.count > 0 Then
|
||||
input = args(0)
|
||||
Else
|
||||
input = "open_app"
|
||||
End If
|
||||
|
||||
<%= if additional_paths != "" do %>
|
||||
Environment.SetEnvironmentVariable("PATH", <%= additional_paths%>Environment.GetEnvironmentVariable("PATH"))
|
||||
<% end %>
|
||||
|
||||
' try release rpc, if release is down, this will fail but that's ok.
|
||||
Dim rpcProc = new System.Diagnostics.Process()
|
||||
rpcProc.StartInfo.FileName = "cmd.exe"
|
||||
rpcProc.StartInfo.Arguments = "/c echo " & input & " | """ & script & """ rpc ""AppBundler.__rpc__()"""
|
||||
rpcProc.StartInfo.UseShellExecute = false
|
||||
rpcProc.StartInfo.CreateNoWindow = true
|
||||
rpcProc.Start()
|
||||
rpcProc.WaitForExit()
|
||||
|
||||
' rpc failed which usually means the release is down, let's start it
|
||||
If rpcProc.ExitCode <> 0 Then
|
||||
Environment.SetEnvironmentVariable("APP_BUILDER_INPUT", input)
|
||||
Dim startProc = new System.Diagnostics.Process()
|
||||
startProc.StartInfo.FileName = "cmd.exe"
|
||||
startProc.StartInfo.Arguments = "/c """ & script & """ start"
|
||||
startProc.StartInfo.UseShellExecute = false
|
||||
startProc.StartInfo.CreateNoWindow = true
|
||||
startProc.StartInfo.RedirectStandardError = true
|
||||
startProc.StartInfo.StandardErrorEncoding = System.Text.Encoding.UTF8
|
||||
startProc.Start()
|
||||
Dim errorMessage = startProc.StandardError.ReadToEnd()
|
||||
startProc.WaitForExit()
|
||||
|
||||
If startProc.ExitCode <> 0 Then
|
||||
MsgBox(
|
||||
"<%= app_name %> exited with error code " & startProc.ExitCode & "." & vbCrLf & errorMessage,
|
||||
MsgBoxStyle.Critical
|
||||
)
|
||||
End If
|
||||
End If
|
||||
End Sub
|
||||
End Module
|
|
@ -1,33 +0,0 @@
|
|||
defmodule AppBundler.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :app_bundler,
|
||||
version: "0.1.0",
|
||||
elixir: "~> 1.13",
|
||||
start_permanent: Mix.env() == :prod,
|
||||
deps: deps(),
|
||||
|
||||
# Suppress warnings
|
||||
xref: [
|
||||
exclude: [
|
||||
:wx
|
||||
]
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
def application do
|
||||
[
|
||||
mod: {AppBundler.Application, []},
|
||||
extra_applications: [:logger, :eex, :inets, :ssl, :crypto]
|
||||
]
|
||||
end
|
||||
|
||||
defp deps do
|
||||
[
|
||||
{:libpe, "~> 1.0"}
|
||||
]
|
||||
end
|
||||
end
|
|
@ -1,3 +0,0 @@
|
|||
%{
|
||||
"libpe": {:hex, :libpe, "1.1.2", "16337b414c690e0ee9c49fe917b059622f001c399303102b98900c05c229cd9a", [:mix], [], "hexpm", "31df0639fafb603b20078c8db9596c8984f35a151c64ec2e483d9136ff9f428c"},
|
||||
}
|
|
@ -20,7 +20,7 @@ erl_crash.dump
|
|||
*.ez
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
app_bundler-*.tar
|
||||
elixirkit-*.tar
|
||||
|
||||
# Temporary files, for example, from tests.
|
||||
/tmp/
|
1
elixirkit/README.md
Normal file
1
elixirkit/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
# ElixirKit
|
|
@ -20,9 +20,7 @@ erl_crash.dump
|
|||
*.ez
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
wx_demo-*.tar
|
||||
demo-*.tar
|
||||
|
||||
# Temporary files, for example, from tests.
|
||||
/tmp/
|
||||
|
||||
/test.sh
|
11
elixirkit/demo/README.md
Normal file
11
elixirkit/demo/README.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
# Demo
|
||||
|
||||
This is an example ElixirKit application.
|
||||
|
||||
See:
|
||||
|
||||
* [Elixir Server](lib/demo.ex)
|
||||
* [Swift Client](rel/swift)
|
||||
* [AppKit Client](rel/appkit)
|
||||
* [C# Client](rel/dotnet)
|
||||
* [Windows Forms Client](rel/winforms)
|
61
elixirkit/demo/lib/demo.ex
Normal file
61
elixirkit/demo/lib/demo.ex
Normal file
|
@ -0,0 +1,61 @@
|
|||
defmodule Demo.Application do
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
Demo.Server
|
||||
]
|
||||
|
||||
opts = [strategy: :one_for_one, name: Demo.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Demo.Server do
|
||||
use GenServer
|
||||
|
||||
def start_link(arg) do
|
||||
GenServer.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
Process.flag(:trap_exit, true)
|
||||
{:ok, server_pid} = ElixirKit.start()
|
||||
log("init")
|
||||
|
||||
Task.start(fn ->
|
||||
for i <- 5..1//-1 do
|
||||
log("Stopping in #{i}...")
|
||||
Process.sleep(1000)
|
||||
end
|
||||
|
||||
System.stop()
|
||||
end)
|
||||
|
||||
{:ok, %{server_pid: server_pid}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:event, "log", message}, state) do
|
||||
log(message)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:EXIT, pid, :shutdown}, state) when pid == state.server_pid do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, _state) do
|
||||
log("Stopping...")
|
||||
end
|
||||
|
||||
defp log(message) do
|
||||
IO.puts(["[server] ", message])
|
||||
end
|
||||
end
|
26
elixirkit/demo/mix.exs
Normal file
26
elixirkit/demo/mix.exs
Normal file
|
@ -0,0 +1,26 @@
|
|||
defmodule Demo.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :demo,
|
||||
version: "0.1.0",
|
||||
elixir: "~> 1.14",
|
||||
start_permanent: Mix.env() == :prod,
|
||||
deps: deps()
|
||||
]
|
||||
end
|
||||
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:logger],
|
||||
mod: {Demo.Application, []}
|
||||
]
|
||||
end
|
||||
|
||||
defp deps do
|
||||
[
|
||||
{:elixirkit, path: ".."}
|
||||
]
|
||||
end
|
||||
end
|
8
elixirkit/demo/rel/appkit/.gitignore
vendored
Normal file
8
elixirkit/demo/rel/appkit/.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
19
elixirkit/demo/rel/appkit/Package.swift
Normal file
19
elixirkit/demo/rel/appkit/Package.swift
Normal file
|
@ -0,0 +1,19 @@
|
|||
// swift-tools-version: 5.5
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Demo",
|
||||
platforms: [
|
||||
.macOS(.v11)
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "ElixirKit", path: "../../../elixirkit_swift")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "Demo",
|
||||
dependencies: ["ElixirKit"]
|
||||
)
|
||||
]
|
||||
)
|
9
elixirkit/demo/rel/appkit/README.md
Normal file
9
elixirkit/demo/rel/appkit/README.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
# Demo
|
||||
|
||||
Run the app, just the executable:
|
||||
|
||||
$ ./run.sh
|
||||
|
||||
Run the app bundle:
|
||||
|
||||
$ ./run_app.sh
|
62
elixirkit/demo/rel/appkit/Sources/Demo/Demo.swift
Normal file
62
elixirkit/demo/rel/appkit/Sources/Demo/Demo.swift
Normal file
|
@ -0,0 +1,62 @@
|
|||
import AppKit
|
||||
import ElixirKit
|
||||
|
||||
@main
|
||||
public struct Demo {
|
||||
public static func main() {
|
||||
let app = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
app.delegate = delegate
|
||||
app.run()
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var window : NSWindow!
|
||||
|
||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||
ElixirKit.API.start(
|
||||
name: "demo",
|
||||
terminationHandler: { _ in
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
)
|
||||
|
||||
ElixirKit.API.publish("log", "Hello from AppKit!")
|
||||
|
||||
let menuItemOne = NSMenuItem()
|
||||
menuItemOne.submenu = NSMenu(title: "Demo")
|
||||
menuItemOne.submenu?.items = [
|
||||
NSMenuItem(title: "Quit Demo", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
||||
]
|
||||
let menu = NSMenu()
|
||||
menu.items = [menuItemOne]
|
||||
NSApp.mainMenu = menu
|
||||
|
||||
window = NSWindow(contentRect: NSMakeRect(0, -1000, 200, 200),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: true)
|
||||
window.orderFrontRegardless()
|
||||
window.title = "Demo"
|
||||
|
||||
let button = NSButton(title: "Press me!", target: self, action: #selector(pressMe))
|
||||
window.contentView!.subviews.append(button)
|
||||
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ app: NSApplication) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ aNotification: Notification) {
|
||||
ElixirKit.API.stop()
|
||||
}
|
||||
|
||||
@objc
|
||||
func pressMe() {
|
||||
ElixirKit.API.publish("log", "button pressed!")
|
||||
}
|
||||
}
|
7
elixirkit/demo/rel/appkit/run.sh
Executable file
7
elixirkit/demo/rel/appkit/run.sh
Executable file
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
swift build
|
||||
target_dir=`swift build --show-bin-path`
|
||||
(cd ../.. && mix release --overwrite --path=$target_dir/rel)
|
||||
$target_dir/Demo
|
8
elixirkit/demo/rel/appkit/run_app.sh
Executable file
8
elixirkit/demo/rel/appkit/run_app.sh
Executable file
|
@ -0,0 +1,8 @@
|
|||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
export ELIXIRKIT_APP_NAME=Demo
|
||||
export ELIXIRKIT_PROJECT_DIR=$PWD/../..
|
||||
|
||||
. ../../../../elixirkit/elixirkit_swift/Scripts/build_macos_app.sh
|
||||
open -W --stdout `tty` --stderr `tty` .build/Demo.app
|
2
elixirkit/demo/rel/dotnet/.gitignore
vendored
Normal file
2
elixirkit/demo/rel/dotnet/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/bin
|
||||
/obj
|
11
elixirkit/demo/rel/dotnet/Demo.cs
Normal file
11
elixirkit/demo/rel/dotnet/Demo.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
using ElixirKit;
|
||||
|
||||
class Demo {
|
||||
public static void Main()
|
||||
{
|
||||
var api = new ElixirKit.API();
|
||||
api.Start(name: "demo");
|
||||
api.Publish("log", "Hello from C#!");
|
||||
api.WaitForExit();
|
||||
}
|
||||
}
|
14
elixirkit/demo/rel/dotnet/Demo.csproj
Normal file
14
elixirkit/demo/rel/dotnet/Demo.csproj
Normal file
|
@ -0,0 +1,14 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../elixirkit_dotnet/ElixirKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
5
elixirkit/demo/rel/dotnet/README.md
Normal file
5
elixirkit/demo/rel/dotnet/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Demo
|
||||
|
||||
Run the app:
|
||||
|
||||
$ ./run.sh
|
7
elixirkit/demo/rel/dotnet/run.sh
Executable file
7
elixirkit/demo/rel/dotnet/run.sh
Executable file
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
dotnet build
|
||||
target_dir="$PWD/bin/Debug/net6.0"
|
||||
(cd ../.. && mix release --overwrite --path=${target_dir}/rel)
|
||||
dotnet run --no-build
|
8
elixirkit/demo/rel/swift/.gitignore
vendored
Normal file
8
elixirkit/demo/rel/swift/.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
19
elixirkit/demo/rel/swift/Package.swift
Normal file
19
elixirkit/demo/rel/swift/Package.swift
Normal file
|
@ -0,0 +1,19 @@
|
|||
// swift-tools-version: 5.5
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Demo",
|
||||
platforms: [
|
||||
.macOS(.v11)
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "ElixirKit", path: "../../../elixirkit_swift")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "Demo",
|
||||
dependencies: ["ElixirKit"]
|
||||
)
|
||||
]
|
||||
)
|
5
elixirkit/demo/rel/swift/README.md
Normal file
5
elixirkit/demo/rel/swift/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Demo
|
||||
|
||||
Run the app:
|
||||
|
||||
$ ./run.sh
|
18
elixirkit/demo/rel/swift/Sources/Demo/Demo.swift
Normal file
18
elixirkit/demo/rel/swift/Sources/Demo/Demo.swift
Normal file
|
@ -0,0 +1,18 @@
|
|||
import Foundation
|
||||
import ElixirKit
|
||||
|
||||
@main
|
||||
struct Demo {
|
||||
public static func main() {
|
||||
ElixirKit.API.start(name: "demo")
|
||||
|
||||
// Capture ctrl+c
|
||||
signal(SIGINT) { signal in
|
||||
ElixirKit.API.stop()
|
||||
exit(signal)
|
||||
}
|
||||
|
||||
ElixirKit.API.publish("log", "Hello from Swift!")
|
||||
ElixirKit.API.waitUntilExit()
|
||||
}
|
||||
}
|
7
elixirkit/demo/rel/swift/run.sh
Executable file
7
elixirkit/demo/rel/swift/run.sh
Executable file
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
swift build
|
||||
target_dir=`swift build --show-bin-path`
|
||||
(cd ../.. && mix release --overwrite --path=$target_dir/rel)
|
||||
$target_dir/demo
|
2
elixirkit/demo/rel/winforms/.gitignore
vendored
Normal file
2
elixirkit/demo/rel/winforms/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/bin
|
||||
/obj
|
99
elixirkit/demo/rel/winforms/Demo.cs
Normal file
99
elixirkit/demo/rel/winforms/Demo.cs
Normal file
|
@ -0,0 +1,99 @@
|
|||
namespace Demo;
|
||||
|
||||
static class DemoMain
|
||||
{
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
var api = new ElixirKit.API(id: "com.example.Demo");
|
||||
|
||||
if (api.MainInstance)
|
||||
{
|
||||
api.Start(name: "demo", exited: (exitCode) =>
|
||||
{
|
||||
Application.Exit();
|
||||
});
|
||||
|
||||
Application.ApplicationExit += (sender, args) =>
|
||||
{
|
||||
api.Stop();
|
||||
};
|
||||
|
||||
api.Publish("log", "Hello from Windows Forms!");
|
||||
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new DemoForm(api));
|
||||
}
|
||||
else
|
||||
{
|
||||
api.Publish("log", "Hello from another instance!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DemoForm : Form
|
||||
{
|
||||
ElixirKit.API api;
|
||||
|
||||
public DemoForm(ElixirKit.API api)
|
||||
{
|
||||
this.api = api;
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void form_Load(object? sender, EventArgs e)
|
||||
{
|
||||
}
|
||||
|
||||
private void button_Click(object? sender, EventArgs e)
|
||||
{
|
||||
api.Publish("log", "button pressed!");
|
||||
}
|
||||
|
||||
// WinForms boilerplate below.
|
||||
|
||||
private System.ComponentModel.IContainer? components = null;
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.components = new System.ComponentModel.Container();
|
||||
this.button = new System.Windows.Forms.Button();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// button
|
||||
//
|
||||
this.button.Location = new System.Drawing.Point(20, 20);
|
||||
this.button.Name = "button";
|
||||
this.button.Size = new System.Drawing.Size(200, 100);
|
||||
this.button.TabIndex = 0;
|
||||
this.button.Text = "Press me!";
|
||||
this.button.UseVisualStyleBackColor = true;
|
||||
this.button.Click += new System.EventHandler(this.button_Click);
|
||||
//
|
||||
// form
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(12F, 25F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(800, 450);
|
||||
this.Controls.Add(this.button);
|
||||
this.Name = "form";
|
||||
this.Text = "Demo";
|
||||
this.Load += new System.EventHandler(this.form_Load);
|
||||
this.ResumeLayout(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.Button button;
|
||||
}
|
17
elixirkit/demo/rel/winforms/Demo.csproj
Normal file
17
elixirkit/demo/rel/winforms/Demo.csproj
Normal file
|
@ -0,0 +1,17 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType Condition="'$(Configuration)' == 'Debug'">Exe</OutputType>
|
||||
<OutputType Condition="'$(Configuration)' == 'Release'">WinExe</OutputType>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../elixirkit_dotnet/ElixirKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
5
elixirkit/demo/rel/winforms/README.md
Normal file
5
elixirkit/demo/rel/winforms/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Demo
|
||||
|
||||
Run the app:
|
||||
|
||||
$ ./run.sh
|
7
elixirkit/demo/rel/winforms/run.sh
Normal file
7
elixirkit/demo/rel/winforms/run.sh
Normal file
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
dotnet build
|
||||
target_dir="$PWD/bin/Debug/net6.0-windows"
|
||||
(cd ../.. && mix release --overwrite --path=${target_dir}/rel)
|
||||
dotnet run --no-build
|
2
elixirkit/elixirkit_dotnet/.gitignore
vendored
Normal file
2
elixirkit/elixirkit_dotnet/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/bin
|
||||
/obj
|
247
elixirkit/elixirkit_dotnet/ElixirKit.cs
Normal file
247
elixirkit/elixirkit_dotnet/ElixirKit.cs
Normal file
|
@ -0,0 +1,247 @@
|
|||
using System;
|
||||
using System.Text;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipes;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ElixirKit;
|
||||
|
||||
public static class API
|
||||
{
|
||||
private static Release? release;
|
||||
private static Mutex? mutex;
|
||||
private static string? id;
|
||||
private static bool mainInstance = true;
|
||||
|
||||
// On Windows we need to manually handle the app being launched multiple times.
|
||||
// It can be opened directly or via its associated file types and URL schemes.
|
||||
// This function checks if the current app is the "main instance".
|
||||
public static bool IsMainInstance(String id)
|
||||
{
|
||||
if (mutex != null)
|
||||
{
|
||||
throw new Exception("IsMainInstance can only be called once");
|
||||
}
|
||||
|
||||
API.id = id;
|
||||
mutex = new Mutex(true, id, out mainInstance);
|
||||
return mainInstance;
|
||||
}
|
||||
|
||||
static void ensureMainInstance()
|
||||
{
|
||||
if (!mainInstance)
|
||||
{
|
||||
throw new Exception("Not on main instance");
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasExited {
|
||||
get {
|
||||
ensureMainInstance();
|
||||
return release!.HasExited;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Start(string name, ExitHandler? exited = null, string? logPath = null)
|
||||
{
|
||||
ensureMainInstance();
|
||||
|
||||
release = new Release(name, exited, logPath);
|
||||
|
||||
if (mutex != null)
|
||||
{
|
||||
var t = new Task(() => {
|
||||
while (true) {
|
||||
var line = PipeReadLine();
|
||||
|
||||
if (line != null)
|
||||
{
|
||||
release!.Send(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
t.Start();
|
||||
}
|
||||
}
|
||||
|
||||
public static int Stop()
|
||||
{
|
||||
ensureMainInstance();
|
||||
return release!.Stop();
|
||||
}
|
||||
|
||||
public static int WaitForExit()
|
||||
{
|
||||
ensureMainInstance();
|
||||
return release!.WaitForExit();
|
||||
}
|
||||
|
||||
public static void Publish(string name, string data)
|
||||
{
|
||||
if (mainInstance)
|
||||
{
|
||||
release!.Publish(name, data);
|
||||
}
|
||||
else
|
||||
{
|
||||
var message = Release.EncodeEventMessage(name, data);
|
||||
PipeWriteLine(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? PipeReadLine()
|
||||
{
|
||||
using var pipe = new NamedPipeServerStream(id!);
|
||||
pipe.WaitForConnection();
|
||||
using var reader = new StreamReader(pipe);
|
||||
var line = reader.ReadLine()!;
|
||||
pipe.Disconnect();
|
||||
return line;
|
||||
}
|
||||
|
||||
private static void PipeWriteLine(string line)
|
||||
{
|
||||
using var pipe = new NamedPipeClientStream(id!);
|
||||
pipe.Connect();
|
||||
using var writer = new StreamWriter(pipe);
|
||||
writer.WriteLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
public delegate void ExitHandler(int ExitCode);
|
||||
|
||||
internal class Release
|
||||
{
|
||||
Process startProcess;
|
||||
NetworkStream stream;
|
||||
TcpListener listener;
|
||||
TcpClient client;
|
||||
|
||||
internal bool HasExited {
|
||||
get {
|
||||
return startProcess.HasExited;
|
||||
}
|
||||
}
|
||||
|
||||
public Release(string name, ExitHandler? exited = null, string? logPath = null)
|
||||
{
|
||||
var endpoint = new IPEndPoint(IPAddress.Loopback, 0);
|
||||
listener = new(endpoint);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
startProcess = new Process()
|
||||
{
|
||||
StartInfo = new ProcessStartInfo()
|
||||
{
|
||||
FileName = relScript(name),
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
}
|
||||
};
|
||||
|
||||
startProcess.StartInfo.Arguments = "start";
|
||||
startProcess.StartInfo.EnvironmentVariables.Add("ELIXIRKIT_PORT", $"{port}");
|
||||
|
||||
if (exited != null)
|
||||
{
|
||||
startProcess.EnableRaisingEvents = true;
|
||||
startProcess.Exited += (sender, args) =>
|
||||
{
|
||||
exited(startProcess.ExitCode);
|
||||
};
|
||||
}
|
||||
|
||||
startProcess.OutputDataReceived += (sender, e) =>
|
||||
{
|
||||
if (!String.IsNullOrEmpty(e.Data)) { Console.WriteLine(e.Data); }
|
||||
};
|
||||
|
||||
startProcess.ErrorDataReceived += (sender, e) =>
|
||||
{
|
||||
if (!String.IsNullOrEmpty(e.Data)) { Console.Error.WriteLine(e.Data); }
|
||||
};
|
||||
|
||||
if (logPath != null)
|
||||
{
|
||||
var logWriter = File.AppendText(logPath);
|
||||
logWriter.AutoFlush = true;
|
||||
|
||||
startProcess.OutputDataReceived += (sender, e) =>
|
||||
{
|
||||
if (!String.IsNullOrEmpty(e.Data)) { logWriter.WriteLine(e.Data); }
|
||||
};
|
||||
|
||||
startProcess.ErrorDataReceived += (sender, e) =>
|
||||
{
|
||||
if (!String.IsNullOrEmpty(e.Data)) { logWriter.WriteLine(e.Data); }
|
||||
};
|
||||
}
|
||||
|
||||
startProcess.Start();
|
||||
startProcess.BeginOutputReadLine();
|
||||
startProcess.BeginErrorReadLine();
|
||||
|
||||
client = listener.AcceptTcpClient();
|
||||
stream = client.GetStream();
|
||||
}
|
||||
|
||||
internal static string EncodeEventMessage(string name, string data)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(data);
|
||||
var encoded = System.Convert.ToBase64String(bytes);
|
||||
return $"event:{name}:{encoded}";
|
||||
}
|
||||
|
||||
public void Publish(string name, string data) {
|
||||
Send(EncodeEventMessage(name, data));
|
||||
}
|
||||
|
||||
internal void Send(string message)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(message + "\n");
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
|
||||
public int Stop()
|
||||
{
|
||||
if (HasExited)
|
||||
{
|
||||
return startProcess!.ExitCode;
|
||||
}
|
||||
|
||||
client.Close();
|
||||
listener.Stop();
|
||||
return WaitForExit();
|
||||
}
|
||||
|
||||
public int WaitForExit()
|
||||
{
|
||||
startProcess!.WaitForExit();
|
||||
return startProcess!.ExitCode;
|
||||
}
|
||||
|
||||
private string relScript(string name)
|
||||
{
|
||||
var exe = Process.GetCurrentProcess().MainModule!.FileName;
|
||||
var dir = Path.GetDirectoryName(exe)!;
|
||||
|
||||
if (Path.GetExtension(exe) == ".exe")
|
||||
{
|
||||
return Path.Combine(dir, "rel", "bin", name + ".bat");
|
||||
}
|
||||
else
|
||||
{
|
||||
return Path.Combine(dir, "rel", "bin", name);
|
||||
}
|
||||
}
|
||||
}
|
9
elixirkit/elixirkit_dotnet/ElixirKit.csproj
Normal file
9
elixirkit/elixirkit_dotnet/ElixirKit.csproj
Normal file
|
@ -0,0 +1,9 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net46</TargetFramework>
|
||||
<LangVersion>10.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
8
elixirkit/elixirkit_swift/.gitignore
vendored
Normal file
8
elixirkit/elixirkit_swift/.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
27
elixirkit/elixirkit_swift/Package.swift
Normal file
27
elixirkit/elixirkit_swift/Package.swift
Normal file
|
@ -0,0 +1,27 @@
|
|||
// swift-tools-version: 5.5
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ElixirKit",
|
||||
platforms: [
|
||||
.macOS(.v11)
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "ElixirKit",
|
||||
targets: ["ElixirKit"]
|
||||
),
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.target(
|
||||
name: "ElixirKit",
|
||||
dependencies: []
|
||||
),
|
||||
.testTarget(
|
||||
name: "ElixirKitTests",
|
||||
dependencies: ["ElixirKit"]
|
||||
)
|
||||
]
|
||||
)
|
1
elixirkit/elixirkit_swift/README.md
Normal file
1
elixirkit/elixirkit_swift/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
# ElixirKit
|
33
elixirkit/elixirkit_swift/Scripts/build_macos_app.sh
Executable file
33
elixirkit/elixirkit_swift/Scripts/build_macos_app.sh
Executable file
|
@ -0,0 +1,33 @@
|
|||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
app_name=$ELIXIRKIT_APP_NAME
|
||||
release_name="${ELIXIRKIT_RELEASE_NAME:-}"
|
||||
app_dir=$PWD/.build/${app_name}.app
|
||||
build_args="${ELIXIRKIT_BUILD_ARGS:-}"
|
||||
|
||||
rm -rf $app_dir
|
||||
swift build $build_args
|
||||
target_dir=`swift build --show-bin-path $build_args`
|
||||
rel_dir=$app_dir/Contents/Resources/rel
|
||||
|
||||
mkdir -p $app_dir/Contents/{MacOS,Resources}
|
||||
|
||||
if [ -f Info.plist ]; then
|
||||
cp Info.plist $app_dir/Contents/Info.plist
|
||||
fi
|
||||
|
||||
cp $target_dir/$app_name $app_dir/Contents/MacOS/$app_name
|
||||
|
||||
if [ -d Resources ]; then
|
||||
for i in Resources/*; do
|
||||
cp $i $app_dir/Contents/Resources/
|
||||
done
|
||||
fi
|
||||
|
||||
(
|
||||
cd $ELIXIRKIT_PROJECT_DIR
|
||||
mix release $release_name --overwrite --path=$rel_dir
|
||||
)
|
||||
|
||||
/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -f $app_dir
|
39
elixirkit/elixirkit_swift/Scripts/build_macos_dmg.sh
Normal file
39
elixirkit/elixirkit_swift/Scripts/build_macos_dmg.sh
Normal file
|
@ -0,0 +1,39 @@
|
|||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
app_name=$ELIXIRKIT_APP_NAME
|
||||
app_dir=$PWD/.build/${app_name}.app
|
||||
|
||||
identity="${ELIXIRKIT_CODESIGN_IDENTITY:-}"
|
||||
team_id="${ELIXIRKIT_NOTARY_TEAM_ID:-}"
|
||||
apple_id="${ELIXIRKIT_NOTARY_APPLE_ID:-}"
|
||||
password="${ELIXIRKIT_NOTARY_PASSWORD:-}"
|
||||
|
||||
if [ -n "$identity" ]; then
|
||||
files=`find $app_dir -perm +111 -type f -exec sh -c "file {} | grep --silent Mach-O" \; -print`
|
||||
files="$files $app_dir/Contents/MacOS/$app_name"
|
||||
codesign --sign="$identity" --options=runtime --entitlements=App.entitlements --force --timestamp --verbose=2 $files
|
||||
else
|
||||
echo "[warning] skipping codesign. Please set ELIXIRKIT_CODESIGN_IDENTITY environment variable"
|
||||
fi
|
||||
|
||||
dmg_path=$PWD/.build/${app_name}Install.dmg
|
||||
dmg_dir=$PWD/.build/dmg
|
||||
rm -rf $dmg_dir
|
||||
mkdir $dmg_dir
|
||||
ln -s /Applications $dmg_dir
|
||||
cp -r $app_dir $dmg_dir/
|
||||
|
||||
hdiutil create $dmg_path -ov -volname ${app_name}Install -fs HFS+ -srcfolder $dmg_dir
|
||||
|
||||
if [ -n "$team_id" ]; then
|
||||
xcrun notarytool submit \
|
||||
--team-id "${team_id}" \
|
||||
--apple-id "${apple_id}" \
|
||||
--password "${password}" \
|
||||
--progress \
|
||||
--wait \
|
||||
$dmg_path
|
||||
else
|
||||
echo "[warning] skipping notarization. Please set ELIXIRKIT_NOTARY_{TEAM_ID,APPLE_ID,PASSWORD} environment variables"
|
||||
fi
|
179
elixirkit/elixirkit_swift/Sources/ElixirKit/ElixirKit.swift
Normal file
179
elixirkit/elixirkit_swift/Sources/ElixirKit/ElixirKit.swift
Normal file
|
@ -0,0 +1,179 @@
|
|||
import Foundation
|
||||
import Network
|
||||
|
||||
public class API {
|
||||
static var process: Process?
|
||||
private static var release: Release?
|
||||
|
||||
public static var isRunning: Bool {
|
||||
get {
|
||||
release != nil && release!.isRunning;
|
||||
}
|
||||
}
|
||||
|
||||
public static func start(name: String, logPath: String? = nil, terminationHandler: ((Process) -> Void)? = nil) {
|
||||
release = Release(name: name, logPath: logPath, terminationHandler: terminationHandler)
|
||||
}
|
||||
|
||||
public static func publish(_ name: String, _ data: String) {
|
||||
release!.publish(name, data)
|
||||
}
|
||||
|
||||
public static func stop() {
|
||||
release!.stop();
|
||||
}
|
||||
|
||||
public static func waitUntilExit() {
|
||||
release!.waitUntilExit();
|
||||
}
|
||||
}
|
||||
|
||||
private class Release {
|
||||
let listener: NWListener
|
||||
let startProcess: Process
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var logHandle: FileHandle?
|
||||
var connection: NWConnection?
|
||||
|
||||
var isRunning: Bool {
|
||||
get {
|
||||
startProcess.isRunning
|
||||
}
|
||||
}
|
||||
|
||||
init(name: String, logPath: String? = nil, terminationHandler: ((Process) -> Void)? = nil) {
|
||||
listener = try! NWListener(using: .tcp, on: .any)
|
||||
|
||||
let bundle = Bundle.main
|
||||
var rootDir = "";
|
||||
|
||||
if bundle.bundlePath.hasSuffix(".app") {
|
||||
rootDir = "\(bundle.bundlePath)/Contents/Resources"
|
||||
}
|
||||
else {
|
||||
rootDir = bundle.bundlePath
|
||||
}
|
||||
|
||||
startProcess = Process()
|
||||
|
||||
if logPath != nil {
|
||||
let logPath = logPath!
|
||||
let fm = FileManager.default
|
||||
if !fm.fileExists(atPath: logPath) { fm.createFile(atPath: logPath, contents: Data()) }
|
||||
logHandle = FileHandle(forUpdatingAtPath: logPath)!
|
||||
logHandle!.seekToEndOfFile()
|
||||
|
||||
let stdout = Pipe()
|
||||
let stderr = Pipe()
|
||||
startProcess.standardOutput = stdout
|
||||
startProcess.standardError = stderr
|
||||
let stdouth = stdout.fileHandleForReading
|
||||
let stderrh = stderr.fileHandleForReading
|
||||
stdouth.waitForDataInBackgroundAndNotify()
|
||||
stderrh.waitForDataInBackgroundAndNotify()
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(receiveStdout(n:)),
|
||||
name: NSNotification.Name.NSFileHandleDataAvailable,
|
||||
object: stdouth
|
||||
)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(receiveStderr(n:)),
|
||||
name: NSNotification.Name.NSFileHandleDataAvailable,
|
||||
object: stderrh
|
||||
)
|
||||
}
|
||||
|
||||
startProcess.launchPath = "\(rootDir)/rel/bin/\(name)"
|
||||
startProcess.arguments = ["start"]
|
||||
startProcess.terminationHandler = terminationHandler
|
||||
|
||||
listener.stateUpdateHandler = stateDidChange(to:)
|
||||
listener.newConnectionHandler = didAccept(connection:)
|
||||
listener.start(queue: .global())
|
||||
|
||||
let timeout = DispatchTime.now() + DispatchTimeInterval.seconds(5)
|
||||
|
||||
if semaphore.wait(timeout: timeout) == .timedOut {
|
||||
fatalError("waited for connection for more than 5s")
|
||||
}
|
||||
}
|
||||
|
||||
func stateDidChange(to state: NWListener.State) {
|
||||
switch state {
|
||||
case .ready:
|
||||
start(port: listener.port!.rawValue.description)
|
||||
|
||||
case .failed(let error):
|
||||
print("Server error: \(error.localizedDescription)")
|
||||
exit(EXIT_FAILURE)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func start(port: String) {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["ELIXIRKIT_PORT"] = port
|
||||
startProcess.environment = env
|
||||
try! startProcess.run()
|
||||
}
|
||||
|
||||
func didAccept(connection: NWConnection) {
|
||||
self.connection = connection
|
||||
self.connection!.start(queue: .main)
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
func send(_ string: String) {
|
||||
connection!.send(
|
||||
content: (string + "\n").data(using: .utf8),
|
||||
completion: .contentProcessed { error in
|
||||
if error != nil {
|
||||
print(error!)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@objc
|
||||
func receiveStdout(n: NSNotification) {
|
||||
let h = n.object as! FileHandle
|
||||
let data = h.availableData
|
||||
if !data.isEmpty {
|
||||
FileHandle.standardOutput.write(data)
|
||||
logHandle!.write(data)
|
||||
h.waitForDataInBackgroundAndNotify()
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func receiveStderr(n: NSNotification) {
|
||||
let h = n.object as! FileHandle
|
||||
let data = h.availableData
|
||||
if !data.isEmpty {
|
||||
logHandle!.write(data)
|
||||
FileHandle.standardError.write(data)
|
||||
h.waitForDataInBackgroundAndNotify()
|
||||
}
|
||||
}
|
||||
|
||||
public func publish(_ name: String, _ data: String) {
|
||||
let encoded = data.data(using: .utf8)!.base64EncodedString()
|
||||
let message = "event:\(name):\(encoded)"
|
||||
send(message)
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
connection!.cancel()
|
||||
listener.cancel()
|
||||
waitUntilExit()
|
||||
}
|
||||
|
||||
public func waitUntilExit() {
|
||||
startProcess.waitUntilExit()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import XCTest
|
||||
@testable import ElixirKit
|
||||
|
||||
final class ElixirKitTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
XCTAssertEqual(1 + 2, 3)
|
||||
}
|
||||
}
|
5
elixirkit/lib/elixirkit.ex
Normal file
5
elixirkit/lib/elixirkit.ex
Normal file
|
@ -0,0 +1,5 @@
|
|||
defmodule ElixirKit do
|
||||
def start do
|
||||
Supervisor.start_child(ElixirKit.Supervisor, {ElixirKit.Server, self()})
|
||||
end
|
||||
end
|
12
elixirkit/lib/elixirkit/application.ex
Normal file
12
elixirkit/lib/elixirkit/application.ex
Normal file
|
@ -0,0 +1,12 @@
|
|||
defmodule ElixirKit.Application do
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = []
|
||||
opts = [strategy: :one_for_one, name: ElixirKit.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
end
|
30
elixirkit/lib/elixirkit/server.ex
Normal file
30
elixirkit/lib/elixirkit/server.ex
Normal file
|
@ -0,0 +1,30 @@
|
|||
defmodule ElixirKit.Server do
|
||||
@moduledoc false
|
||||
|
||||
use GenServer
|
||||
|
||||
def start_link(arg) do
|
||||
GenServer.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(pid) do
|
||||
port = System.fetch_env!("ELIXIRKIT_PORT") |> String.to_integer()
|
||||
{:ok, socket} = :gen_tcp.connect('localhost', port, mode: :binary, packet: :line)
|
||||
{:ok, %{pid: pid, socket: socket}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:tcp, socket, "event:" <> rest}, state) when socket == state.socket do
|
||||
[name, data] = rest |> String.trim_trailing() |> String.split(":")
|
||||
data = Base.decode64!(data)
|
||||
send(state.pid, {:event, name, data})
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:tcp_closed, socket}, state) when socket == state.socket do
|
||||
System.stop()
|
||||
{:stop, :shutdown, state}
|
||||
end
|
||||
end
|
39
elixirkit/mix.exs
Normal file
39
elixirkit/mix.exs
Normal file
|
@ -0,0 +1,39 @@
|
|||
defmodule ElixirKit.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :elixirkit,
|
||||
version: "0.1.0",
|
||||
elixir: "~> 1.14",
|
||||
start_permanent: Mix.env() == :prod,
|
||||
package: package(),
|
||||
deps: deps()
|
||||
]
|
||||
end
|
||||
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:logger],
|
||||
mod: {ElixirKit.Application, []}
|
||||
]
|
||||
end
|
||||
|
||||
def package do
|
||||
[
|
||||
files: [
|
||||
"lib",
|
||||
"elixirkit_swift/Package.swift",
|
||||
"elixirkit_swift/Sources",
|
||||
"elixirkit_dotnet/ElixirKit.csproj",
|
||||
"elixirkit_dotnet/ElixirKit.cs",
|
||||
"mix.exs",
|
||||
"README.md"
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
defp deps do
|
||||
[]
|
||||
end
|
||||
end
|
1
elixirkit/otp_bootstrap/.gitignore
vendored
Normal file
1
elixirkit/otp_bootstrap/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/_build
|
128
elixirkit/otp_bootstrap/build.sh
Executable file
128
elixirkit/otp_bootstrap/build.sh
Executable file
|
@ -0,0 +1,128 @@
|
|||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -ne 3 ]; then
|
||||
cat <<EOF
|
||||
Usage:
|
||||
build.sh otp_version openssl_version target
|
||||
|
||||
Set BUILD_DOCS=1 to build doc chunks.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
otp_version=$1
|
||||
openssl_version=$2
|
||||
target=$3
|
||||
build_docs=${BUILD_DOCS:-}
|
||||
|
||||
# Common build flags
|
||||
export MAKEFLAGS=-j8
|
||||
cflags="-Os -fno-common -mmacosx-version-min=11.0"
|
||||
|
||||
case "$target" in
|
||||
macos-aarch64)
|
||||
arch="arm64"
|
||||
;;
|
||||
macos-x86_64)
|
||||
arch="x86_64"
|
||||
;;
|
||||
*)
|
||||
echo "bad target $target"
|
||||
exit 1
|
||||
esac
|
||||
|
||||
build_dir=$PWD/_build
|
||||
openssl_src_dir=$build_dir/openssl-src-$openssl_version
|
||||
openssl_rel_dir=$build_dir/openssl-rel-$openssl_version-$target
|
||||
otp_src_dir=$build_dir/otp-src-$otp_version
|
||||
otp_rel_dir=$build_dir/otp-rel-$otp_version-openssl-$openssl_version-$target
|
||||
|
||||
echo "Building OpenSSL $openssl_version..."
|
||||
|
||||
if [ -d $openssl_src_dir ]; then
|
||||
echo "$openssl_src_dir already exists"
|
||||
else
|
||||
url=https://github.com/openssl/openssl
|
||||
ref=OpenSSL_`echo $openssl_version | tr '.' '_'`
|
||||
git clone --depth 1 $url --branch $ref $openssl_src_dir
|
||||
fi
|
||||
|
||||
if [ -d $openssl_rel_dir ]; then
|
||||
echo "$openssl_rel_dir already exists"
|
||||
else
|
||||
(
|
||||
cd $openssl_src_dir
|
||||
./Configure "darwin64-$arch-cc" --prefix=$openssl_rel_dir $cflags
|
||||
make clean
|
||||
make
|
||||
make install_sw
|
||||
)
|
||||
fi
|
||||
|
||||
echo "Building OTP $otp_version..."
|
||||
|
||||
if [ -d $otp_src_dir ]; then
|
||||
echo "$otp_src_dir already exists"
|
||||
else
|
||||
url=https://github.com/erlang/otp
|
||||
ref=OTP-$otp_version
|
||||
git clone --depth 1 $url --branch $ref $otp_src_dir
|
||||
fi
|
||||
|
||||
if [ -d $otp_rel_dir ]; then
|
||||
echo "$otp_rel_dir already exists"
|
||||
else
|
||||
(
|
||||
cd $otp_src_dir
|
||||
git clean -dfx
|
||||
export ERL_TOP=$PWD
|
||||
export ERLC_USE_SERVER=true
|
||||
xcrun="xcrun -sdk macosx"
|
||||
sysroot=`$xcrun --show-sdk-path`
|
||||
|
||||
./configure --enable-bootstrap-only
|
||||
|
||||
./otp_build configure \
|
||||
--build=`erts/autoconf/config.guess` \
|
||||
--host="$arch-apple-darwin" \
|
||||
--with-ssl=$openssl_rel_dir \
|
||||
--disable-dynamic-ssl-lib \
|
||||
--without-{javac,odbc,wx,observer,debugger,et} \
|
||||
erl_xcomp_sysroot=$sysroot \
|
||||
CC="$xcrun cc -arch $arch" \
|
||||
CFLAGS="$cflags" \
|
||||
CXX="$xcrun c++ -arch $arch" \
|
||||
CXXFLAGS="$cflags" \
|
||||
LD="$xcrun ld" \
|
||||
LDFLAGS="-lc++" \
|
||||
RANLIB="$xcrun ranlib"
|
||||
|
||||
./otp_build boot -a
|
||||
./otp_build release -a $otp_rel_dir
|
||||
|
||||
if [ "$build_docs" = "1" ]; then
|
||||
make release_docs DOC_TARGETS=chunks RELEASE_ROOT=$otp_rel_dir
|
||||
fi
|
||||
|
||||
cd $otp_rel_dir
|
||||
./Install -cross -sasl $PWD
|
||||
)
|
||||
fi
|
||||
|
||||
echo "Checking OTP..."
|
||||
|
||||
if [[ `uname -m` = "arm64" && "$target" = *"x86_64" ]]; then
|
||||
echo "skip"
|
||||
exit 0
|
||||
fi
|
||||
if [[ `uname -m` = "x86_64" && "$target" = *"aarch64" ]]; then
|
||||
echo "skip"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export PATH=$otp_rel_dir/bin:$PATH
|
||||
erl -noshell -eval 'io:format("root_dir=~p~n", [code:root_dir()]), halt().'
|
||||
erl -noshell -eval 'io:format("~s", [erlang:system_info(system_version)]), halt().'
|
||||
erl -noshell -eval 'io:format("~s~n", [erlang:system_info(system_architecture)]), halt().'
|
||||
erl -noshell -eval 'ok = crypto:start(), io:format("crypto ok~n"), halt().'
|
45
elixirkit/otp_bootstrap/build_macos_universal.sh
Executable file
45
elixirkit/otp_bootstrap/build_macos_universal.sh
Executable file
|
@ -0,0 +1,45 @@
|
|||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -ne 2 ]; then
|
||||
cat <<EOF
|
||||
Usage:
|
||||
build_macos_universal.sh otp_version openssl_version
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
otp_version=$1
|
||||
openssl_version=$2
|
||||
|
||||
echo "\nBuilding macos-universal..."
|
||||
otp_rel_dir=$PWD/_build/otp-rel-$otp_version-openssl-$openssl_version-macos-universal
|
||||
|
||||
if [ -d $otp_rel_dir ]; then
|
||||
echo "$otp_rel_dir already exists"
|
||||
else
|
||||
echo "Building macos-aarch64..."
|
||||
BUILD_DOCS=1 ./build.sh $otp_version $openssl_version macos-aarch64
|
||||
|
||||
echo "\nBuilding macos-x86_64..."
|
||||
./build.sh $otp_version $openssl_version macos-x86_64
|
||||
|
||||
dir1=$PWD/_build/otp-rel-$otp_version-openssl-$openssl_version-macos-aarch64
|
||||
dir2=$PWD/_build/otp-rel-$otp_version-openssl-$openssl_version-macos-x86_64
|
||||
cp -R $dir1 $otp_rel_dir
|
||||
cd $otp_rel_dir
|
||||
for i in `find . -perm +111 -type f -exec sh -c "file {} | grep --silent 'Mach-O'" \; -print`; do
|
||||
echo lipo $otp_rel_dir/$i
|
||||
lipo $i $dir2/$i -create -output $i.universal
|
||||
mv $i.universal $i
|
||||
done
|
||||
cd -
|
||||
fi
|
||||
|
||||
export PATH=$otp_rel_dir/bin:$PATH
|
||||
|
||||
echo "checking OTP"
|
||||
erl -noshell -eval 'io:format("root_dir=~p~n", [code:root_dir()]), halt().'
|
||||
erl -noshell -eval 'io:format("~s", [erlang:system_info(system_version)]), halt().'
|
||||
erl -noshell -eval 'io:format("~s~n", [erlang:system_info(system_architecture)]), halt().'
|
||||
erl -noshell -eval 'ok = crypto:start(), io:format("crypto ok~n"), halt().'
|
|
@ -1,3 +1,3 @@
|
|||
defmodule AppBundlerTest do
|
||||
defmodule ElixirKitTest do
|
||||
use ExUnit.Case, async: true
|
||||
end
|
|
@ -1,179 +1,41 @@
|
|||
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)
|
||||
GenServer.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
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
|
||||
AppBundler.init()
|
||||
os = AppBundler.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, _} = ElixirKit.start()
|
||||
{:ok, nil}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:open_app, state) do
|
||||
open_browser()
|
||||
def handle_info({:event, "open", url}, state) do
|
||||
open(url)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:open_file, path}, state) do
|
||||
defp open("") do
|
||||
open(LivebookWeb.Endpoint.access_url())
|
||||
end
|
||||
|
||||
defp open("file://" <> path) do
|
||||
path
|
||||
|> Livebook.Utils.notebook_open_url()
|
||||
|> open_browser()
|
||||
|
||||
{:noreply, state}
|
||||
|> open()
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:open_url, "livebook://" <> rest}, state) do
|
||||
defp open("livebook://" <> rest) do
|
||||
"https://#{rest}"
|
||||
|> Livebook.Utils.notebook_import_url()
|
||||
|> open_browser()
|
||||
|
||||
{:noreply, state}
|
||||
|> open()
|
||||
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
|
||||
defp open(url) do
|
||||
Livebook.Utils.browser_open(url)
|
||||
end
|
||||
end
|
||||
|
||||
defmodule WxUtils do
|
||||
@moduledoc false
|
||||
@wxID_ANY -1
|
||||
|
||||
def taskbar(title, icon, menu_items) do
|
||||
pid = self()
|
||||
os = AppBundler.os()
|
||||
options = if os == :macos, do: [iconType: 1], else: []
|
||||
|
||||
# skip keyboard shortcuts
|
||||
menu_items =
|
||||
for item <- menu_items do
|
||||
{title, options} = item
|
||||
options = Keyword.delete(options, :key)
|
||||
{title, options}
|
||||
end
|
||||
|
||||
taskbar =
|
||||
:wxTaskBarIcon.new(
|
||||
[
|
||||
createPopupMenu: fn ->
|
||||
menu = menu(menu_items)
|
||||
|
||||
# For some reason, on macOS the menu event must be handled in another process
|
||||
# but on Windows it must be either the same process OR we use the callback.
|
||||
case os do
|
||||
:macos ->
|
||||
env = :wx.get_env()
|
||||
|
||||
Task.start_link(fn ->
|
||||
:wx.set_env(env)
|
||||
:wxMenu.connect(menu, :command_menu_selected)
|
||||
|
||||
receive do
|
||||
message ->
|
||||
send(pid, message)
|
||||
end
|
||||
end)
|
||||
|
||||
:windows ->
|
||||
:ok =
|
||||
:wxMenu.connect(menu, :command_menu_selected,
|
||||
callback: fn wx, _ ->
|
||||
send(pid, wx)
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
menu
|
||||
end
|
||||
] ++ options
|
||||
)
|
||||
|
||||
:wxTaskBarIcon.setIcon(taskbar, icon, tooltip: title)
|
||||
taskbar
|
||||
end
|
||||
|
||||
def menu(items) do
|
||||
menu = :wxMenu.new()
|
||||
|
||||
Enum.each(items, fn
|
||||
{title, options} ->
|
||||
id = Keyword.get(options, :id, @wxID_ANY)
|
||||
|
||||
title =
|
||||
case Keyword.fetch(options, :key) do
|
||||
{:ok, key} ->
|
||||
title <> "\t" <> key
|
||||
|
||||
:error ->
|
||||
title
|
||||
end
|
||||
|
||||
:wxMenu.append(menu, id, title)
|
||||
end)
|
||||
|
||||
menu
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
60
mix.exs
60
mix.exs
|
@ -30,16 +30,11 @@ defmodule Livebook.MixProject do
|
|||
def application do
|
||||
[
|
||||
mod: {Livebook.Application, []},
|
||||
extra_applications:
|
||||
[:logger, :runtime_tools, :os_mon, :inets, :ssl, :xmerl] ++
|
||||
extra_applications(Mix.target()),
|
||||
extra_applications: [:logger, :runtime_tools, :os_mon, :inets, :ssl, :xmerl],
|
||||
env: Application.get_all_env(:livebook)
|
||||
]
|
||||
end
|
||||
|
||||
defp extra_applications(:app), do: [:wx]
|
||||
defp extra_applications(_), do: []
|
||||
|
||||
defp elixirc_paths(:test), do: elixirc_paths(:dev) ++ ["test/support"]
|
||||
defp elixirc_paths(_), do: ["lib", "proto/lib"]
|
||||
|
||||
|
@ -111,7 +106,7 @@ defmodule Livebook.MixProject do
|
|||
]
|
||||
end
|
||||
|
||||
defp target_deps(:app), do: [{:app_bundler, path: "app_bundler"}]
|
||||
defp target_deps(:app), do: [{:elixirkit, path: "elixirkit"}]
|
||||
defp target_deps(_), do: []
|
||||
|
||||
@lock (with {:ok, contents} <- File.read("mix.lock"),
|
||||
|
@ -134,14 +129,6 @@ defmodule Livebook.MixProject do
|
|||
## Releases
|
||||
|
||||
defp releases do
|
||||
macos_notarization = macos_notarization()
|
||||
|
||||
additional_paths = [
|
||||
"rel/vendor/otp/erts-#{:erlang.system_info(:version)}/bin",
|
||||
"rel/vendor/otp/bin",
|
||||
"rel/vendor/elixir/bin"
|
||||
]
|
||||
|
||||
[
|
||||
livebook: [
|
||||
include_executables_for: [:unix],
|
||||
|
@ -155,53 +142,12 @@ defmodule Livebook.MixProject do
|
|||
steps: [
|
||||
:assemble,
|
||||
&remove_cookie/1,
|
||||
&standalone_erlang_elixir/1,
|
||||
&AppBundler.bundle/1
|
||||
],
|
||||
app: [
|
||||
name: "Livebook",
|
||||
url_schemes: ["livebook"],
|
||||
document_types: [
|
||||
[
|
||||
name: "LiveMarkdown",
|
||||
extensions: ["livemd"],
|
||||
macos: [
|
||||
icon_path: "rel/app/icon.png",
|
||||
role: "Editor"
|
||||
],
|
||||
windows: [
|
||||
icon_path: "rel/app/icon.ico"
|
||||
]
|
||||
]
|
||||
],
|
||||
macos: [
|
||||
app_type: :agent,
|
||||
icon_path: "rel/app/icon-macos.png",
|
||||
build_dmg: macos_notarization != nil,
|
||||
notarization: macos_notarization,
|
||||
additional_paths: additional_paths ++ ["/usr/local/bin"]
|
||||
],
|
||||
windows: [
|
||||
icon_path: "rel/app/icon.ico",
|
||||
build_installer: true,
|
||||
additional_paths: additional_paths
|
||||
]
|
||||
&standalone_erlang_elixir/1
|
||||
]
|
||||
]
|
||||
]
|
||||
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
|
||||
|
|
2
mix.lock
2
mix.lock
|
@ -9,10 +9,8 @@
|
|||
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
|
||||
"earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"},
|
||||
"ecto": {:hex, :ecto, "3.9.4", "3ee68e25dbe0c36f980f1ba5dd41ee0d3eb0873bccae8aeaf1a2647242bffa35", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "de5f988c142a3aa4ec18b85a4ec34a2390b65b24f02385c1144252ff6ff8ee75"},
|
||||
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
|
||||
"floki": {:hex, :floki, "0.34.0", "002d0cc194b48794d74711731db004fafeb328fe676976f160685262d43706a8", [:mix], [], "hexpm", "9c3a9f43f40dde00332a589bd9d389b90c1f518aef500364d00636acc5ebc99c"},
|
||||
"hpax": {:hex, :hpax, "0.1.1", "2396c313683ada39e98c20a75a82911592b47e5c24391363343bde74f82396ca", [:mix], [], "hexpm", "0ae7d5a0b04a8a60caf7a39fcf3ec476f35cc2cc16c05abea730d3ce6ac6c826"},
|
||||
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
|
||||
"jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
|
||||
"mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
|
||||
"mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"},
|
||||
|
|
|
@ -4,8 +4,9 @@ set MIX_ARCHIVES=!RELEASE_ROOT!\vendor\archives
|
|||
set MIX_REBAR3=!RELEASE_ROOT!\vendor\rebar3
|
||||
set LIVEBOOK_SHUTDOWN_ENABLED=true
|
||||
set LIVEBOOK_PORT=0
|
||||
set PATH=!RELEASE_ROOT!\vendor\otp\erts-<%= @release.erts_version%>\bin;!RELEASE_ROOT!\vendor\otp\bin;!RELEASE_ROOT!\vendor\elixir\bin;!PATH!
|
||||
|
||||
set cookie_path="!RELEASE_ROOT!\releases\COOKIE"
|
||||
set cookie_path=!RELEASE_ROOT!\releases\COOKIE
|
||||
if not exist %cookie_path% (
|
||||
for /f "skip=1" %%X in ('wmic os get localdatetime') do if not defined TIMESTAMP set TIMESTAMP=%%X
|
||||
echo cookie-!TIMESTAMP:~0,11!-!RANDOM! > %cookie_path%
|
||||
|
|
|
@ -5,6 +5,7 @@ export MIX_REBAR3="${RELEASE_ROOT}/vendor/rebar3"
|
|||
export WX_MACOS_NON_GUI_APP=1
|
||||
export LIVEBOOK_SHUTDOWN_ENABLED=true
|
||||
export LIVEBOOK_PORT=0
|
||||
export PATH="$RELEASE_ROOT/vendor/otp/erts-<%= @release.erts_version%>/bin:$RELEASE_ROOT/vendor/otp/bin:$RELEASE_ROOT/vendor/elixir/bin:$PATH"
|
||||
|
||||
cookie_path="${RELEASE_ROOT}/releases/COOKIE"
|
||||
if [ ! -f $cookie_path ]; then
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 26 KiB |
BIN
rel/app/icon.png
BIN
rel/app/icon.png
Binary file not shown.
Before Width: | Height: | Size: 21 KiB |
8
rel/app/macos/.gitignore
vendored
Normal file
8
rel/app/macos/.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
42
rel/app/macos/Info.plist
Normal file
42
rel/app/macos/Info.plist
Normal file
|
@ -0,0 +1,42 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>Livebook</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>dev.livebook.Livebook</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.8.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>LSRequiresNativeExecution</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Livebook</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>livebook</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>LiveMarkdown</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>livemd</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
19
rel/app/macos/Package.swift
Normal file
19
rel/app/macos/Package.swift
Normal file
|
@ -0,0 +1,19 @@
|
|||
// swift-tools-version: 5.5
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Livebook",
|
||||
platforms: [
|
||||
.macOS(.v11)
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "ElixirKit", path: "../../../elixirkit/elixirkit_swift")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "Livebook",
|
||||
dependencies: ["ElixirKit"]
|
||||
)
|
||||
]
|
||||
)
|
5
rel/app/macos/README.md
Normal file
5
rel/app/macos/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Livebook for macOS
|
||||
|
||||
Run the app:
|
||||
|
||||
$ ./run.sh
|
BIN
rel/app/macos/Resources/AppIcon.icns
Normal file
BIN
rel/app/macos/Resources/AppIcon.icns
Normal file
Binary file not shown.
BIN
rel/app/macos/Resources/LivebookIcon.icns
Normal file
BIN
rel/app/macos/Resources/LivebookIcon.icns
Normal file
Binary file not shown.
BIN
rel/app/macos/Resources/MenuBarIcon.png
Normal file
BIN
rel/app/macos/Resources/MenuBarIcon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.3 KiB |
88
rel/app/macos/Sources/Livebook/Livebook.swift
Normal file
88
rel/app/macos/Sources/Livebook/Livebook.swift
Normal file
|
@ -0,0 +1,88 @@
|
|||
import AppKit
|
||||
import ElixirKit
|
||||
|
||||
@main
|
||||
public struct Livebook {
|
||||
public static func main() {
|
||||
let app = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
app.delegate = delegate
|
||||
app.run()
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var statusItem: NSStatusItem!
|
||||
private var logPath: String!
|
||||
private var launchedByOpenURL = false
|
||||
private var initialURLs: [URL] = []
|
||||
|
||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||
logPath = "\(NSHomeDirectory())/Library/Logs/Livebook.log"
|
||||
|
||||
ElixirKit.API.start(name: "app", logPath: logPath) { process in
|
||||
if process.terminationStatus != 0 {
|
||||
DispatchQueue.main.sync {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = "Livebook exited with error status \(process.terminationStatus)"
|
||||
alert.addButton(withTitle: "Dismiss")
|
||||
alert.addButton(withTitle: "View Logs")
|
||||
|
||||
switch alert.runModal() {
|
||||
case .alertSecondButtonReturn:
|
||||
self.viewLogs()
|
||||
default:
|
||||
()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
|
||||
if (self.initialURLs == []) {
|
||||
ElixirKit.API.publish("open", "")
|
||||
}
|
||||
else {
|
||||
for url in self.initialURLs {
|
||||
ElixirKit.API.publish("open", url.absoluteString)
|
||||
}
|
||||
}
|
||||
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
let button = statusItem.button!
|
||||
button.image = NSImage(named: "MenuBarIcon")
|
||||
let menu = NSMenu()
|
||||
menu.items = [
|
||||
NSMenuItem(title: "Open", action: #selector(open), keyEquivalent: "o"),
|
||||
NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
||||
]
|
||||
statusItem.menu = menu
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ aNotification: Notification) {
|
||||
ElixirKit.API.stop()
|
||||
}
|
||||
|
||||
func application(_ app: NSApplication, open urls: [URL]) {
|
||||
if !ElixirKit.API.isRunning {
|
||||
initialURLs = urls
|
||||
return
|
||||
}
|
||||
|
||||
for url in urls {
|
||||
ElixirKit.API.publish("open", url.absoluteString)
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func open() {
|
||||
ElixirKit.API.publish("open", "")
|
||||
}
|
||||
|
||||
@objc
|
||||
func viewLogs() {
|
||||
NSWorkspace.shared.open(NSURL.fileURL(withPath: logPath))
|
||||
}
|
||||
}
|
10
rel/app/macos/build_app.sh
Executable file
10
rel/app/macos/build_app.sh
Executable file
|
@ -0,0 +1,10 @@
|
|||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
export MIX_TARGET=app
|
||||
export MIX_ENV=prod
|
||||
export ELIXIRKIT_APP_NAME=Livebook
|
||||
export ELIXIRKIT_PROJECT_DIR=$PWD/../../..
|
||||
export ELIXIRKIT_RELEASE_NAME=app
|
||||
|
||||
. ../../../elixirkit/elixirkit_swift/Scripts/build_macos_app.sh
|
5
rel/app/macos/build_dmg.sh
Executable file
5
rel/app/macos/build_dmg.sh
Executable file
|
@ -0,0 +1,5 @@
|
|||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
. `dirname $0`/build_app.sh
|
||||
. ../../../elixirkit/elixirkit_swift/Scripts/build_macos_dmg.sh
|
5
rel/app/macos/run.sh
Executable file
5
rel/app/macos/run.sh
Executable file
|
@ -0,0 +1,5 @@
|
|||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
. `dirname $0`/build_app.sh
|
||||
open -W --stdout `tty` --stderr `tty` $app_dir
|
|
@ -79,11 +79,11 @@ defmodule Standalone do
|
|||
download_elixir_at_destination(standalone_destination, elixir_version)
|
||||
|
||||
filenames =
|
||||
case AppBundler.os() do
|
||||
:macos ->
|
||||
case :os.type() do
|
||||
{:unix, :darwin} ->
|
||||
["elixir", "elixirc", "mix", "iex"]
|
||||
|
||||
:windows ->
|
||||
{:win32, _} ->
|
||||
["elixir.bat", "elixirc.bat", "mix.bat", "iex.bat"]
|
||||
end
|
||||
|
||||
|
|
2
rel/app/windows/.gitignore
vendored
Normal file
2
rel/app/windows/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/bin
|
||||
/obj
|
99
rel/app/windows/Installer.nsi
Normal file
99
rel/app/windows/Installer.nsi
Normal file
|
@ -0,0 +1,99 @@
|
|||
!include "MUI2.nsh"
|
||||
!include "WinVer.nsh"
|
||||
|
||||
Name "Livebook"
|
||||
ManifestDPIAware true
|
||||
OutFile "bin\LivebookInstall.exe"
|
||||
Unicode True
|
||||
|
||||
; Install to user home so we have permission to write COOKIE, crash dumps, etc
|
||||
InstallDir "$LOCALAPPDATA\Livebook"
|
||||
|
||||
; Need admin for registering URL scheme
|
||||
RequestExecutionLevel admin
|
||||
|
||||
!define MUI_ABORTWARNING
|
||||
!define MUI_ICON "Resources\AppIcon.ico"
|
||||
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
!insertmacro MUI_UNPAGE_CONFIRM
|
||||
!insertmacro MUI_UNPAGE_INSTFILES
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
|
||||
Function .onInit
|
||||
${IfNot} ${AtLeastWin10}
|
||||
MessageBox mb_iconStop "Livebook requires Windows 10+"
|
||||
Abort
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Section "Install"
|
||||
SetOutPath "$INSTDIR"
|
||||
|
||||
File "bin\vc_redist.x64.exe"
|
||||
ExecWait '"$INSTDIR\vc_redist.x64.exe" /install /quiet /norestart'
|
||||
Delete "$INSTDIR\vc_redist.x64.exe"
|
||||
|
||||
File /a /r "bin\Livebook-Release\"
|
||||
|
||||
CreateDirectory "$INSTDIR\Logs"
|
||||
WriteUninstaller "$INSTDIR\LivebookUninstall.exe"
|
||||
SectionEnd
|
||||
|
||||
Section "Desktop Shortcut"
|
||||
CreateShortCut "$DESKTOP\Livebook.lnk" "$INSTDIR\Livebook.exe" ""
|
||||
SectionEnd
|
||||
|
||||
Section "Check"
|
||||
DetailPrint "Checking Erlang..."
|
||||
; we use otp\erts-:vsn\bin\erl.exe instead of otp\bin\erl.exe because the latter for some reason
|
||||
; hardcoded the path: c:\otp\erts-:vsn\bin\erlexec.dll. The Elixir releases uses the former
|
||||
; anyway.
|
||||
nsExec::ExecToLog '"$INSTDIR\rel\vendor\otp\erts-${ERTS_VERSION}\bin\erl.exe" -noinput -eval "erlang:display(ok), halt()."'
|
||||
Pop $0
|
||||
${If} $0 != 0
|
||||
MessageBox mb_iconStop "Checking Erlang failed: $0. Please click 'Show details' and report an issue."
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
DetailPrint "Checking Distributed Erlang..."
|
||||
nsExec::ExecToLog '"$INSTDIR\rel\vendor\otp\erts-${ERTS_VERSION}\bin\erl.exe" -sname "livebook-install-test" -noinput -eval "erlang:display(ok), halt()."'
|
||||
Pop $0
|
||||
${If} $0 != 0
|
||||
MessageBox mb_iconStop "Checking Distributed Erlang failed: $0. Please click 'Show details' and report an issue."
|
||||
Abort
|
||||
${EndIf}
|
||||
SectionEnd
|
||||
|
||||
Section "Install Handlers"
|
||||
DetailPrint "Registering .livemd File Handler"
|
||||
DeleteRegKey HKCR ".livemd"
|
||||
WriteRegStr HKCR ".livemd" "" "Livebook.LiveMarkdown"
|
||||
DeleteRegKey HKCR "Livebook.LiveMarkdown"
|
||||
WriteRegStr HKCR "Livebook.LiveMarkdown" "" "LiveMarkdown"
|
||||
WriteRegStr HKCR "Livebook.LiveMarkdown\DefaultIcon" "" "$INSTDIR\Livebook.exe,1"
|
||||
WriteRegStr HKCR "Livebook.LiveMarkdown\shell\open\command" "" '"$INSTDIR\Livebook.exe" "open:%1"'
|
||||
|
||||
DetailPrint "Registering livebook URL Handler"
|
||||
DeleteRegKey HKCR "livebook"
|
||||
WriteRegStr HKCR "livebook" "" "Livebook URL Protocol"
|
||||
WriteRegStr HKCR "livebook" "URL Protocol" ""
|
||||
WriteRegStr HKCR "livebook\shell" "" ""
|
||||
WriteRegStr HKCR "livebook\shell\open" "" ""
|
||||
WriteRegStr HKCR "livebook\shell\open\command" "" '"$INSTDIR\Livebook.exe" "open:%1"'
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
DeleteRegKey HKCR ".livemd"
|
||||
DeleteRegKey HKCR "Livebook.LiveMarkdown"
|
||||
DeleteRegKey HKCR "livebook"
|
||||
|
||||
DetailPrint "Terminating Livebook..."
|
||||
ExecWait "taskkill /f /t /im Livebook.exe"
|
||||
ExecWait "taskkill /f /t /im epmd.exe"
|
||||
Sleep 1000
|
||||
|
||||
Delete "$DESKTOP\Livebook.lnk"
|
||||
RMDir /r "$INSTDIR"
|
||||
SectionEnd
|
118
rel/app/windows/Livebook.cs
Normal file
118
rel/app/windows/Livebook.cs
Normal file
|
@ -0,0 +1,118 @@
|
|||
using System;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Windows.Forms;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Livebook;
|
||||
|
||||
static class LivebookMain
|
||||
{
|
||||
[STAThread]
|
||||
static void Main(string[] args)
|
||||
{
|
||||
var prefix = "open:";
|
||||
var url = "";
|
||||
|
||||
if (args.Length == 1 && args[0].StartsWith(prefix))
|
||||
{
|
||||
var uri = new System.Uri(args[0].Remove(0, prefix.Length));
|
||||
url = uri.AbsoluteUri;
|
||||
}
|
||||
|
||||
if (ElixirKit.API.IsMainInstance("dev.livebook.Livebook"))
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new LivebookApp(url));
|
||||
|
||||
var code = ElixirKit.API.Stop();
|
||||
if (code == 0) { return; }
|
||||
var message = $"Livebook exited with exit code {code}.\r\nLogs available at: {getLogPath()}";
|
||||
MessageBox.Show(new Form() { TopMost = true }, message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
ElixirKit.API.Publish("open", url);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string getLogPath()
|
||||
{
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Livebook",
|
||||
"Logs",
|
||||
"Livebook.log"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DummyForm : Form {}
|
||||
|
||||
class LivebookApp : ApplicationContext
|
||||
{
|
||||
private NotifyIcon notifyIcon;
|
||||
|
||||
public LivebookApp(string url)
|
||||
{
|
||||
ThreadExit += threadExit;
|
||||
|
||||
ContextMenuStrip menu = new ContextMenuStrip();
|
||||
menu.Items.Add("Open", null, openClicked);
|
||||
menu.Items.Add("Quit", null, quitClicked);
|
||||
notifyIcon = new NotifyIcon()
|
||||
{
|
||||
Text = "Livebook",
|
||||
Visible = true,
|
||||
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath)!,
|
||||
ContextMenuStrip = menu
|
||||
};
|
||||
notifyIcon.Click += notifyIconClicked;
|
||||
|
||||
var logPath = LivebookMain.getLogPath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(logPath)!);
|
||||
|
||||
ElixirKit.API.Start(
|
||||
name: "app",
|
||||
logPath: logPath,
|
||||
exited: (exitCode) =>
|
||||
{
|
||||
Application.Exit();
|
||||
}
|
||||
);
|
||||
|
||||
ElixirKit.API.Publish("open", url);
|
||||
}
|
||||
|
||||
private void threadExit(object? sender, EventArgs e)
|
||||
{
|
||||
notifyIcon.Visible = false;
|
||||
}
|
||||
|
||||
private void notifyIconClicked(object? sender, EventArgs e)
|
||||
{
|
||||
MouseEventArgs mouseEventArgs = (MouseEventArgs)e;
|
||||
|
||||
if (mouseEventArgs.Button == MouseButtons.Left)
|
||||
{
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
private void openClicked(object? sender, EventArgs e)
|
||||
{
|
||||
open();
|
||||
}
|
||||
|
||||
private void quitClicked(object? sender, EventArgs e)
|
||||
{
|
||||
notifyIcon.Visible = false;
|
||||
Application.Exit();
|
||||
}
|
||||
|
||||
private void open() {
|
||||
ElixirKit.API.Publish("open", "");
|
||||
}
|
||||
}
|
23
rel/app/windows/Livebook.csproj
Normal file
23
rel/app/windows/Livebook.csproj
Normal file
|
@ -0,0 +1,23 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType Condition="'$(Configuration)' == 'Debug'">Exe</OutputType>
|
||||
<OutputType Condition="'$(Configuration)' == 'Release'">WinExe</OutputType>
|
||||
<OutDir>bin/Livebook-$(Configuration)</OutDir>
|
||||
<PublishDir>bin/Livebook-$(Configuration)</PublishDir>
|
||||
<TargetFramework>net4.6-windows</TargetFramework>
|
||||
<LangVersion>10.0</LangVersion>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
|
||||
<SelfContained>false</SelfContained>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<ApplicationIcon>Resources/AppIcon.ico</ApplicationIcon>
|
||||
<ApplicationManifest>App.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../elixirkit/elixirkit_dotnet/ElixirKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
5
rel/app/windows/README.md
Normal file
5
rel/app/windows/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Livebook for Windows
|
||||
|
||||
Run the app:
|
||||
|
||||
$ ./run.sh
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue