diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 814d47d..3c5cf8e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ permissions:
jobs:
build:
name: Build & Verify Universal Binary
- runs-on: macos-14
+ runs-on: macos-15
steps:
- name: Checkout repository
@@ -25,7 +25,7 @@ jobs:
- name: Select Xcode
run: |
- XCODE_PATH=$(ls -d /Applications/Xcode_16*.app 2>/dev/null | sort -V | tail -1)
+ XCODE_PATH=$(ls -d /Applications/Xcode_16*.app /Applications/Xcode_15*.app 2>/dev/null | sort -V | tail -1)
if [ -n "$XCODE_PATH" ]; then
sudo xcode-select -s "$XCODE_PATH"
echo "Selected: $XCODE_PATH"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2ddcedc..cc563a5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -17,7 +17,7 @@ permissions:
jobs:
release:
name: Build & Publish Release
- runs-on: macos-14
+ runs-on: macos-15
steps:
- name: Checkout repository
@@ -27,7 +27,7 @@ jobs:
- name: Select Xcode
run: |
- XCODE_PATH=$(ls -d /Applications/Xcode_16*.app 2>/dev/null | sort -V | tail -1)
+ XCODE_PATH=$(ls -d /Applications/Xcode_16*.app /Applications/Xcode_15*.app 2>/dev/null | sort -V | tail -1)
if [ -n "$XCODE_PATH" ]; then
sudo xcode-select -s "$XCODE_PATH"
echo "Selected: $XCODE_PATH"
@@ -42,11 +42,15 @@ jobs:
- name: Resolve Release Tag & Notes
id: release_info
+ env:
+ INPUT_TAG: ${{ inputs.version_tag || github.event.inputs.version_tag }}
+ REF_NAME: ${{ github.ref_name }}
+ REF: ${{ github.ref }}
run: |
- if [ -n "${{ github.event.inputs.version_tag }}" ]; then
- TAG="${{ github.event.inputs.version_tag }}"
- elif [ -n "${{ github.ref_name }}" ] && [[ "${{ github.ref }}" == refs/tags/* ]]; then
- TAG="${{ github.ref_name }}"
+ if [ -n "$INPUT_TAG" ]; then
+ TAG="$INPUT_TAG"
+ elif [ -n "$REF_NAME" ] && [[ "$REF" == refs/tags/* ]]; then
+ TAG="$REF_NAME"
else
VER=$(grep '"version"' version.json | head -1 | sed 's/.*: *"\(.*\)".*/\1/')
TAG="v${VER}"
@@ -59,6 +63,7 @@ jobs:
tag_name: ${{ steps.release_info.outputs.tag }}
name: "MySound ${{ steps.release_info.outputs.tag }}"
generate_release_notes: true
+ fail_on_unmatched_files: true
files: |
build/MySound.dmg
build/MySound.zip
diff --git a/.gitignore b/.gitignore
index ae1cb11..a59297c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -128,3 +128,6 @@ __pycache__/
.venv/
venv/
ENV/
+
+# Brainstorming Visual Companion
+.superpowers/
diff --git a/Entitlements.plist b/Entitlements.plist
index ff26b27..acbfffe 100644
--- a/Entitlements.plist
+++ b/Entitlements.plist
@@ -6,6 +6,8 @@
-
+
-
+
diff --git a/Sources/App.swift b/Sources/App.swift
index 18a4539..c47fadd 100644
--- a/Sources/App.swift
+++ b/Sources/App.swift
@@ -8,13 +8,12 @@ import AppKit
/// `MySoundAppDelegate` handles macOS application lifecycle events that SwiftUI does not expose,
/// ensuring clean teardown of CoreAudio resources (aggregate devices, process taps) when the
/// app is terminated by the system (e.g. logout, shutdown, force quit).
+@MainActor
class MySoundAppDelegate: NSObject, NSApplicationDelegate {
/// Called by macOS when the application is about to terminate (user quit, system shutdown, etc.).
/// Ensures all CoreAudio process taps and aggregate devices are destroyed cleanly.
func applicationWillTerminate(_ notification: Notification) {
- Task { @MainActor in
- AudioTapManager.shared.removeAllTaps()
- }
+ AudioTapManager.shared.removeAllTaps()
}
}
diff --git a/Sources/VolumeControlView.swift b/Sources/VolumeControlView.swift
index 820b477..2a40810 100644
--- a/Sources/VolumeControlView.swift
+++ b/Sources/VolumeControlView.swift
@@ -186,6 +186,7 @@ struct VolumeControlView: View {
@State private var savedAppVolumes: [Int32: Double] = [:]
@State private var isQuitHovered: Bool = false
@State private var isGearHovered: Bool = false
+ @State private var isMasterMuteHovered: Bool = false
@State private var hasPermission: Bool = true
@State private var permissionCheckTimer: Timer?
@@ -275,79 +276,50 @@ struct VolumeControlView: View {
}
// -----------------------------------------------------------------
- // MARK: Header & Master Volume
+ // MARK: Header & Master Volume (Wrapping Flow Capsules)
// -----------------------------------------------------------------
VStack(alignment: .leading, spacing: 8) {
- HStack(alignment: .center) {
- // Output device selection dropdown
- Menu {
- Section(header: Text("Switch Output Device")) {
+ // Top Row: Dynamic 1-Click Selectable Wrapping Device Capsules & Mute All
+ HStack(alignment: .top, spacing: 6) {
+ WrappingHStack(horizontalSpacing: 6, verticalSpacing: 6) {
+ if tapManager.availableOutputDevices.isEmpty {
+ OutputDeviceChip(
+ device: tapManager.currentOutputDevice ?? AudioOutputDevice(id: 0, name: "Output Device", uid: "default"),
+ isSelected: true,
+ onSelect: {}
+ )
+ } else {
ForEach(tapManager.availableOutputDevices) { device in
- Button(action: {
- tapManager.setDefaultOutputDevice(device)
- }) {
- HStack {
- Label {
- Text(device.name)
- } icon: {
- Image(systemName: device.iconName)
- }
- if tapManager.currentOutputDevice?.id == device.id {
- Image(systemName: "checkmark")
+ OutputDeviceChip(
+ device: device,
+ isSelected: tapManager.currentOutputDevice?.id == device.id,
+ onSelect: {
+ withAnimation(.easeInOut(duration: 0.15)) {
+ tapManager.setDefaultOutputDevice(device)
}
}
- }
+ )
}
}
- } label: {
- HStack(spacing: 5) {
- Image(systemName: tapManager.currentOutputDevice?.iconName ?? "laptopcomputer")
- .font(.system(size: 13, weight: .semibold))
- .foregroundColor(.blue)
-
- Text(tapManager.currentOutputDevice?.name ?? "Output Device")
- .font(.system(size: 13, weight: .semibold))
- .foregroundColor(.primary)
- .lineLimit(1)
- .truncationMode(.tail)
-
- if tapManager.availableOutputDevices.count > 1 {
- Image(systemName: "chevron.up.chevron.down")
- .font(.system(size: 9, weight: .bold))
- .foregroundColor(.secondary)
- }
- }
- .padding(.horizontal, 6)
- .padding(.vertical, 3)
- .background(Color.primary.opacity(0.04))
- .cornerRadius(6)
}
- .menuStyle(.borderlessButton)
- .accessibilityLabel("Select Output Device, currently \(tapManager.currentOutputDevice?.name ?? "Output Device")")
+ .frame(maxWidth: .infinity, alignment: .leading)
- Spacer()
-
- // Batch Mute / Unmute Button
if !appManager.apps.isEmpty {
Button(action: toggleMuteAll) {
Text(isAllMuted ? "Unmute All" : "Mute All")
- .font(.caption)
- .fontWeight(.medium)
+ .font(.system(size: 11, weight: .medium))
.foregroundColor(.blue)
+ .padding(.horizontal, 4)
+ .padding(.vertical, 4)
}
.buttonStyle(.plain)
.accessibilityLabel(isAllMuted ? "Unmute all running applications" : "Mute all running applications")
}
}
+ .padding(.horizontal, 4)
- // Master Volume Slider Row: [Device Icon] [Speaker Mute Button] [Slider] [Percentage]
- HStack(spacing: 6) {
- // Device Icon
- Image(systemName: tapManager.currentOutputDevice?.iconName ?? "laptopcomputer")
- .font(.system(size: 13))
- .foregroundColor(.secondary)
- .frame(width: 18, height: 18)
-
+ // Master Volume Slider Row: [Speaker Mute Button] [Slider] [Percentage]
+ HStack(spacing: 8) {
// Master Speaker Mute Button
Button(action: {
if masterVolume > 0.001 {
@@ -358,14 +330,19 @@ struct VolumeControlView: View {
tapManager.setSystemVolume(Float(masterVolume))
}) {
Image(systemName: masterVolume <= 0.001 ? "speaker.slash.fill" : "speaker.wave.3.fill")
- .foregroundColor(masterVolume <= 0.001 ? .red : .secondary)
- .font(.system(size: 11))
+ .foregroundColor(masterVolume <= 0.001 ? .red : (isMasterMuteHovered ? .primary : .secondary))
+ .font(.system(size: 12))
.frame(width: 18, height: 18)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
+ .onHover { hovering in
+ withAnimation(.easeInOut(duration: 0.12)) {
+ isMasterMuteHovered = hovering
+ }
+ }
.accessibilityLabel(masterVolume <= 0.001 ? "Unmute system master volume" : "Mute system master volume")
-
+
// Custom Master Volume Slider
BoxySlider(value: $masterVolume, range: 0...1, tint: .blue)
.accessibilityLabel("System Master Volume")
@@ -392,17 +369,20 @@ struct VolumeControlView: View {
masterVolume = 1.0
tapManager.setSystemVolume(1.0)
}
-
+
// Percentage readout
Text("\(Int(masterVolume * 100))%")
.font(.caption.monospacedDigit())
.foregroundColor(masterVolume <= 0.001 ? .secondary.opacity(0.5) : .secondary)
- .frame(width: 36, alignment: .trailing)
+ .frame(width: 34, alignment: .trailing)
}
+ .padding(.horizontal, 6)
+ .frame(maxWidth: .infinity)
}
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .background(Color(NSColor.controlBackgroundColor).opacity(0.5))
+ .padding(.horizontal, 6)
+ .padding(.top, 8)
+ .padding(.bottom, 6)
+ .background(Color(NSColor.controlBackgroundColor).opacity(0.35))
.onAppear {
masterVolume = Double(tapManager.getSystemVolume())
if masterVolume > 0.001 {
@@ -521,11 +501,17 @@ struct VolumeControlView: View {
let newApps = AppManager.getRunningApps(existingApps: appManager.apps)
appManager.apps = newApps
- // Re-check permissions every 3 seconds so banner vanishes when user approves in Settings
+ // Re-check permissions every 3 seconds only if permission is missing
permissionCheckTimer?.invalidate()
- permissionCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in
- Task { @MainActor in
- hasPermission = AudioTapManager.hasAudioCapturePermission()
+ if !hasPermission {
+ permissionCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { timer in
+ Task { @MainActor in
+ let granted = AudioTapManager.hasAudioCapturePermission()
+ hasPermission = granted
+ if granted {
+ timer.invalidate()
+ }
+ }
}
}
}
@@ -754,6 +740,109 @@ struct VolumeControlView: View {
}
}
+// =============================================================================
+// MARK: - Wrapping HStack Layout
+// =============================================================================
+
+/// A custom SwiftUI `Layout` that arranges subviews horizontally and wraps to subsequent lines
+/// when available horizontal width is exceeded.
+struct WrappingHStack: Layout {
+ var horizontalSpacing: CGFloat = 6
+ var verticalSpacing: CGFloat = 6
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ guard !subviews.isEmpty else { return .zero }
+ let width = proposal.width ?? 300
+ var currentX: CGFloat = 0
+ var currentY: CGFloat = 0
+ var lineHeight: CGFloat = 0
+ var maxRowWidth: CGFloat = 0
+
+ for subview in subviews {
+ let size = subview.sizeThatFits(.unspecified)
+ let itemWidth = min(size.width, width)
+ if currentX + itemWidth > width && currentX > 0 {
+ maxRowWidth = max(maxRowWidth, currentX - horizontalSpacing)
+ currentX = 0
+ currentY += lineHeight + verticalSpacing
+ lineHeight = 0
+ }
+ lineHeight = max(lineHeight, size.height)
+ currentX += itemWidth + horizontalSpacing
+ }
+ maxRowWidth = max(maxRowWidth, currentX > 0 ? currentX - horizontalSpacing : 0)
+
+ return CGSize(width: min(width, maxRowWidth), height: currentY + lineHeight)
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ guard !subviews.isEmpty else { return }
+ var currentX: CGFloat = bounds.minX
+ var currentY: CGFloat = bounds.minY
+ var lineHeight: CGFloat = 0
+
+ for subview in subviews {
+ let size = subview.sizeThatFits(.unspecified)
+ let itemWidth = min(size.width, bounds.width)
+ if currentX + itemWidth > bounds.maxX && currentX > bounds.minX {
+ currentX = bounds.minX
+ currentY += lineHeight + verticalSpacing
+ lineHeight = 0
+ }
+ subview.place(at: CGPoint(x: currentX, y: currentY), proposal: ProposedViewSize(width: itemWidth, height: size.height))
+ lineHeight = max(lineHeight, size.height)
+ currentX += itemWidth + horizontalSpacing
+ }
+ }
+}
+
+// =============================================================================
+// MARK: - Output Device Chip
+// =============================================================================
+
+/// `OutputDeviceChip` renders an individual interactive output device capsule button
+/// with device-specific SF Symbol, localized device name, active state indicator,
+/// and smooth hover brightening.
+struct OutputDeviceChip: View {
+ let device: AudioOutputDevice
+ let isSelected: Bool
+ let onSelect: () -> Void
+ @State private var isHovered: Bool = false
+
+ var body: some View {
+ Button(action: onSelect) {
+ HStack(spacing: 5) {
+ Image(systemName: device.iconName)
+ .font(.system(size: 11, weight: isSelected ? .semibold : .medium))
+ .foregroundColor(isSelected ? .primary : (isHovered ? .primary : .secondary))
+
+ Text(device.name)
+ .font(.system(size: 11.5, weight: isSelected ? .semibold : .regular))
+ .foregroundColor(isSelected ? .primary : (isHovered ? .primary : .secondary))
+ .lineLimit(1)
+ }
+ .padding(.horizontal, 9)
+ .padding(.vertical, 5)
+ .background(
+ Capsule(style: .continuous)
+ .fill(isSelected ? Color.primary.opacity(0.14) : (isHovered ? Color.primary.opacity(0.08) : Color.primary.opacity(0.04)))
+ )
+ .overlay(
+ Capsule(style: .continuous)
+ .stroke(isSelected ? Color.primary.opacity(0.12) : Color.clear, lineWidth: 0.5)
+ )
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ .onHover { hovering in
+ withAnimation(.easeInOut(duration: 0.12)) {
+ isHovered = hovering
+ }
+ }
+ .accessibilityLabel("\(device.name)\(isSelected ? ", currently active" : ", click to select")")
+ }
+}
+
// =============================================================================
// MARK: - App Volume Row
// =============================================================================
diff --git a/build.sh b/build.sh
index 0d76592..48fbb91 100755
--- a/build.sh
+++ b/build.sh
@@ -84,6 +84,10 @@ cat <