mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 10:05:57 +08:00
Desktop app packaging for Windows (#1032)
This commit is contained in:
parent
59e4713008
commit
29f0f54bbd
1
.github/scripts/app/build_mac.sh
vendored
1
.github/scripts/app/build_mac.sh
vendored
|
@ -1,4 +1,5 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# $ sh .github/scripts/app/build_mac.sh
|
||||
|
|
11
.github/scripts/app/build_windows.sh
vendored
Normal file
11
.github/scripts/app/build_windows.sh
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# $ sh .github/scripts/app/build_windows.sh
|
||||
# $ _build/app_prod/rel/LivebookInstall.exe
|
||||
# $ start livebook://github.com/livebook-dev/livebook/blob/main/test/support/notebooks/basic.livemd
|
||||
# $ start ./test/support/notebooks/basic.livemd
|
||||
set -e
|
||||
|
||||
MIX_ENV=prod MIX_TARGET=app mix release windows_installer --overwrite
|
|
@ -35,21 +35,39 @@ defmodule WxDemo.Window do
|
|||
}
|
||||
end
|
||||
|
||||
def windows_connected(url) do
|
||||
url
|
||||
|> String.trim()
|
||||
|> String.trim_leading("\"")
|
||||
|> String.trim_trailing("\"")
|
||||
|> windows_to_wx()
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
title = "WxDemo"
|
||||
|
||||
true = Process.register(self(), __MODULE__)
|
||||
os = os()
|
||||
wx = :wx.new()
|
||||
frame = :wxFrame.new(wx, -1, title)
|
||||
frame = :wxFrame.new(wx, -1, title, size: {100, 100})
|
||||
|
||||
if macOS?() do
|
||||
if os == :macos do
|
||||
fixup_macos_menubar(frame, title)
|
||||
end
|
||||
|
||||
:wxFrame.show(frame)
|
||||
:wxFrame.connect(frame, :command_menu_selected)
|
||||
:wxFrame.connect(frame, :close_window, skip: true)
|
||||
:wx.subscribe_events()
|
||||
|
||||
case os do
|
||||
:macos ->
|
||||
:wx.subscribe_events()
|
||||
|
||||
:windows ->
|
||||
windows_to_wx(System.get_env("WXDEMO_URL") || "")
|
||||
end
|
||||
|
||||
state = %{frame: frame}
|
||||
{frame, state}
|
||||
end
|
||||
|
@ -67,17 +85,16 @@ defmodule WxDemo.Window do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:open_url, url}, state) do
|
||||
:wxMessageDialog.new(state.frame, inspect(url))
|
||||
|> :wxDialog.showModal()
|
||||
|
||||
def handle_info(event, state) do
|
||||
show_dialog(state, inspect(event))
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
# ignore other events
|
||||
def handle_info(_event, state) do
|
||||
{:noreply, state}
|
||||
# Helpers
|
||||
|
||||
defp show_dialog(state, data) do
|
||||
:wxMessageDialog.new(state.frame, data)
|
||||
|> :wxDialog.showModal()
|
||||
end
|
||||
|
||||
defp fixup_macos_menubar(frame, title) do
|
||||
|
@ -98,7 +115,22 @@ defmodule WxDemo.Window do
|
|||
end
|
||||
end
|
||||
|
||||
defp macOS?() do
|
||||
:os.type() == {:unix, :darwin}
|
||||
defp os() do
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> :macos
|
||||
{:win32, _} -> :windows
|
||||
end
|
||||
end
|
||||
|
||||
defp windows_to_wx("") do
|
||||
send(__MODULE__, {:new_file, ''})
|
||||
end
|
||||
|
||||
defp windows_to_wx("wxdemo://" <> _ = url) do
|
||||
send(__MODULE__, {:open_url, String.to_charlist(url)})
|
||||
end
|
||||
|
||||
defp windows_to_wx(path) do
|
||||
send(__MODULE__, {:open_file, String.to_charlist(path)})
|
||||
end
|
||||
end
|
||||
|
|
|
@ -28,7 +28,15 @@ defmodule WxDemo.MixProject do
|
|||
defp releases do
|
||||
options = [
|
||||
name: "WxDemo",
|
||||
url_schemes: ["wxdemo"]
|
||||
url_schemes: ["wxdemo"],
|
||||
document_types: [
|
||||
%{
|
||||
name: "WxDemo",
|
||||
extensions: ["wxdemo"],
|
||||
# macos specific
|
||||
role: "Editor"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
[
|
||||
|
@ -39,6 +47,13 @@ defmodule WxDemo.MixProject do
|
|||
mac_app_dmg: [
|
||||
include_executables_for: [:unix],
|
||||
steps: [:assemble, &build_mac_app_dmg(&1, options)]
|
||||
],
|
||||
windows_installer: [
|
||||
include_executables_for: [:windows],
|
||||
steps: [
|
||||
:assemble,
|
||||
&AppBuilder.build_windows_installer(&1, [module: WxDemo.Window] ++ options)
|
||||
]
|
||||
]
|
||||
]
|
||||
end
|
||||
|
|
|
@ -2,4 +2,6 @@ defmodule AppBuilder do
|
|||
defdelegate build_mac_app(release, options), to: AppBuilder.MacOS
|
||||
|
||||
defdelegate build_mac_app_dmg(release, options), to: AppBuilder.MacOS
|
||||
|
||||
defdelegate build_windows_installer(release, options), to: AppBuilder.Windows
|
||||
end
|
||||
|
|
|
@ -134,9 +134,7 @@ defmodule AppBuilder.MacOS do
|
|||
end
|
||||
|
||||
defp launcher(additional_paths) do
|
||||
additional_paths = additional_paths
|
||||
|> Enum.map(&("\\(resourcePath)#{&1}"))
|
||||
|> Enum.join(":")
|
||||
additional_paths = Enum.map_join(additional_paths, ":", &"\\(resourcePath)#{&1}")
|
||||
|
||||
"""
|
||||
import Foundation
|
||||
|
|
202
app_builder/lib/app_builder/windows.ex
Normal file
202
app_builder/lib/app_builder/windows.ex
Normal file
|
@ -0,0 +1,202 @@
|
|||
defmodule AppBuilder.Windows do
|
||||
@moduledoc false
|
||||
|
||||
import AppBuilder.Utils
|
||||
require EEx
|
||||
|
||||
@doc """
|
||||
Creates a Windows installer.
|
||||
"""
|
||||
def build_windows_installer(release, options) do
|
||||
tmp_dir = release.path <> "_tmp"
|
||||
File.rm_rf(tmp_dir)
|
||||
File.mkdir_p!(tmp_dir)
|
||||
|
||||
File.cp_r!(release.path, Path.join(tmp_dir, "rel"))
|
||||
|
||||
options =
|
||||
Keyword.validate!(options, [
|
||||
:name,
|
||||
:version,
|
||||
:url_schemes,
|
||||
:document_types,
|
||||
:logo_path,
|
||||
:module
|
||||
])
|
||||
|
||||
app_name = Keyword.fetch!(options, :name)
|
||||
|
||||
logo_path = options[:logo_path] || Application.app_dir(:wx, "examples/demo/erlang.png")
|
||||
app_icon_path = Path.join(tmp_dir, "app_icon.ico")
|
||||
copy_image(logo_path, app_icon_path)
|
||||
|
||||
erts_dir = Path.join([tmp_dir, "rel", "erts-#{:erlang.system_info(:version)}"])
|
||||
rcedit_path = Path.join(Mix.Project.build_path(), "rcedit")
|
||||
ensure_rcedit(rcedit_path)
|
||||
cmd!(rcedit_path, ["--set-icon", app_icon_path, Path.join([erts_dir, "bin", "erl.exe"])])
|
||||
|
||||
File.write!(Path.join(tmp_dir, "#{app_name}.vbs"), launcher_vbs(release, options))
|
||||
nsi_path = Path.join(tmp_dir, "#{app_name}.nsi")
|
||||
File.write!(nsi_path, nsi(options))
|
||||
cmd!("makensis", [nsi_path])
|
||||
|
||||
File.rename!(
|
||||
Path.join(tmp_dir, "#{app_name}Install.exe"),
|
||||
Path.join([Mix.Project.build_path(), "rel", "#{app_name}Install.exe"])
|
||||
)
|
||||
|
||||
release
|
||||
end
|
||||
|
||||
code = """
|
||||
<%
|
||||
app_name = Keyword.fetch!(options, :name)
|
||||
url_schemes = Keyword.get(options, :url_schemes, [])
|
||||
%>
|
||||
!include "MUI2.nsh"
|
||||
|
||||
;--------------------------------
|
||||
;General
|
||||
|
||||
Name "<%= app_name %>"
|
||||
OutFile "<%= app_name %>Install.exe"
|
||||
Unicode True
|
||||
InstallDir "$LOCALAPPDATA\\<%= app_name %>"
|
||||
|
||||
; Need admin for registering URL scheme
|
||||
RequestExecutionLevel admin
|
||||
|
||||
;--------------------------------
|
||||
;Interface Settings
|
||||
|
||||
!define MUI_ABORTWARNING
|
||||
|
||||
;--------------------------------
|
||||
;Pages
|
||||
|
||||
;!insertmacro MUI_PAGE_COMPONENTS
|
||||
!define MUI_ICON "app_icon.ico"
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
|
||||
!insertmacro MUI_UNPAGE_CONFIRM
|
||||
!insertmacro MUI_UNPAGE_INSTFILES
|
||||
|
||||
;--------------------------------
|
||||
;Languages
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
|
||||
;--------------------------------
|
||||
;Installer Sections
|
||||
|
||||
Section "Install"
|
||||
SetOutPath "$INSTDIR"
|
||||
File /r rel rel
|
||||
File "<%= app_name %>.vbs"
|
||||
File "app_icon.ico"
|
||||
CreateDirectory "$INSTDIR\\Logs"
|
||||
WriteUninstaller "$INSTDIR\\<%= app_name %>Uninstall.exe"
|
||||
|
||||
<%= for type <- Keyword.get(options, :document_types, []) do %>
|
||||
<%= for ext <- type.extensions do %>
|
||||
WriteRegStr HKCR ".<%= ext %>" "" "<%= app_name %>.<%= type.name %>"
|
||||
<% end %>
|
||||
WriteRegStr HKCR "<%= app_name %>.<%= type.name %>" "" "<%= type.name %>"
|
||||
WriteRegStr HKCR "<%= app_name %>.<%= type.name %>\\DefaultIcon" "" "$INSTDIR\\app_icon.ico"
|
||||
WriteRegStr HKCR "<%= app_name %>.<%= type.name %>\\shell\\open\\command" "" '$WINDIR\\system32\\wscript.exe "$INSTDIR\\<%= app_name %>.vbs" "%1"'
|
||||
<% end %>
|
||||
|
||||
<%= for url_scheme <- url_schemes do %>
|
||||
DetailPrint "Register <%= url_scheme %> URL Handler"
|
||||
DeleteRegKey HKCR "<%= url_scheme %>"
|
||||
WriteRegStr HKCR "<%= url_scheme %>" "" "<%= url_scheme %> Protocol"
|
||||
WriteRegStr HKCR "<%= url_scheme %>" "URL Protocol" ""
|
||||
WriteRegStr HKCR "<%= url_scheme %>\\shell" "" ""
|
||||
WriteRegStr HKCR "<%= url_scheme %>\\shell\\open" "" ""
|
||||
WriteRegStr HKCR "<%= url_scheme %>\\shell\\open\\command" "" '$WINDIR\\system32\\wscript.exe "$INSTDIR\\<%= app_name %>.vbs" "%1"'
|
||||
<% end %>
|
||||
SectionEnd
|
||||
|
||||
Section "Desktop Shortcut"
|
||||
CreateShortCut "$DESKTOP\\<%= app_name %>.lnk" "$INSTDIR\\<%= app_name %>.vbs" "" "$INSTDIR\\app_icon.ico"
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
Delete "$DESKTOP\\<%= app_name %>.lnk"
|
||||
; TODO: stop epmd if it was started
|
||||
RMDir /r "$INSTDIR"
|
||||
SectionEnd
|
||||
"""
|
||||
|
||||
EEx.function_from_string(:defp, :nsi, code, [:options], trim: true)
|
||||
|
||||
code = ~S"""
|
||||
<%
|
||||
app_name = Keyword.fetch!(options, :name)
|
||||
module = Keyword.fetch!(options, :module)
|
||||
%>
|
||||
' This vbs script avoids a flashing cmd window when launching the release bat file
|
||||
|
||||
path = Left(Wscript.ScriptFullName, Len(Wscript.ScriptFullName) - Len(Wscript.ScriptName)) & "rel\bin\<%= release.name %>.bat"
|
||||
|
||||
If WScript.Arguments.Count > 0 Then
|
||||
url = WScript.Arguments(0)
|
||||
Else
|
||||
url = ""
|
||||
End If
|
||||
|
||||
Set shell = CreateObject("WScript.Shell")
|
||||
|
||||
' Below we run two commands:
|
||||
'
|
||||
' 1. bin/release rpc
|
||||
' 2. bin/release start
|
||||
'
|
||||
' The first one will only succeed when the app is already running. The second one when it is not.
|
||||
' It's ok for either to fail because we run them asynchronously.
|
||||
|
||||
Set env = shell.Environment("Process")
|
||||
env("PATH") = ".\rel\erts-<%= :erlang.system_info(:version) %>\bin;" & env("PATH")
|
||||
|
||||
' > bin/release rpc "mod.windows_connected(url)"
|
||||
'
|
||||
' We send the URL through IO, as opposed through the rpc expression, to avoid RCE.
|
||||
cmd = "echo """ & url & """ | """ & path & """ rpc <%= inspect(module) %>.windows_connected(IO.read(:line))"
|
||||
code = shell.Run("cmd /c " & cmd, 0)
|
||||
|
||||
' > bin/release start
|
||||
cmd = """" & path & """ start"
|
||||
env("<%= String.upcase(app_name) <> "_URL" %>") = url
|
||||
code = shell.Run("cmd /c " & cmd & " >> .\Logs\<%= app_name %>.log 2>&1", 0)
|
||||
"""
|
||||
|
||||
EEx.function_from_string(:defp, :launcher_vbs, code, [:release, :options], trim: true)
|
||||
|
||||
# TODO: Use https://github.com/elixir-desktop/libpe when fixed
|
||||
defp ensure_rcedit(path) do
|
||||
unless File.exists?(path) do
|
||||
url = "https://github.com/electron/rcedit/releases/download/v1.1.1/rcedit-x64.exe"
|
||||
cmd!("curl", ["-L", url, "-o", path])
|
||||
end
|
||||
end
|
||||
|
||||
defp copy_image(src_path, dest_path) do
|
||||
if Path.extname(src_path) == ".ico" do
|
||||
File.cp!(src_path, dest_path)
|
||||
else
|
||||
sizes = [16, 32, 48, 64, 128]
|
||||
|
||||
for i <- sizes do
|
||||
cmd!("magick", [src_path, "-resize", "#{i}x#{i}", sized_path(dest_path, i)])
|
||||
end
|
||||
|
||||
sized_paths = Enum.map(sizes, &sized_path(dest_path, &1))
|
||||
cmd!("magick", sized_paths ++ [dest_path])
|
||||
end
|
||||
end
|
||||
|
||||
defp sized_path(path, size) do
|
||||
String.replace_trailing(path, ".ico", ".#{size}.ico")
|
||||
end
|
||||
end
|
|
@ -310,7 +310,7 @@ defmodule Livebook.Utils do
|
|||
def browser_open(url) do
|
||||
{cmd, args} =
|
||||
case :os.type() do
|
||||
{:win32, _} -> {"cmd", ["/c", "start", url]}
|
||||
{:win32, _} -> {"cmd", ["/c", "start", String.replace(url, "&", "^&")]}
|
||||
{:unix, :darwin} -> {"open", [url]}
|
||||
{:unix, _} -> {"xdg-open", [url]}
|
||||
end
|
||||
|
|
|
@ -20,23 +20,44 @@ if Mix.target() == :app do
|
|||
}
|
||||
end
|
||||
|
||||
def windows_connected(url) do
|
||||
url
|
||||
|> String.trim()
|
||||
|> String.trim_leading("\"")
|
||||
|> String.trim_trailing("\"")
|
||||
|> windows_to_wx()
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
title = "Livebook"
|
||||
|
||||
wx = :wx.new()
|
||||
frame = :wxFrame.new(wx, -1, title, size: {0, 0})
|
||||
true = Process.register(self(), __MODULE__)
|
||||
os = os()
|
||||
|
||||
if macos?() do
|
||||
# TODO: on all platforms either add a basic window with some buttons OR a wxwebview
|
||||
size = if os == :macos, do: {0, 0}, else: {100, 100}
|
||||
|
||||
wx = :wx.new()
|
||||
frame = :wxFrame.new(wx, -1, title, size: size)
|
||||
|
||||
if os == :macos do
|
||||
fixup_macos_menubar(frame, title)
|
||||
end
|
||||
|
||||
:wxFrame.show(frame)
|
||||
:wxFrame.connect(frame, :command_menu_selected)
|
||||
:wxFrame.connect(frame, :close_window, skip: true)
|
||||
:wx.subscribe_events()
|
||||
state = %{frame: frame}
|
||||
|
||||
case os do
|
||||
:macos ->
|
||||
:wx.subscribe_events()
|
||||
|
||||
:windows ->
|
||||
windows_to_wx(System.get_env("LIVEBOOK_URL") || "")
|
||||
end
|
||||
|
||||
state = %{frame: frame}
|
||||
{frame, state}
|
||||
end
|
||||
|
||||
|
@ -111,8 +132,28 @@ if Mix.target() == :app do
|
|||
end
|
||||
end
|
||||
|
||||
defp macos?() do
|
||||
:os.type() == {:unix, :darwin}
|
||||
defp os() do
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> :macos
|
||||
{:win32, _} -> :windows
|
||||
end
|
||||
end
|
||||
|
||||
defp windows_to_wx("") do
|
||||
send(__MODULE__, {:new_file, ''})
|
||||
end
|
||||
|
||||
defp windows_to_wx("livebook://" <> _ = url) do
|
||||
send(__MODULE__, {:open_url, String.to_charlist(url)})
|
||||
end
|
||||
|
||||
defp windows_to_wx(path) do
|
||||
path =
|
||||
path
|
||||
|> String.replace("\\", "/")
|
||||
|> String.to_charlist()
|
||||
|
||||
send(__MODULE__, {:open_file, path})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
28
mix.exs
28
mix.exs
|
@ -144,6 +144,17 @@ defmodule Livebook.MixProject do
|
|||
include_erts: false,
|
||||
rel_templates_path: "rel/app",
|
||||
steps: [:assemble, &remove_cookie/1, &standalone_erlang_elixir/1, &build_mac_app_dmg/1]
|
||||
],
|
||||
windows_installer: [
|
||||
include_executables_for: [:windows],
|
||||
include_erts: false,
|
||||
rel_templates_path: "rel/app",
|
||||
steps: [
|
||||
:assemble,
|
||||
&remove_cookie/1,
|
||||
&standalone_erlang_elixir/1,
|
||||
&build_windows_installer/1
|
||||
]
|
||||
]
|
||||
]
|
||||
end
|
||||
|
@ -165,10 +176,15 @@ defmodule Livebook.MixProject do
|
|||
name: "Livebook",
|
||||
version: @version,
|
||||
logo_path: "rel/app/mac-icon.png",
|
||||
url_schemes: ["livebook"],
|
||||
additional_paths: ["/rel/vendor/bin", "/rel/vendor/elixir/bin"],
|
||||
url_schemes: ["livebook"],
|
||||
document_types: [
|
||||
%{name: "LiveMarkdown", role: "Editor", extensions: ["livemd"]}
|
||||
%{
|
||||
name: "LiveMarkdown",
|
||||
extensions: ["livemd"],
|
||||
# macos specific
|
||||
role: "Editor"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
|
@ -191,4 +207,12 @@ defmodule Livebook.MixProject do
|
|||
|
||||
AppBuilder.build_mac_app_dmg(release, options)
|
||||
end
|
||||
|
||||
defp build_windows_installer(release) do
|
||||
options =
|
||||
Keyword.take(@app_options, [:name, :version, :url_schemes, :document_types]) ++
|
||||
[module: LivebookApp, logo_path: "static/images/logo.png"]
|
||||
|
||||
AppBuilder.build_windows_installer(release, options)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -25,24 +25,26 @@ defmodule Standalone do
|
|||
_ = File.rm(Path.join(erts_destination_source, "erl"))
|
||||
_ = File.rm(Path.join(erts_destination_source, "erl.ini"))
|
||||
|
||||
erts_destination_source
|
||||
|> Path.join("erl")
|
||||
|> File.write!(~S"""
|
||||
#!/bin/sh
|
||||
SELF=$(readlink "$0" || true)
|
||||
if [ -z "$SELF" ]; then SELF="$0"; fi
|
||||
BINDIR="$(cd "$(dirname "$SELF")" && pwd -P)"
|
||||
ROOTDIR="${ERL_ROOTDIR:-"$(dirname "$(dirname "$BINDIR")")"}"
|
||||
EMU=beam
|
||||
PROGNAME=$(echo "$0" | sed 's/.*\///')
|
||||
export EMU
|
||||
export ROOTDIR
|
||||
export BINDIR
|
||||
export PROGNAME
|
||||
exec "$BINDIR/erlexec" ${1+"$@"}
|
||||
""")
|
||||
if os() == :macos do
|
||||
erts_destination_source
|
||||
|> Path.join("erl")
|
||||
|> File.write!(~S"""
|
||||
#!/bin/sh
|
||||
SELF=$(readlink "$0" || true)
|
||||
if [ -z "$SELF" ]; then SELF="$0"; fi
|
||||
BINDIR="$(cd "$(dirname "$SELF")" && pwd -P)"
|
||||
ROOTDIR="${ERL_ROOTDIR:-"$(dirname "$(dirname "$BINDIR")")"}"
|
||||
EMU=beam
|
||||
PROGNAME=$(echo "$0" | sed 's/.*\///')
|
||||
export EMU
|
||||
export ROOTDIR
|
||||
export BINDIR
|
||||
export PROGNAME
|
||||
exec "$BINDIR/erlexec" ${1+"$@"}
|
||||
""")
|
||||
|
||||
executable!(Path.join(erts_destination_source, "erl"))
|
||||
executable!(Path.join(erts_destination_source, "erl"))
|
||||
end
|
||||
|
||||
# Copy lib
|
||||
erts_destination_lib = Path.join(release.path, "lib")
|
||||
|
@ -93,8 +95,16 @@ defmodule Standalone do
|
|||
standalone_destination = Path.join(release.path, "vendor/elixir")
|
||||
download_elixir_at_destination(standalone_destination, elixir_version)
|
||||
|
||||
["elixir", "elixirc", "mix", "iex"]
|
||||
|> Enum.map(&executable!(Path.join(standalone_destination, "bin/#{&1}")))
|
||||
filenames =
|
||||
case os() do
|
||||
:macos ->
|
||||
["elixir", "elixirc", "mix", "iex"]
|
||||
|
||||
:windows ->
|
||||
["elixir.bat", "elixirc.bat", "mix.bat", "iex.bat"]
|
||||
end
|
||||
|
||||
Enum.map(filenames, &executable!(Path.join(standalone_destination, "bin/#{&1}")))
|
||||
|
||||
release
|
||||
end
|
||||
|
@ -131,4 +141,11 @@ defmodule Standalone do
|
|||
end
|
||||
|
||||
defp executable!(path), do: File.chmod!(path, 0o755)
|
||||
|
||||
defp os() do
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> :macos
|
||||
{:win32, _} -> :windows
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue