feat(launcher): SwiftUI menubar app
Context: AV-Live needs a single-click way to boot its two heavy components (the SuperCollider sound engine and the openFrameworks oscope-of visualizer) together, with their logs aggregated in one place. Running them by hand from terminal during a live set is fragile. Approach: Swift Package Manager + a small build.sh that wraps the release binary into a real .app bundle (Info.plist with LSUIElement=true to keep it out of the Dock). The UI uses NSStatusItem + NSPopover so it works back to macOS 11 (Big Sur) — MenuBarExtra would require 13+ which the current target MBP can't run. Changes: - launcher/Package.swift : SwiftPM manifest, single executable target, macOS 11 minimum - AVLiveLauncherApp.swift : @main App + AppDelegate that owns the status item, popover and lazily-created log window - ProcessManager.swift : ObservableObject spawning sclang and oscope-of via Foundation Process, capturing stdout/stderr through Pipe + readabilityHandler, ring-buffered log array (2000 lines), termination handlers that flip @Published flags back. Paths persisted in UserDefaults with sane defaults - MenuBarContent.swift : popover view with two ProcessRow toggles, Logs / Paths / Quit, Settings sheet using NSOpenPanel - LogView.swift : separate window with filter, auto-scroll, color-coded source column, monospaced font. #available guard for textSelection - Info.plist : bundle id cc.saillant.AVLiveLauncher, GPL-3 copyright, LSUIElement true, min macOS 11 - build.sh : swift build -c release, copies binary + Info.plist into AVLiveLauncher.app, ad-hoc codesigns - .gitignore + README Impact: 'cd launcher && ./build.sh && open build/AVLiveLauncher.app' gives a working .app that brings up the AV stack from the menubar. Smoke-tested on arm64 — Intel MBP needs a local build or a future universal -arch x86_64 flag in build.sh.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.build/
|
||||
build/
|
||||
.swiftpm/
|
||||
Package.resolved
|
||||
DerivedData/
|
||||
@@ -0,0 +1,13 @@
|
||||
// swift-tools-version:5.7
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "AVLiveLauncher",
|
||||
platforms: [.macOS(.v11)],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "AVLiveLauncher",
|
||||
path: "Sources/AVLiveLauncher"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
# AVLiveLauncher
|
||||
|
||||
macOS menubar launcher for AV-Live. Starts and stops `sclang` (which
|
||||
auto-loads `sound_algo/00_load.scd` and serves the web UI) and the
|
||||
`oscope-of` visualizer with one click each, and aggregates their logs in
|
||||
one window.
|
||||
|
||||
## Build
|
||||
|
||||
Requires Swift 5.7+ and Xcode Command Line Tools.
|
||||
|
||||
```sh
|
||||
cd launcher
|
||||
./build.sh
|
||||
open build/AVLiveLauncher.app
|
||||
```
|
||||
|
||||
The first run shows a `waveform.path.ecg` icon in the menubar. Click it
|
||||
to open the popover.
|
||||
|
||||
## Configuration
|
||||
|
||||
Three paths are stored in `UserDefaults` (`~/Library/Preferences/cc.saillant.AVLiveLauncher.plist`) :
|
||||
|
||||
| Default | Override |
|
||||
|---------|----------|
|
||||
| `/Applications/SuperCollider.app/Contents/MacOS/sclang` | Paths… → sclang binary |
|
||||
| `~/Documents/Projets/AV-Live/sound_algo/00_load.scd` | Paths… → load file |
|
||||
| `~/Documents/Projets/AV-Live/oscope-of/bin/oscope-of` | Paths… → oscope binary |
|
||||
|
||||
## What it does
|
||||
|
||||
- Spawns `sclang <load_file>` with stdout/stderr captured into the log
|
||||
window. `sclang` itself boots `scsynth`, loads the 1099-SynthDef
|
||||
palette, and serves the web bridge on `:8080`.
|
||||
- Spawns the `oscope-of` binary with cwd set to its parent directory
|
||||
(so oF can find `bin/data/`).
|
||||
- Quitting the menubar app sends `SIGTERM` to both children.
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>AVLiveLauncher</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>cc.saillant.AVLiveLauncher</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>AV-Live</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>AV-Live</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>GPL-3.0-or-later © L'Electron Rare</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,87 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MenuBar-only app : NSStatusItem + NSPopover. macOS 11+ compatible.
|
||||
// LSUIElement = true (set in Info.plist) hides the dock icon.
|
||||
|
||||
@main
|
||||
struct AVLiveLauncherApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
// Empty Settings scene so SwiftUI is happy. The real UI is the popover.
|
||||
Settings { EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var statusItem: NSStatusItem!
|
||||
private var popover: NSPopover!
|
||||
private var logWindow: NSWindow?
|
||||
private let processManager = ProcessManager()
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
// Status bar icon
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
if let button = statusItem.button {
|
||||
if #available(macOS 11.0, *) {
|
||||
button.image = NSImage(
|
||||
systemSymbolName: "waveform.path.ecg",
|
||||
accessibilityDescription: "AV-Live"
|
||||
)
|
||||
} else {
|
||||
button.title = "AV"
|
||||
}
|
||||
button.action = #selector(togglePopover(_:))
|
||||
button.target = self
|
||||
}
|
||||
|
||||
// Popover with the SwiftUI menu content
|
||||
popover = NSPopover()
|
||||
popover.contentSize = NSSize(width: 360, height: 320)
|
||||
popover.behavior = .transient
|
||||
popover.contentViewController = NSHostingController(
|
||||
rootView: MenuBarContent(
|
||||
processManager: processManager,
|
||||
openLogs: { [weak self] in self?.showLogs() }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
processManager.stopAll()
|
||||
}
|
||||
|
||||
@objc private func togglePopover(_ sender: Any?) {
|
||||
guard let button = statusItem.button else { return }
|
||||
if popover.isShown {
|
||||
popover.performClose(sender)
|
||||
} else {
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
popover.contentViewController?.view.window?.makeKey()
|
||||
}
|
||||
}
|
||||
|
||||
private func showLogs() {
|
||||
if let w = logWindow {
|
||||
w.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
let w = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 720, height: 480),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
w.title = "AV-Live Logs"
|
||||
w.center()
|
||||
w.contentViewController = NSHostingController(
|
||||
rootView: LogView(processManager: processManager)
|
||||
)
|
||||
w.isReleasedWhenClosed = false
|
||||
logWindow = w
|
||||
w.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LogView: View {
|
||||
@ObservedObject var processManager: ProcessManager
|
||||
@State private var filter: String = ""
|
||||
@State private var autoScroll = true
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "HH:mm:ss"
|
||||
return f
|
||||
}()
|
||||
|
||||
private var filtered: [LogLine] {
|
||||
guard !filter.isEmpty else { return processManager.logs }
|
||||
return processManager.logs.filter {
|
||||
$0.text.localizedCaseInsensitiveContains(filter)
|
||||
|| $0.source.localizedCaseInsensitiveContains(filter)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
TextField("Filter", text: $filter)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Auto-scroll", isOn: $autoScroll)
|
||||
Button("Clear") { processManager.clearLogs() }
|
||||
}
|
||||
.padding(8)
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 1) {
|
||||
ForEach(filtered) { line in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text(Self.timeFormatter.string(from: line.timestamp))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Text(line.source)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(color(for: line.source))
|
||||
.frame(width: 70, alignment: .leading)
|
||||
selectableText(line.text)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
.id(line.id)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.onChange(of: filtered.count) { _ in
|
||||
if autoScroll, let last = filtered.last {
|
||||
withAnimation(.linear(duration: 0.05)) {
|
||||
proxy.scrollTo(last.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func color(for source: String) -> Color {
|
||||
if source.hasPrefix("sclang") { return .blue }
|
||||
if source.hasPrefix("oscope") { return .purple }
|
||||
return .secondary
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func selectableText(_ s: String) -> some View {
|
||||
if #available(macOS 12.0, *) {
|
||||
Text(s).textSelection(.enabled)
|
||||
} else {
|
||||
Text(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MenuBarContent: View {
|
||||
@ObservedObject var processManager: ProcessManager
|
||||
let openLogs: () -> Void
|
||||
@State private var showSettings = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("AV-Live")
|
||||
.font(.headline)
|
||||
|
||||
Divider()
|
||||
|
||||
ProcessRow(
|
||||
title: "SuperCollider",
|
||||
subtitle: "sclang + scsynth + web bridge",
|
||||
isRunning: processManager.sclangRunning,
|
||||
start: processManager.startSclang,
|
||||
stop: processManager.stopSclang
|
||||
)
|
||||
|
||||
ProcessRow(
|
||||
title: "Oscilloscope",
|
||||
subtitle: "oscope-of visualizer",
|
||||
isRunning: processManager.oscopeRunning,
|
||||
start: processManager.startOscope,
|
||||
stop: processManager.stopOscope
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Button(action: openLogs) {
|
||||
Label("Logs", systemImage: "text.alignleft")
|
||||
}
|
||||
Button(action: { showSettings = true }) {
|
||||
Label("Paths…", systemImage: "gearshape")
|
||||
}
|
||||
Spacer()
|
||||
Button("Quit") { NSApp.terminate(nil) }
|
||||
.keyboardShortcut("q")
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(width: 360)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(processManager: processManager,
|
||||
dismiss: { showSettings = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessRow: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let isRunning: Bool
|
||||
let start: () -> Void
|
||||
let stop: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Circle()
|
||||
.fill(isRunning ? Color.green : Color.secondary.opacity(0.4))
|
||||
.frame(width: 10, height: 10)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title).font(.body)
|
||||
Text(subtitle).font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button(isRunning ? "Stop" : "Start") {
|
||||
isRunning ? stop() : start()
|
||||
}
|
||||
.frame(width: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsView: View {
|
||||
@ObservedObject var processManager: ProcessManager
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Paths").font(.headline)
|
||||
PathField(
|
||||
label: "sclang binary",
|
||||
path: Binding(
|
||||
get: { processManager.sclangPath },
|
||||
set: { processManager.sclangPath = $0 }
|
||||
),
|
||||
isDirectory: false
|
||||
)
|
||||
PathField(
|
||||
label: "Load file (00_load.scd)",
|
||||
path: Binding(
|
||||
get: { processManager.soundAlgoLoadFile },
|
||||
set: { processManager.soundAlgoLoadFile = $0 }
|
||||
),
|
||||
isDirectory: false
|
||||
)
|
||||
PathField(
|
||||
label: "oscope-of binary",
|
||||
path: Binding(
|
||||
get: { processManager.oscopePath },
|
||||
set: { processManager.oscopePath = $0 }
|
||||
),
|
||||
isDirectory: false
|
||||
)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Done", action: dismiss).keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 520)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PathField: View {
|
||||
let label: String
|
||||
@Binding var path: String
|
||||
let isDirectory: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label).font(.caption).foregroundColor(.secondary)
|
||||
HStack {
|
||||
TextField("", text: $path)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Choose…") {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = !isDirectory
|
||||
panel.canChooseDirectories = isDirectory
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = URL(fileURLWithPath: path).deletingLastPathComponent()
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
path = url.path
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
struct LogLine: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let timestamp = Date()
|
||||
let source: String
|
||||
let text: String
|
||||
}
|
||||
|
||||
final class ProcessManager: ObservableObject {
|
||||
// Observable state
|
||||
@Published var sclangRunning = false
|
||||
@Published var oscopeRunning = false
|
||||
@Published private(set) var logs: [LogLine] = []
|
||||
|
||||
// Persisted paths (UserDefaults, key/value)
|
||||
@Published var sclangPath: String { didSet { defaults.set(sclangPath, forKey: "sclangPath") } }
|
||||
@Published var soundAlgoLoadFile: String { didSet { defaults.set(soundAlgoLoadFile, forKey: "soundAlgoLoadFile") } }
|
||||
@Published var oscopePath: String { didSet { defaults.set(oscopePath, forKey: "oscopePath") } }
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private var sclangProc: Process?
|
||||
private var oscopeProc: Process?
|
||||
private let logQueue = DispatchQueue(label: "cc.saillant.avlive.log")
|
||||
private let maxLogLines = 2000
|
||||
|
||||
init() {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
sclangPath = defaults.string(forKey: "sclangPath")
|
||||
?? "/Applications/SuperCollider.app/Contents/MacOS/sclang"
|
||||
soundAlgoLoadFile = defaults.string(forKey: "soundAlgoLoadFile")
|
||||
?? "\(home)/Documents/Projets/AV-Live/sound_algo/00_load.scd"
|
||||
oscopePath = defaults.string(forKey: "oscopePath")
|
||||
?? "\(home)/Documents/Projets/AV-Live/oscope-of/bin/oscope-of"
|
||||
}
|
||||
|
||||
// MARK: - sclang
|
||||
|
||||
func startSclang() {
|
||||
guard sclangProc == nil else { return }
|
||||
guard FileManager.default.fileExists(atPath: sclangPath) else {
|
||||
append(source: "launcher", text: "sclang not found at \(sclangPath)")
|
||||
return
|
||||
}
|
||||
guard FileManager.default.fileExists(atPath: soundAlgoLoadFile) else {
|
||||
append(source: "launcher", text: "load file not found at \(soundAlgoLoadFile)")
|
||||
return
|
||||
}
|
||||
let p = Process()
|
||||
p.executableURL = URL(fileURLWithPath: sclangPath)
|
||||
p.arguments = [soundAlgoLoadFile]
|
||||
// sclang resolves relative paths from the current working dir; cd to the load file's dir
|
||||
p.currentDirectoryURL = URL(fileURLWithPath: soundAlgoLoadFile).deletingLastPathComponent()
|
||||
attach(process: p, label: "sclang")
|
||||
do {
|
||||
try p.run()
|
||||
sclangProc = p
|
||||
DispatchQueue.main.async { self.sclangRunning = true }
|
||||
p.terminationHandler = { [weak self] proc in
|
||||
self?.append(source: "sclang", text: "exited with status \(proc.terminationStatus)")
|
||||
DispatchQueue.main.async {
|
||||
self?.sclangProc = nil
|
||||
self?.sclangRunning = false
|
||||
}
|
||||
}
|
||||
append(source: "launcher", text: "started sclang \(sclangPath) \(soundAlgoLoadFile)")
|
||||
} catch {
|
||||
append(source: "launcher", text: "failed to start sclang: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func stopSclang() {
|
||||
sclangProc?.terminate()
|
||||
}
|
||||
|
||||
// MARK: - oscope-of
|
||||
|
||||
func startOscope() {
|
||||
guard oscopeProc == nil else { return }
|
||||
guard FileManager.default.fileExists(atPath: oscopePath) else {
|
||||
append(source: "launcher", text: "oscope-of binary not found at \(oscopePath)")
|
||||
return
|
||||
}
|
||||
let p = Process()
|
||||
p.executableURL = URL(fileURLWithPath: oscopePath)
|
||||
p.currentDirectoryURL = URL(fileURLWithPath: oscopePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
attach(process: p, label: "oscope")
|
||||
do {
|
||||
try p.run()
|
||||
oscopeProc = p
|
||||
DispatchQueue.main.async { self.oscopeRunning = true }
|
||||
p.terminationHandler = { [weak self] proc in
|
||||
self?.append(source: "oscope", text: "exited with status \(proc.terminationStatus)")
|
||||
DispatchQueue.main.async {
|
||||
self?.oscopeProc = nil
|
||||
self?.oscopeRunning = false
|
||||
}
|
||||
}
|
||||
append(source: "launcher", text: "started oscope-of \(oscopePath)")
|
||||
} catch {
|
||||
append(source: "launcher", text: "failed to start oscope-of: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func stopOscope() {
|
||||
oscopeProc?.terminate()
|
||||
}
|
||||
|
||||
// MARK: - utilities
|
||||
|
||||
func stopAll() {
|
||||
sclangProc?.terminate()
|
||||
oscopeProc?.terminate()
|
||||
}
|
||||
|
||||
func clearLogs() {
|
||||
DispatchQueue.main.async { self.logs.removeAll() }
|
||||
}
|
||||
|
||||
private func attach(process: Process, label: String) {
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
attachReader(outPipe.fileHandleForReading, source: label)
|
||||
attachReader(errPipe.fileHandleForReading, source: label + "!")
|
||||
}
|
||||
|
||||
private func attachReader(_ fh: FileHandle, source: String) {
|
||||
fh.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty,
|
||||
let str = String(data: data, encoding: .utf8) else { return }
|
||||
for raw in str.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
let line = String(raw)
|
||||
if !line.isEmpty {
|
||||
self?.append(source: source, text: line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func append(source: String, text: String) {
|
||||
let entry = LogLine(source: source, text: text)
|
||||
DispatchQueue.main.async {
|
||||
self.logs.append(entry)
|
||||
if self.logs.count > self.maxLogLines {
|
||||
self.logs.removeFirst(self.logs.count - self.maxLogLines)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
# Build AVLiveLauncher and bundle it into a .app
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "==> swift build (release)"
|
||||
swift build -c release
|
||||
|
||||
BIN_NAME="AVLiveLauncher"
|
||||
BIN_PATH=".build/release/${BIN_NAME}"
|
||||
APP_PATH="build/${BIN_NAME}.app"
|
||||
|
||||
if [ ! -x "${BIN_PATH}" ]; then
|
||||
echo "Build did not produce ${BIN_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> bundling ${APP_PATH}"
|
||||
rm -rf "${APP_PATH}"
|
||||
mkdir -p "${APP_PATH}/Contents/MacOS" "${APP_PATH}/Contents/Resources"
|
||||
cp "${BIN_PATH}" "${APP_PATH}/Contents/MacOS/${BIN_NAME}"
|
||||
cp Resources/Info.plist "${APP_PATH}/Contents/Info.plist"
|
||||
|
||||
# Ad-hoc sign so Gatekeeper at least lets the user open it manually
|
||||
codesign --force --sign - "${APP_PATH}" 2>/dev/null || true
|
||||
|
||||
echo "==> done"
|
||||
echo " open ${APP_PATH}"
|
||||
Reference in New Issue
Block a user