Add the Fyne desktop app (M5)
A cross-platform desktop UI over the same client service: status, routes with add/remove/copy/open, live logs, settings, official-profile import, and start/stop. It uses the existing JSON config and offers to stop a running CLI daemon so the two do not fight over routes. The app can autostart the service, refreshes status/config every few seconds, and builds as a macOS .app via scripts/build-desktop-app.sh.
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
/tunneld
|
/tunneld
|
||||||
|
/tunnel
|
||||||
|
/tunnel-desktop
|
||||||
|
/Tunnel.app
|
||||||
cmd/tunneld/tunneld
|
cmd/tunneld/tunneld
|
||||||
|
cmd/tunnel/tunnel
|
||||||
|
cmd/tunnel-desktop/tunnel-desktop
|
||||||
.local/
|
.local/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ what we take vs. skip). Never vendor it into the build.
|
|||||||
- Desktop app: `go build -o .local/tunnel-desktop ./cmd/tunnel-desktop` (Fyne;
|
- Desktop app: `go build -o .local/tunnel-desktop ./cmd/tunnel-desktop` (Fyne;
|
||||||
needs CGO). It runs the same `internal/client` service in-process and uses
|
needs CGO). It runs the same `internal/client` service in-process and uses
|
||||||
`~/.config/tunnel/config.json`. If the CLI service is already running, stop
|
`~/.config/tunnel/config.json`. If the CLI service is already running, stop
|
||||||
it first — the app shows a banner and offers to stop it.
|
it first — the app shows a banner and offers to stop it. `--autostart`
|
||||||
|
connects on launch. On macOS, `scripts/build-desktop-app.sh <version>` builds
|
||||||
|
and ad-hoc signs `Tunnel.app`; the installed copy is
|
||||||
|
`~/Applications/Tunnel.app`. Cross-OS builds need that OS's CGO toolchain;
|
||||||
|
`CGO_ENABLED=0` does not work for Fyne.
|
||||||
- Dev loop: `go build -o .local/tunneld ./cmd/tunneld && .local/tunneld -domain localhost`,
|
- Dev loop: `go build -o .local/tunneld ./cmd/tunneld && .local/tunneld -domain localhost`,
|
||||||
then `OPENTUNNEL_API=http://localhost:8080 opentunnel route add 4096 --name api`
|
then `OPENTUNNEL_API=http://localhost:8080 opentunnel route add 4096 --name api`
|
||||||
with the official CLI. `go build` drops a binary next to the source — it is
|
with the official CLI. `go build` drops a binary next to the source — it is
|
||||||
|
|||||||
@@ -98,7 +98,16 @@ client service:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o .local/tunnel-desktop ./cmd/tunnel-desktop
|
go build -o .local/tunnel-desktop ./cmd/tunnel-desktop
|
||||||
.local/tunnel-desktop
|
.local/tunnel-desktop # click Start
|
||||||
|
.local/tunnel-desktop --autostart
|
||||||
|
```
|
||||||
|
|
||||||
|
On macOS, build and ad-hoc sign a launchable bundle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-desktop-app.sh 0.2.0
|
||||||
|
ditto Tunnel.app "$HOME/Applications/Tunnel.app"
|
||||||
|
open "$HOME/Applications/Tunnel.app" --args --autostart
|
||||||
```
|
```
|
||||||
|
|
||||||
It uses `~/.config/tunnel/config.json` like the CLI, shows tunnel status and
|
It uses `~/.config/tunnel/config.json` like the CLI, shows tunnel status and
|
||||||
@@ -107,6 +116,11 @@ and profile import, and has a start/stop button that runs the service
|
|||||||
in-process. If the CLI service is already running, a banner offers to stop it
|
in-process. If the CLI service is already running, a banner offers to stop it
|
||||||
first.
|
first.
|
||||||
|
|
||||||
|
Fyne needs CGO: the macOS build is tested; Linux and Windows must be built on
|
||||||
|
those platforms (or with a native CGO cross toolchain), not with
|
||||||
|
`CGO_ENABLED=0`. Distribution of the macOS app still needs Developer ID signing
|
||||||
|
and notarization (the local script only ad-hoc signs).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Every flag also reads an environment variable (`tunneld -h`):
|
Every flag also reads an environment variable (`tunneld -h`):
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -1,11 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
|
|
||||||
"gitea.tzgyn.dev/TZGyn/tunnel/internal/desktop"
|
"gitea.tzgyn.dev/TZGyn/tunnel/internal/desktop"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
desktop.Run(version)
|
autostart := flag.Bool("autostart", false, "start the tunnel service when the app launches")
|
||||||
|
flag.Parse()
|
||||||
|
desktop.Run(version, *autostart)
|
||||||
}
|
}
|
||||||
|
|||||||
+99
-60
@@ -17,7 +17,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"time"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/app"
|
"fyne.io/fyne/v2/app"
|
||||||
@@ -63,13 +63,13 @@ type App struct {
|
|||||||
certLabel *widget.Label
|
certLabel *widget.Label
|
||||||
configLabel *widget.Label
|
configLabel *widget.Label
|
||||||
startButton *widget.Button
|
startButton *widget.Button
|
||||||
routeList *widget.List
|
routeBox *fyne.Container
|
||||||
logList *widget.List
|
logText *widget.TextGrid
|
||||||
banner fyne.CanvasObject
|
banner fyne.CanvasObject
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run creates and runs the desktop app.
|
// Run creates and runs the desktop app.
|
||||||
func Run(version string) {
|
func Run(version string, autostart bool) {
|
||||||
a := &App{version: version, configPath: client.DefaultPath()}
|
a := &App{version: version, configPath: client.DefaultPath()}
|
||||||
a.paths = client.ConfigPaths(a.configPath)
|
a.paths = client.ConfigPaths(a.configPath)
|
||||||
|
|
||||||
@@ -83,7 +83,18 @@ func Run(version string) {
|
|||||||
a.readExistingLog()
|
a.readExistingLog()
|
||||||
|
|
||||||
a.window.SetOnClosed(func() { a.stop() })
|
a.window.SetOnClosed(func() { a.stop() })
|
||||||
a.window.ShowAndRun()
|
a.window.Show()
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
fyne.Do(a.refresh)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if autostart {
|
||||||
|
a.start()
|
||||||
|
}
|
||||||
|
a.app.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) loadService() {
|
func (a *App) loadService() {
|
||||||
@@ -141,15 +152,16 @@ func (a *App) buildUI() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
bannerLabel := widget.NewLabel("The `tunnel` CLI service is running and claims these routes. Stop it to use the app.")
|
bannerLabel := widget.NewLabel("The tunnel CLI service is running and claims these routes. Stop it to use the app.")
|
||||||
bannerLabel.Wrapping = fyne.TextWrapWord
|
bannerLabel.Wrapping = fyne.TextWrapWord
|
||||||
|
bannerLabel.Importance = widget.WarningImportance
|
||||||
stopCLI := widget.NewButtonWithIcon("Stop CLI", theme.MediaStopIcon(), func() {
|
stopCLI := widget.NewButtonWithIcon("Stop CLI", theme.MediaStopIcon(), func() {
|
||||||
if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) {
|
if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) {
|
||||||
_ = syscall.Kill(pid, syscall.SIGTERM)
|
terminateProcess(pid)
|
||||||
}
|
}
|
||||||
a.banner.Hide()
|
a.banner.Hide()
|
||||||
})
|
})
|
||||||
a.banner = container.NewHBox(bannerLabel, stopCLI)
|
a.banner = container.NewBorder(nil, nil, nil, stopCLI, bannerLabel)
|
||||||
a.banner.Hide()
|
a.banner.Hide()
|
||||||
|
|
||||||
routeHeader := container.NewHBox(
|
routeHeader := container.NewHBox(
|
||||||
@@ -157,42 +169,11 @@ func (a *App) buildUI() {
|
|||||||
layout.NewSpacer(),
|
layout.NewSpacer(),
|
||||||
widget.NewButtonWithIcon("Add route", theme.ContentAddIcon(), a.addRouteDialog),
|
widget.NewButtonWithIcon("Add route", theme.ContentAddIcon(), a.addRouteDialog),
|
||||||
)
|
)
|
||||||
a.routeList = widget.NewList(
|
a.routeBox = container.NewVBox()
|
||||||
func() int { return len(a.routeNames()) },
|
a.logText = widget.NewTextGrid()
|
||||||
func() fyne.CanvasObject {
|
logScroll := container.NewScroll(a.logText)
|
||||||
return container.NewHBox(
|
logScroll.SetMinSize(fyne.NewSize(0, 220))
|
||||||
widget.NewLabel("name"),
|
a.logWriter.onAppend = func() { fyne.Do(a.refreshLog) }
|
||||||
widget.NewLabel("target"),
|
|
||||||
widget.NewHyperlink("url", nil),
|
|
||||||
layout.NewSpacer(),
|
|
||||||
widget.NewButtonWithIcon("", theme.ContentCopyIcon(), nil),
|
|
||||||
widget.NewButtonWithIcon("", theme.DeleteIcon(), nil),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
func(i widget.ListItemID, item fyne.CanvasObject) {
|
|
||||||
names := a.routeNames()
|
|
||||||
name := names[i]
|
|
||||||
route := a.cfg.Routes[name]
|
|
||||||
linkURL := a.cfg.RouteURL(name)
|
|
||||||
box := item.(*fyne.Container)
|
|
||||||
box.Objects[0].(*widget.Label).SetText(name)
|
|
||||||
box.Objects[1].(*widget.Label).SetText(route.Target)
|
|
||||||
link := box.Objects[2].(*widget.Hyperlink)
|
|
||||||
link.SetText(linkURL)
|
|
||||||
if parsed, err := url.Parse(linkURL); err == nil {
|
|
||||||
link.SetURL(parsed)
|
|
||||||
}
|
|
||||||
box.Objects[4].(*widget.Button).OnTapped = func() { a.app.Clipboard().SetContent(linkURL) }
|
|
||||||
box.Objects[5].(*widget.Button).OnTapped = func() { a.removeRoute(name) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
a.logList = widget.NewList(
|
|
||||||
func() int { return a.logWriter.count() },
|
|
||||||
func() fyne.CanvasObject { return widget.NewLabel("") },
|
|
||||||
func(i widget.ListItemID, item fyne.CanvasObject) { item.(*widget.Label).SetText(a.logWriter.line(i)) },
|
|
||||||
)
|
|
||||||
a.logWriter.onAppend = func() { fyne.Do(a.logList.Refresh) }
|
|
||||||
|
|
||||||
content := container.NewVBox(
|
content := container.NewVBox(
|
||||||
container.NewHBox(
|
container.NewHBox(
|
||||||
@@ -208,9 +189,9 @@ func (a *App) buildUI() {
|
|||||||
statusCard,
|
statusCard,
|
||||||
a.banner,
|
a.banner,
|
||||||
routeHeader,
|
routeHeader,
|
||||||
a.routeList,
|
a.routeBox,
|
||||||
widget.NewLabelWithStyle("Log", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
widget.NewLabelWithStyle("Log", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
a.logList,
|
logScroll,
|
||||||
)
|
)
|
||||||
a.window.SetContent(container.NewPadded(container.NewScroll(content)))
|
a.window.SetContent(container.NewPadded(container.NewScroll(content)))
|
||||||
}
|
}
|
||||||
@@ -235,20 +216,64 @@ func (a *App) refresh() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.cfg = cfg
|
a.cfg = cfg
|
||||||
a.svc.SetConfig(cfg)
|
if a.svc != nil {
|
||||||
|
a.svc.SetConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
a.apiLabel.SetText(valueOr(cfg.API, "(not set)"))
|
a.apiLabel.SetText(valueOr(cfg.API, "(not set)"))
|
||||||
a.tunnelLabel.SetText(valueOr(cfg.Hostname, "(no tunnel yet)"))
|
a.tunnelLabel.SetText(valueOr(cfg.Hostname, "(no tunnel yet)"))
|
||||||
a.certLabel.SetText(valueOr(cfg.CertificateExpiry, "(no certificate)"))
|
a.certLabel.SetText(formatCert(cfg.CertificateExpiry))
|
||||||
if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) {
|
if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) {
|
||||||
a.banner.Show()
|
a.banner.Show()
|
||||||
} else {
|
} else {
|
||||||
a.banner.Hide()
|
a.banner.Hide()
|
||||||
}
|
}
|
||||||
a.routeList.Refresh()
|
a.refreshRoutes()
|
||||||
a.updateStatus()
|
a.updateStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) refreshRoutes() {
|
||||||
|
a.routeBox.Objects = nil
|
||||||
|
for _, name := range a.routeNames() {
|
||||||
|
route := a.cfg.Routes[name]
|
||||||
|
linkURL := a.cfg.RouteURL(name)
|
||||||
|
link := widget.NewHyperlink(linkURL, nil)
|
||||||
|
if parsed, err := url.Parse(linkURL); err == nil {
|
||||||
|
link.SetURL(parsed)
|
||||||
|
}
|
||||||
|
copyButton := widget.NewButtonWithIcon("", theme.ContentCopyIcon(), func() {
|
||||||
|
a.app.Clipboard().SetContent(linkURL)
|
||||||
|
})
|
||||||
|
deleteButton := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() {
|
||||||
|
a.removeRoute(name)
|
||||||
|
})
|
||||||
|
row := container.NewHBox(
|
||||||
|
container.NewGridWrap(fyne.NewSize(130, 32), widget.NewLabel(name)),
|
||||||
|
container.NewGridWrap(fyne.NewSize(170, 32), widget.NewLabel(route.Target)),
|
||||||
|
link,
|
||||||
|
layout.NewSpacer(),
|
||||||
|
copyButton,
|
||||||
|
deleteButton,
|
||||||
|
)
|
||||||
|
a.routeBox.Add(row)
|
||||||
|
}
|
||||||
|
a.routeBox.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) refreshLog() {
|
||||||
|
a.logText.SetText(a.logWriter.text(300))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCert(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return "(no certificate)"
|
||||||
|
}
|
||||||
|
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
||||||
|
return parsed.Local().Format("2006-01-02 15:04 MST")
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) updateStatus() {
|
func (a *App) updateStatus() {
|
||||||
status := a.svc.CurrentStatus()
|
status := a.svc.CurrentStatus()
|
||||||
running := a.isRunning()
|
running := a.isRunning()
|
||||||
@@ -280,7 +305,8 @@ func (a *App) updateStatus() {
|
|||||||
a.lastError.Hide()
|
a.lastError.Hide()
|
||||||
}
|
}
|
||||||
a.dot.Refresh()
|
a.dot.Refresh()
|
||||||
a.routeList.Refresh()
|
a.startButton.Enable()
|
||||||
|
a.refreshRoutes()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) isRunning() bool {
|
func (a *App) isRunning() bool {
|
||||||
@@ -312,7 +338,6 @@ func (a *App) start() {
|
|||||||
svc := a.svc
|
svc := a.svc
|
||||||
a.runMu.Unlock()
|
a.runMu.Unlock()
|
||||||
|
|
||||||
a.startButton.Disable()
|
|
||||||
a.stateLabel.SetText("Provisioning…")
|
a.stateLabel.SetText("Provisioning…")
|
||||||
a.dot.Refresh()
|
a.dot.Refresh()
|
||||||
go func() {
|
go func() {
|
||||||
@@ -476,9 +501,13 @@ func (a *App) readExistingLog() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
onAppend := a.logWriter.onAppend
|
||||||
|
a.logWriter.onAppend = nil
|
||||||
for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
|
for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
|
||||||
a.logWriter.append(line)
|
a.logWriter.remember(line)
|
||||||
}
|
}
|
||||||
|
a.logWriter.onAppend = onAppend
|
||||||
|
a.refreshLog()
|
||||||
}
|
}
|
||||||
|
|
||||||
func randomRouteName() string {
|
func randomRouteName() string {
|
||||||
@@ -502,11 +531,6 @@ func readCLIpid(path string) int {
|
|||||||
return pid
|
return pid
|
||||||
}
|
}
|
||||||
|
|
||||||
func processAlive(pid int) bool {
|
|
||||||
err := syscall.Kill(pid, 0)
|
|
||||||
return err == nil || errors.Is(err, syscall.EPERM)
|
|
||||||
}
|
|
||||||
|
|
||||||
// uiLogWriter mirrors service logs into a bounded in-memory list and a file.
|
// uiLogWriter mirrors service logs into a bounded in-memory list and a file.
|
||||||
type uiLogWriter struct {
|
type uiLogWriter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -532,6 +556,14 @@ func (w *uiLogWriter) Write(p []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *uiLogWriter) append(line string) {
|
func (w *uiLogWriter) append(line string) {
|
||||||
|
w.remember(line)
|
||||||
|
if w.file != nil {
|
||||||
|
_, _ = io.WriteString(w.file, line+"\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remember adds a line to the in-memory list without writing it to the file.
|
||||||
|
func (w *uiLogWriter) remember(line string) {
|
||||||
if line == "" {
|
if line == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -542,9 +574,6 @@ func (w *uiLogWriter) append(line string) {
|
|||||||
}
|
}
|
||||||
onAppend := w.onAppend
|
onAppend := w.onAppend
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
if w.file != nil {
|
|
||||||
_, _ = io.WriteString(w.file, line+"\n")
|
|
||||||
}
|
|
||||||
if onAppend != nil {
|
if onAppend != nil {
|
||||||
onAppend()
|
onAppend()
|
||||||
}
|
}
|
||||||
@@ -565,6 +594,16 @@ func (w *uiLogWriter) line(i int) string {
|
|||||||
return w.lines[i]
|
return w.lines[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *uiLogWriter) text(limit int) string {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
lines := w.lines
|
||||||
|
if len(lines) > limit {
|
||||||
|
lines = lines[len(lines)-limit:]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
func valueOr(value, fallback string) string {
|
func valueOr(value, fallback string) string {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return fallback
|
return fallback
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func processAlive(pid int) bool {
|
||||||
|
err := syscall.Kill(pid, 0)
|
||||||
|
return err == nil || errors.Is(err, syscall.EPERM)
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminateProcess(pid int) {
|
||||||
|
_ = syscall.Kill(pid, syscall.SIGTERM)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package desktop
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// The CLI background daemon is not supported on Windows; the desktop app
|
||||||
|
// runs the service in-process, so there is no CLI pid to show or stop.
|
||||||
|
func processAlive(int) bool { return false }
|
||||||
|
|
||||||
|
func terminateProcess(pid int) {
|
||||||
|
if process, err := os.FindProcess(pid); err == nil {
|
||||||
|
_ = process.Kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build and ad-hoc sign a local macOS Tunnel.app.
|
||||||
|
# Distribution releases still need Developer ID signing + notarization.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
VERSION="${1:-0.2.0}"
|
||||||
|
BUILD="${BUILD:-1}"
|
||||||
|
FYNE="$(command -v fyne || true)"
|
||||||
|
if [ -z "$FYNE" ]; then
|
||||||
|
FYNE="$(go env GOPATH)/bin/fyne"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$FYNE" ]; then
|
||||||
|
echo "installing fyne CLI..."
|
||||||
|
go install fyne.io/tools/cmd/fyne@latest
|
||||||
|
FYNE="$(go env GOPATH)/bin/fyne"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p .local
|
||||||
|
go build -o .local/Tunnel -ldflags "-X main.version=$VERSION" ./cmd/tunnel-desktop
|
||||||
|
rm -rf Tunnel.app
|
||||||
|
"$FYNE" package -os darwin \
|
||||||
|
-executable .local/Tunnel \
|
||||||
|
-name Tunnel -app-id dev.tzgyn.tunnel \
|
||||||
|
-app-version "$VERSION" -app-build "$BUILD" \
|
||||||
|
-icon assets/icon.png -release
|
||||||
|
|
||||||
|
codesign --force --deep --sign - Tunnel.app
|
||||||
|
echo "Built and ad-hoc signed Tunnel.app (version $VERSION)."
|
||||||
|
echo "Install locally with:"
|
||||||
|
echo " rm -rf \"\$HOME/Applications/Tunnel.app\" && ditto Tunnel.app \"\$HOME/Applications/Tunnel.app\""
|
||||||
Reference in New Issue
Block a user