Skip to content
Open
110 changes: 110 additions & 0 deletions .github/workflows/build-dmg.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
name: Build macOS and Create DMG

on:
push:
tags: ['v*']
workflow_dispatch: # Allow manual trigger for testing

jobs:
build:
runs-on: macos-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: Create build directory
run: mkdir -p build

- name: Build archive
run: |
xcodebuild -scheme Ice \
-project Ice.xcodeproj \
-configuration Release \
-archivePath $PWD/build/Ice.xcarchive \
archive \
CODE_SIGNING_ALLOWED=NO \
ONLY_ACTIVE_ARCH=NO

- name: Export .app
run: |
# Create export options plist
cat > ExportOptions.plist << EOF
<?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>method</key>
<string>mac-application</string>
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
</dict>
</plist>
EOF

# Export the archive
xcodebuild -exportArchive \
-archivePath $PWD/build/Ice.xcarchive \
-exportOptionsPlist ExportOptions.plist \
-exportPath build/

- name: Extract version from tag
id: version
run: |
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
# Extract version from tag (e.g., refs/tags/v1.0.0 -> v1.0.0)
VERSION=${GITHUB_REF#refs/tags/}
else
# For non-tag builds, use commit SHA
VERSION="build-${GITHUB_SHA:0:7}"
fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT

- name: Create DMG
run: |
# Verify the .app exists
if [ ! -d "build/Ice.app" ]; then
echo "Error: Ice.app not found in build directory"
ls -la build/
exit 1
fi

# Create a temporary directory for DMG contents
mkdir -p dmg-contents
cp -R "build/Ice.app" dmg-contents/

# Create a symbolic link to Applications folder
ln -s /Applications dmg-contents/Applications

# Create the DMG using hdiutil
hdiutil create -volname "Ice ${{ steps.version.outputs.VERSION }}" \
-srcfolder dmg-contents \
-ov -format UDZO \
"build/Ice-${{ steps.version.outputs.VERSION }}.dmg"

# Clean up temporary directory
rm -rf dmg-contents

- name: Upload DMG as artifact
uses: actions/upload-artifact@v4
with:
name: Ice-DMG-${{ steps.version.outputs.VERSION }}
path: build/*.dmg

- name: Create GitHub Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: build/*.dmg
name: Ice ${{ steps.version.outputs.VERSION }}
draft: false
prerelease: false
generate_release_notes: true
token: ${{ github.token }}
18 changes: 18 additions & 0 deletions ExportOptions.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?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>method</key>
<string>mac-application</string>
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
<key>teamID</key>
<string></string>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
2 changes: 1 addition & 1 deletion Ice/Events/EventManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ extension EventManager {
guard
let appState,
appState.settingsManager.generalSettingsManager.showOnHover,
!appState.settingsManager.generalSettingsManager.useIceBar,
!appState.settingsManager.displaySettingsManager.hasAnyDisplayWithIceBar,
isMouseInsideMenuBar
else {
return
Expand Down
12 changes: 11 additions & 1 deletion Ice/Hotkeys/HotkeyAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// Ice
//

import AppKit

enum HotkeyAction: String, Codable, CaseIterable {
// Menu Bar Sections
case toggleHiddenSection = "ToggleHiddenSection"
Expand Down Expand Up @@ -40,7 +42,15 @@ enum HotkeyAction: String, Codable, CaseIterable {
case .searchMenuBarItems:
await appState.menuBarManager.searchPanel.toggle()
case .enableIceBar:
appState.settingsManager.generalSettingsManager.useIceBar.toggle()
// Toggle Ice Bar on the current display (where mouse is, or main display)
let targetScreen = NSScreen.screenWithMouse ?? NSScreen.main
if let targetScreen {
let displayManager = appState.settingsManager.displaySettingsManager
let currentConfig = displayManager.configuration(for: targetScreen.displayID)
var newConfig = currentConfig
newConfig.useIceBar.toggle()
displayManager.setConfiguration(newConfig, for: targetScreen.displayID)
}
case .showSectionDividers:
appState.settingsManager.advancedSettingsManager.showSectionDividers.toggle()
case .toggleApplicationMenus:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ enum SettingsNavigationIdentifier: String, NavigationIdentifier {
case general = "General"
case menuBarLayout = "Menu Bar Layout"
case menuBarAppearance = "Menu Bar Appearance"
case displays = "Displays"
case hotkeys = "Hotkeys"
case advanced = "Advanced"
case about = "About"
Expand Down
6 changes: 3 additions & 3 deletions Ice/MenuBar/ControlItem/ControlItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -264,16 +264,16 @@ final class ControlItem {
}
.store(in: &c)

appState.settingsManager.generalSettingsManager.$useIceBar
appState.settingsManager.displaySettingsManager.anyIceBarUsageChanged
.receive(on: DispatchQueue.main)
.sink { [weak self] useIceBar in
.sink { [weak self] anyIceBarInUse in
guard
let self,
let button = statusItem.button
else {
return
}
if useIceBar {
if anyIceBarInUse {
button.sendAction(on: [.leftMouseDown, .rightMouseUp])
} else {
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
Expand Down
8 changes: 7 additions & 1 deletion Ice/MenuBar/MenuBarSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,13 @@ final class MenuBarSection {

/// A Boolean value that indicates whether the Ice Bar should be used.
private var useIceBar: Bool {
appState?.settingsManager.generalSettingsManager.useIceBar ?? false
guard let appState else { return false }

// Get the screen where the Ice Bar would appear
guard let screen = screenForIceBar else { return false }

// Check per-display configuration
return appState.settingsManager.displaySettingsManager.shouldUseIceBar(on: screen)
}

/// A weak reference to the menu bar manager's Ice Bar panel.
Expand Down
175 changes: 175 additions & 0 deletions Ice/Settings/SettingsManagers/DisplaySettingsManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
//
// DisplaySettingsManager.swift
// Ice
//

import AppKit
import Combine
import Foundation

/// Configuration for Ice Bar on a specific display.
struct DisplayIceBarConfiguration: Codable, Hashable {
/// Whether to use Ice Bar on this display.
var useIceBar: Bool

/// The location where the Ice Bar appears on this display.
var iceBarLocation: IceBarLocation

init(useIceBar: Bool = false, iceBarLocation: IceBarLocation = .dynamic) {
self.useIceBar = useIceBar
self.iceBarLocation = iceBarLocation
}
}

/// Manages per-display settings.
@MainActor
final class DisplaySettingsManager: ObservableObject {
/// Per-display Ice Bar configurations, keyed by display ID.
@Published var displayConfigurations: [CGDirectDisplayID: DisplayIceBarConfiguration] = [:]

/// Publisher that emits whenever any display's Ice Bar usage changes.
var anyIceBarUsageChanged: AnyPublisher<Bool, Never> {
$displayConfigurations
.map { configurations in
configurations.values.contains { $0.useIceBar }
}
.removeDuplicates()
.eraseToAnyPublisher()
}

/// Encoder for properties.
private let encoder = JSONEncoder()

/// Decoder for properties.
private let decoder = JSONDecoder()

/// Storage for internal observers.
private var cancellables = Set<AnyCancellable>()

/// The shared app state.
private(set) weak var appState: AppState?

init(appState: AppState) {
self.appState = appState
}

func performSetup() {
loadInitialState()
configureCancellables()
}

private func loadInitialState() {
if let data = Defaults.data(forKey: .displayConfigurations) {
do {
// Decode as [String: DisplayIceBarConfiguration] first, then convert keys
let stringKeyedDict = try decoder.decode([String: DisplayIceBarConfiguration].self, from: data)
displayConfigurations = Dictionary(
uniqueKeysWithValues: stringKeyedDict.compactMap { (key, value) in
guard let displayID = CGDirectDisplayID(key) else { return nil }
return (displayID, value)
}
)
} catch {
Logger.displaySettingsManager.error("Error decoding display configurations: \(error)")
}
}

// Migrate from legacy useIceBar setting
migrateFromLegacySetting()
}

private func migrateFromLegacySetting() {
// If we have the old useIceBar setting but no display configurations,
// apply it to all connected displays
guard displayConfigurations.isEmpty else { return }

let legacyUseIceBar = Defaults.bool(forKey: .useIceBar) ?? false
let legacyIceBarLocation = IceBarLocation(rawValue: Defaults.integer(forKey: .iceBarLocation)) ?? .dynamic

if legacyUseIceBar || legacyIceBarLocation != .dynamic {
for screen in NSScreen.screens {
let config = DisplayIceBarConfiguration(
useIceBar: legacyUseIceBar,
iceBarLocation: legacyIceBarLocation
)
displayConfigurations[screen.displayID] = config
}
}
}

private func configureCancellables() {
var c = Set<AnyCancellable>()

$displayConfigurations
.receive(on: DispatchQueue.main)
.sink { [weak self] configurations in
guard let self else { return }
do {
// Convert to [String: DisplayIceBarConfiguration] for JSON encoding
let stringKeyedDict = Dictionary(
uniqueKeysWithValues: configurations.map { (key, value) in
(String(key), value)
}
)
let data = try encoder.encode(stringKeyedDict)
Defaults.set(data, forKey: .displayConfigurations)
} catch {
Logger.displaySettingsManager.error("Error encoding display configurations: \(error)")
}
}
.store(in: &c)

// React to display changes
NotificationCenter.default
.publisher(for: NSApplication.didChangeScreenParametersNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.ensureConfigurationsForAllDisplays()
}
.store(in: &c)

cancellables = c
}

/// Ensures all connected displays have configurations.
private func ensureConfigurationsForAllDisplays() {
for screen in NSScreen.screens {
if displayConfigurations[screen.displayID] == nil {
displayConfigurations[screen.displayID] = DisplayIceBarConfiguration()
}
}
}

/// Returns the Ice Bar configuration for the given display.
func configuration(for displayID: CGDirectDisplayID) -> DisplayIceBarConfiguration {
return displayConfigurations[displayID] ?? DisplayIceBarConfiguration()
}

/// Sets the Ice Bar configuration for the given display.
func setConfiguration(_ configuration: DisplayIceBarConfiguration, for displayID: CGDirectDisplayID) {
displayConfigurations[displayID] = configuration
}

/// Returns whether Ice Bar should be used on the given display.
func shouldUseIceBar(on displayID: CGDirectDisplayID) -> Bool {
return configuration(for: displayID).useIceBar
}

/// Returns whether Ice Bar should be used on the given screen.
func shouldUseIceBar(on screen: NSScreen) -> Bool {
return shouldUseIceBar(on: screen.displayID)
}

/// Returns whether any display has Ice Bar enabled.
var hasAnyDisplayWithIceBar: Bool {
return displayConfigurations.values.contains { $0.useIceBar }
}
}

// MARK: DisplaySettingsManager: BindingExposable
extension DisplaySettingsManager: BindingExposable { }

// MARK: - Logger
private extension Logger {
static let displaySettingsManager = Logger(category: "DisplaySettingsManager")
}
4 changes: 4 additions & 0 deletions Ice/Settings/SettingsManagers/GeneralSettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,13 @@ final class GeneralSettingsManager: ObservableObject {

/// A Boolean value that indicates whether to show hidden items
/// in a separate bar below the menu bar.
/// - Note: This property is deprecated. Use DisplaySettingsManager for per-display Ice Bar configuration.
@available(*, deprecated, message: "Use DisplaySettingsManager for per-display Ice Bar configuration")
@Published var useIceBar = false

/// The location where the Ice Bar appears.
/// - Note: This property is deprecated. Use DisplaySettingsManager for per-display Ice Bar configuration.
@available(*, deprecated, message: "Use DisplaySettingsManager for per-display Ice Bar configuration")
@Published var iceBarLocation: IceBarLocation = .dynamic

/// A Boolean value that indicates whether the hidden section
Expand Down
Loading