Initial commit: HUDini — classic volume HUD for macOS
Menu bar utility that shows a centered HUD overlay with volume bars when system volume changes. CoreAudio monitoring, auto-fade, works on all spaces and fullscreen. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
cee343dd96
9 changed files with 497 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
*.app
|
||||||
|
DerivedData/
|
||||||
|
.DS_Store
|
||||||
|
PROGRESS.md
|
||||||
19
Package.swift
Normal file
19
Package.swift
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// swift-tools-version:5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "HUDini",
|
||||||
|
platforms: [.macOS(.v13)],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "HUDini",
|
||||||
|
path: "Sources/HUDini",
|
||||||
|
exclude: ["Resources"],
|
||||||
|
linkerSettings: [
|
||||||
|
.linkedFramework("Cocoa"),
|
||||||
|
.linkedFramework("CoreAudio"),
|
||||||
|
.linkedFramework("AudioToolbox"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
70
Sources/HUDini/AppDelegate.swift
Normal file
70
Sources/HUDini/AppDelegate.swift
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import Cocoa
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
private var statusItem: NSStatusItem!
|
||||||
|
private var volumeMonitor: VolumeMonitor!
|
||||||
|
private var hudPanel: HUDPanel!
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
// Hide from Dock
|
||||||
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
|
||||||
|
setupStatusBar()
|
||||||
|
setupHUD()
|
||||||
|
setupVolumeMonitor()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupStatusBar() {
|
||||||
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||||
|
|
||||||
|
if let button = statusItem.button {
|
||||||
|
button.image = NSImage(systemSymbolName: "speaker.wave.2.fill", accessibilityDescription: "HUDini")
|
||||||
|
button.image?.size = NSSize(width: 18, height: 18)
|
||||||
|
}
|
||||||
|
|
||||||
|
let menu = NSMenu()
|
||||||
|
menu.addItem(NSMenuItem(title: "HUDini", action: nil, keyEquivalent: ""))
|
||||||
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
|
let enabledItem = NSMenuItem(title: "Enabled", action: #selector(toggleEnabled(_:)), keyEquivalent: "e")
|
||||||
|
enabledItem.state = .on
|
||||||
|
enabledItem.target = self
|
||||||
|
menu.addItem(enabledItem)
|
||||||
|
|
||||||
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
let quitItem = NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")
|
||||||
|
quitItem.target = self
|
||||||
|
menu.addItem(quitItem)
|
||||||
|
|
||||||
|
statusItem.menu = menu
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupHUD() {
|
||||||
|
hudPanel = HUDPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupVolumeMonitor() {
|
||||||
|
volumeMonitor = VolumeMonitor { [weak self] volume, isMuted in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.hudPanel.show(volume: volume, isMuted: isMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volumeMonitor.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func toggleEnabled(_ sender: NSMenuItem) {
|
||||||
|
if sender.state == .on {
|
||||||
|
sender.state = .off
|
||||||
|
volumeMonitor.stop()
|
||||||
|
} else {
|
||||||
|
sender.state = .on
|
||||||
|
volumeMonitor.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func quit() {
|
||||||
|
volumeMonitor.stop()
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
Sources/HUDini/HUDPanel.swift
Normal file
68
Sources/HUDini/HUDPanel.swift
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import Cocoa
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
class HUDPanel {
|
||||||
|
private var panel: NSPanel?
|
||||||
|
private var hideTimer: Timer?
|
||||||
|
private let hudSize = NSSize(width: 200, height: 200)
|
||||||
|
|
||||||
|
func show(volume: Float, isMuted: Bool) {
|
||||||
|
hideTimer?.invalidate()
|
||||||
|
|
||||||
|
if panel == nil {
|
||||||
|
createPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let panel = panel else { return }
|
||||||
|
|
||||||
|
// Update content
|
||||||
|
let hostingView = NSHostingView(rootView: HUDView(volume: volume, isMuted: isMuted))
|
||||||
|
hostingView.frame = NSRect(origin: .zero, size: hudSize)
|
||||||
|
panel.contentView = hostingView
|
||||||
|
|
||||||
|
// Center on main screen
|
||||||
|
if let screen = NSScreen.main {
|
||||||
|
let screenFrame = screen.visibleFrame
|
||||||
|
let x = screenFrame.midX - hudSize.width / 2
|
||||||
|
let y = screenFrame.midY - hudSize.height / 2
|
||||||
|
panel.setFrameOrigin(NSPoint(x: x, y: y))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show
|
||||||
|
panel.alphaValue = 1.0
|
||||||
|
panel.orderFrontRegardless()
|
||||||
|
|
||||||
|
// Auto-hide after 1.5s
|
||||||
|
hideTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in
|
||||||
|
self?.fadeOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createPanel() {
|
||||||
|
let panel = NSPanel(
|
||||||
|
contentRect: NSRect(origin: .zero, size: hudSize),
|
||||||
|
styleMask: [.borderless, .nonactivatingPanel],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
panel.level = .floating
|
||||||
|
panel.isOpaque = false
|
||||||
|
panel.backgroundColor = .clear
|
||||||
|
panel.hasShadow = true
|
||||||
|
panel.hidesOnDeactivate = false
|
||||||
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle]
|
||||||
|
panel.isMovableByWindowBackground = false
|
||||||
|
panel.ignoresMouseEvents = true
|
||||||
|
self.panel = panel
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fadeOut() {
|
||||||
|
guard let panel = panel else { return }
|
||||||
|
NSAnimationContext.runAnimationGroup({ context in
|
||||||
|
context.duration = 0.4
|
||||||
|
panel.animator().alphaValue = 0.0
|
||||||
|
}, completionHandler: { [weak self] in
|
||||||
|
self?.panel?.orderOut(nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
51
Sources/HUDini/HUDView.swift
Normal file
51
Sources/HUDini/HUDView.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HUDView: View {
|
||||||
|
let volume: Float
|
||||||
|
let isMuted: Bool
|
||||||
|
|
||||||
|
private let totalBars = 16
|
||||||
|
private let cornerRadius: CGFloat = 18
|
||||||
|
|
||||||
|
var filledBars: Int {
|
||||||
|
if isMuted { return 0 }
|
||||||
|
return Int(round(Double(volume) * Double(totalBars)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var speakerIcon: String {
|
||||||
|
if isMuted { return "speaker.slash.fill" }
|
||||||
|
if volume == 0 { return "speaker.fill" }
|
||||||
|
if volume < 0.33 { return "speaker.wave.1.fill" }
|
||||||
|
if volume < 0.66 { return "speaker.wave.2.fill" }
|
||||||
|
return "speaker.wave.3.fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: speakerIcon)
|
||||||
|
.font(.system(size: 48, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(height: 60)
|
||||||
|
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ForEach(0..<totalBars, id: \.self) { index in
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(index < filledBars ? Color.white : Color.white.opacity(0.2))
|
||||||
|
.frame(height: 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
.frame(width: 200, height: 200)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
.fill(Color.black.opacity(0.55))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
26
Sources/HUDini/Resources/Info.plist
Normal file
26
Sources/HUDini/Resources/Info.plist
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?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>HUDini</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>HUDini</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.grosfrumos.hudini</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>HUDini</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
226
Sources/HUDini/VolumeMonitor.swift
Normal file
226
Sources/HUDini/VolumeMonitor.swift
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
import CoreAudio
|
||||||
|
import AudioToolbox
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class VolumeMonitor {
|
||||||
|
typealias VolumeCallback = (Float, Bool) -> Void
|
||||||
|
|
||||||
|
private let callback: VolumeCallback
|
||||||
|
private var deviceID: AudioDeviceID = kAudioObjectUnknown
|
||||||
|
private var isListening = false
|
||||||
|
private var lastVolume: Float = -1
|
||||||
|
private var lastMuted: Bool = false
|
||||||
|
|
||||||
|
init(callback: @escaping VolumeCallback) {
|
||||||
|
self.callback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !isListening else { return }
|
||||||
|
deviceID = getDefaultOutputDevice()
|
||||||
|
guard deviceID != kAudioObjectUnknown else { return }
|
||||||
|
|
||||||
|
addDeviceChangeListener()
|
||||||
|
addVolumeListener(for: deviceID)
|
||||||
|
addMuteListener(for: deviceID)
|
||||||
|
isListening = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard isListening else { return }
|
||||||
|
removeVolumeListener(for: deviceID)
|
||||||
|
removeMuteListener(for: deviceID)
|
||||||
|
removeDeviceChangeListener()
|
||||||
|
isListening = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Default Output Device
|
||||||
|
|
||||||
|
private func getDefaultOutputDevice() -> AudioDeviceID {
|
||||||
|
var deviceID = AudioDeviceID(0)
|
||||||
|
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
let status = AudioObjectGetPropertyData(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID
|
||||||
|
)
|
||||||
|
return status == noErr ? deviceID : kAudioObjectUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Volume
|
||||||
|
|
||||||
|
func getVolume() -> Float {
|
||||||
|
guard deviceID != kAudioObjectUnknown else { return 0 }
|
||||||
|
var volume: Float32 = 0
|
||||||
|
var size = UInt32(MemoryLayout<Float32>.size)
|
||||||
|
|
||||||
|
// Try master channel first
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyVolumeScalar,
|
||||||
|
mScope: kAudioDevicePropertyScopeOutput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
|
||||||
|
var status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &volume)
|
||||||
|
if status == noErr { return volume }
|
||||||
|
|
||||||
|
// Fallback to channel 1 (left)
|
||||||
|
address.mElement = 1
|
||||||
|
status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &volume)
|
||||||
|
return status == noErr ? volume : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMuted() -> Bool {
|
||||||
|
guard deviceID != kAudioObjectUnknown else { return false }
|
||||||
|
var muted: UInt32 = 0
|
||||||
|
var size = UInt32(MemoryLayout<UInt32>.size)
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyMute,
|
||||||
|
mScope: kAudioDevicePropertyScopeOutput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &muted)
|
||||||
|
return status == noErr ? muted != 0 : false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Listeners
|
||||||
|
|
||||||
|
private func addVolumeListener(for device: AudioDeviceID) {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyVolumeScalar,
|
||||||
|
mScope: kAudioDevicePropertyScopeOutput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
|
||||||
|
// Try master first
|
||||||
|
let status = AudioObjectAddPropertyListener(device, &address, volumeChanged, Unmanaged.passUnretained(self).toOpaque())
|
||||||
|
if status != noErr {
|
||||||
|
// Fallback: listen on channel 1
|
||||||
|
address.mElement = 1
|
||||||
|
AudioObjectAddPropertyListener(device, &address, volumeChanged, Unmanaged.passUnretained(self).toOpaque())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeVolumeListener(for device: AudioDeviceID) {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyVolumeScalar,
|
||||||
|
mScope: kAudioDevicePropertyScopeOutput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
AudioObjectRemovePropertyListener(device, &address, volumeChanged, Unmanaged.passUnretained(self).toOpaque())
|
||||||
|
address.mElement = 1
|
||||||
|
AudioObjectRemovePropertyListener(device, &address, volumeChanged, Unmanaged.passUnretained(self).toOpaque())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addMuteListener(for device: AudioDeviceID) {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyMute,
|
||||||
|
mScope: kAudioDevicePropertyScopeOutput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
AudioObjectAddPropertyListener(device, &address, muteChanged, Unmanaged.passUnretained(self).toOpaque())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeMuteListener(for device: AudioDeviceID) {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyMute,
|
||||||
|
mScope: kAudioDevicePropertyScopeOutput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
AudioObjectRemovePropertyListener(device, &address, muteChanged, Unmanaged.passUnretained(self).toOpaque())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addDeviceChangeListener() {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
AudioObjectAddPropertyListener(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject), &address, deviceChanged,
|
||||||
|
Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeDeviceChangeListener() {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
AudioObjectRemovePropertyListener(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject), &address, deviceChanged,
|
||||||
|
Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Callbacks
|
||||||
|
|
||||||
|
fileprivate func handleVolumeChange() {
|
||||||
|
let vol = getVolume()
|
||||||
|
let muted = isMuted()
|
||||||
|
callback(vol, muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func handleMuteChange() {
|
||||||
|
let vol = getVolume()
|
||||||
|
let muted = isMuted()
|
||||||
|
callback(vol, muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func handleDeviceChange() {
|
||||||
|
// Remove old listeners
|
||||||
|
if deviceID != kAudioObjectUnknown {
|
||||||
|
removeVolumeListener(for: deviceID)
|
||||||
|
removeMuteListener(for: deviceID)
|
||||||
|
}
|
||||||
|
// Switch to new device
|
||||||
|
deviceID = getDefaultOutputDevice()
|
||||||
|
if deviceID != kAudioObjectUnknown {
|
||||||
|
addVolumeListener(for: deviceID)
|
||||||
|
addMuteListener(for: deviceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// C-style callbacks that bridge to the class methods
|
||||||
|
private func volumeChanged(
|
||||||
|
_ objectID: AudioObjectID,
|
||||||
|
_ numberAddresses: UInt32,
|
||||||
|
_ addresses: UnsafePointer<AudioObjectPropertyAddress>,
|
||||||
|
_ clientData: UnsafeMutableRawPointer?
|
||||||
|
) -> OSStatus {
|
||||||
|
guard let clientData = clientData else { return noErr }
|
||||||
|
let monitor = Unmanaged<VolumeMonitor>.fromOpaque(clientData).takeUnretainedValue()
|
||||||
|
monitor.handleVolumeChange()
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
|
|
||||||
|
private func muteChanged(
|
||||||
|
_ objectID: AudioObjectID,
|
||||||
|
_ numberAddresses: UInt32,
|
||||||
|
_ addresses: UnsafePointer<AudioObjectPropertyAddress>,
|
||||||
|
_ clientData: UnsafeMutableRawPointer?
|
||||||
|
) -> OSStatus {
|
||||||
|
guard let clientData = clientData else { return noErr }
|
||||||
|
let monitor = Unmanaged<VolumeMonitor>.fromOpaque(clientData).takeUnretainedValue()
|
||||||
|
monitor.handleMuteChange()
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deviceChanged(
|
||||||
|
_ objectID: AudioObjectID,
|
||||||
|
_ numberAddresses: UInt32,
|
||||||
|
_ addresses: UnsafePointer<AudioObjectPropertyAddress>,
|
||||||
|
_ clientData: UnsafeMutableRawPointer?
|
||||||
|
) -> OSStatus {
|
||||||
|
guard let clientData = clientData else { return noErr }
|
||||||
|
let monitor = Unmanaged<VolumeMonitor>.fromOpaque(clientData).takeUnretainedValue()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
monitor.handleDeviceChange()
|
||||||
|
}
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
6
Sources/HUDini/main.swift
Normal file
6
Sources/HUDini/main.swift
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import Cocoa
|
||||||
|
|
||||||
|
let app = NSApplication.shared
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
app.delegate = delegate
|
||||||
|
app.run()
|
||||||
25
build.sh
Executable file
25
build.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_NAME="HUDini"
|
||||||
|
BUILD_DIR=".build"
|
||||||
|
APP_BUNDLE="$APP_NAME.app"
|
||||||
|
|
||||||
|
echo "Building $APP_NAME..."
|
||||||
|
swift build -c release
|
||||||
|
|
||||||
|
echo "Creating app bundle..."
|
||||||
|
rm -rf "$APP_BUNDLE"
|
||||||
|
mkdir -p "$APP_BUNDLE/Contents/MacOS"
|
||||||
|
mkdir -p "$APP_BUNDLE/Contents/Resources"
|
||||||
|
|
||||||
|
# Copy binary
|
||||||
|
cp "$BUILD_DIR/release/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
|
||||||
|
|
||||||
|
# Copy Info.plist
|
||||||
|
cp "Sources/$APP_NAME/Resources/Info.plist" "$APP_BUNDLE/Contents/"
|
||||||
|
|
||||||
|
echo "Done! Created $APP_BUNDLE"
|
||||||
|
echo ""
|
||||||
|
echo "To run: open $APP_BUNDLE"
|
||||||
|
echo "To install: cp -r $APP_BUNDLE /Applications/"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue