Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions BatteryWidget/BatteryWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ struct Provider: StandardProvider {
}

struct BatteryWidgetEntryView: View {
var preferenceEntry = Container.get(PreferenceEntry.self) ?? PreferenceEntry()
var entry: Provider.Entry

var body: some View {
Expand Down Expand Up @@ -67,6 +68,7 @@ struct BatteryWidgetEntryView: View {
WidgetNotAvailbleView(text: "widget.not_available".localized())
}
}
.preferredColorScheme(preferenceEntry.colorScheme)
}
}

Expand Down
87 changes: 87 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Build & Run

```bash
# Build the app
xcodebuild -scheme eul -project ./eul.xcodeproj -sdk macosx build

# Format code (via BuildTools SPM package)
cd BuildTools && swift run -c release swiftformat ../

# Lint format (CI check)
cd BuildTools && swift run -c release swiftformat ../ --lint

# Release build (no signing)
xcodebuild -scheme eul -project ./eul.xcodeproj -sdk macosx build CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED="NO" CODE_SIGN_ENTITLEMENTS="" CODE_SIGNING_ALLOWED="NO"
```

CI runs on `macos-latest` with Xcode 12.4 (for Big Sur compatibility). Open `eul.xcodeproj` in Xcode to run/debug.

## Architecture

**eul** is a macOS menu bar hardware monitor built with SwiftUI (min deployment: macOS 10.15).

### Targets

| Target | Purpose |
|--------|---------|
| `eul` | Main app — menu bar items, preferences window, hardware polling |
| `SharedLibrary` | Shared framework — models, widget data, reusable UI components |
| `BatteryWidget`, `CpuWidget`, `MemoryWidget`, `NetworkWidget` | macOS 11+ Big Sur widgets |
| `SelfUpdate` | Auto-update UI (separate process) |
| `BuildTools` | SwiftFormat as a local SPM dependency |

### Main App Structure (eul/)

```
eul/
AppDelegate.swift # @NSApplicationMain, window + lifecycle + polling loops
Store/ # ObservableObject stores (Combine @Published)
Schema/ # Enums, models, protocols
TextComponents/ # Per-component text display configs
Views/
StatusBar/ # NSStatusBar item SwiftUI views (per component)
Menu/ # Drop-down menu views (per component)
Preference/ # Preferences window views
Chart/ # LineChart SwiftUI view
Components/ # Reusable SwiftUI components
StatusBar/ # NSStatusBar management (StatusBarManager, StatusBarItem)
Extension/ # Swift extensions
Utilities/ # Hardware interaction (SMC, IOKit, shell, GPU info)
ViewModifier/ # Custom SwiftUI view modifiers
```

### Data Flow

1. **Hardware polling** — `AppDelegate` runs timed refresh loops posting `.SMCShouldRefresh` and `.NetworkShouldRefresh` notifications
2. **SmcControl** singleton reads sensor data via SMCKit, posts `.StoreShouldRefresh`
3. **Stores** (e.g. `CpuStore`, `BatteryStore`) observe refresh notifications via `Refreshable` protocol, update `@Published` properties
4. **StatusBarManager** subscribes to store changes, re-renders `NSStatusBar` items with SwiftUI views
5. **SharedStore** enum holds all store singletons, also provides `withGlobalEnvironmentObjects()` view modifier to inject all stores as `@EnvironmentObject`

### Key Singletons

- `SmcControl.shared` — SMC hardware interface (temperatures, fan speeds)
- `StatusBarManager.shared` — menu bar item lifecycle
- `SharedStore.*` — all store instances (battery, cpu, gpu, memory, network, disk, fan, bluetooth, preference, ui, components, etc.)

### Preferences

Persisted to `UserDefaults` as JSON under key `"preference"`. `PreferenceStore` handles load/save, with change observation via Combine. Supports temperature unit, language, text display mode, refresh rates, component visibility, appearance mode, and update settings.

### Dependencies (SPM)

- **SMCKit** — temperature sensor and fan reading via AppleSMC
- **SwiftyJSON** — JSON serialization for preferences and GitHub API
- **Localize-Swift** — i18n (20+ languages in `Resource/`)

### Widgets (macOS 11+)

Each widget target shares data through `SharedLibrary/Container` which accepts widget `Entry` types and makes them available to `TimelineProvider`. Widget sections are built with `WidgetSectionView`.

### Localization

`Resource/` contains `.lproj` directories with `Localizable.strings` for each language. Strings are localized via `Localize-Swift` using `.localized()` calls throughout the codebase.
1 change: 1 addition & 0 deletions CpuWidget/CpuWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ struct CpuWidgetEntryView: View {
WidgetNotAvailbleView(text: "widget.not_available".localized())
}
}
.preferredColorScheme(preferenceEntry.colorScheme)
}
}

Expand Down
1 change: 1 addition & 0 deletions MemoryWidget/MemoryWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ struct MemoryWidgetEntryView: View {
WidgetNotAvailbleView(text: "widget.not_available".localized())
}
}
.preferredColorScheme(preferenceEntry.colorScheme)
}
}

Expand Down
2 changes: 2 additions & 0 deletions NetworkWidget/NetworkWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ struct Provider: StandardProvider {
}

struct NetworkWidgetEntryView: View {
var preferenceEntry = Container.get(PreferenceEntry.self) ?? PreferenceEntry()
var entry: Provider.Entry

var body: some View {
Expand Down Expand Up @@ -55,6 +56,7 @@ struct NetworkWidgetEntryView: View {
WidgetNotAvailbleView(text: "widget.not_available".localized())
}
}
.preferredColorScheme(preferenceEntry.colorScheme)
}
}

Expand Down
11 changes: 1 addition & 10 deletions SharedLibrary/Extension/String.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,7 @@ public extension String {
}

var splittedByWhitespace: [String] {
guard let trimWhiteSpaceRegEx = try? NSRegularExpression(pattern: "/ +/g") else {
return []
}
let trimmed = trimWhiteSpaceRegEx.stringByReplacingMatches(
in: self,
options: [],
range: NSRange(location: 0, length: count),
withTemplate: " "
)
return trimmed.split(separator: " ").map { String($0) }
split(separator: " ").filter { !$0.isEmpty }.map { String($0) }
}

var numericOnly: String {
Expand Down
14 changes: 12 additions & 2 deletions SharedLibrary/Schema/PreferenceEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,24 @@
// Copyright © 2020 Gao Sun. All rights reserved.
//

import Foundation
import SwiftUI

public struct PreferenceEntry: SharedEntry {
public init(temperatureUnit: TemperatureUnit = TemperatureUnit.celius) {
public init(temperatureUnit: TemperatureUnit = TemperatureUnit.celius, appearanceMode: String = "auto") {
self.temperatureUnit = temperatureUnit
self.appearanceMode = appearanceMode
}

public static let containerKey = "PreferenceEntry"

public var temperatureUnit = TemperatureUnit.celius
public var appearanceMode = "auto"

public var colorScheme: SwiftUI.ColorScheme? {
switch appearanceMode {
case "dark": return .dark
case "light": return .light
default: return nil
}
}
}
4 changes: 2 additions & 2 deletions SharedLibrary/Schema/StandardProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@

import WidgetKit

@available(OSXApplicationExtension 11, *)
@available(macOSApplicationExtension 11, *)
public protocol StandardProvider: TimelineProvider {
associatedtype WidgetEntry: SharedWidgetEntry
}

@available(OSXApplicationExtension 11, *)
@available(macOSApplicationExtension 11, *)
public extension StandardProvider {
func placeholder(in _: Context) -> WidgetEntry {
Container.get(WidgetEntry.self) ?? WidgetEntry.sample
Expand Down
2 changes: 1 addition & 1 deletion eul.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1345,7 +1345,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "cd BuildTools\nSDKROOT=macosx\n#swift package update #Uncomment this line temporarily to update the version used to the latest matching your BuildTools/Package.swift file\nswift run -c release swiftformat \"$SRCROOT\"\n";
shellScript = "cd BuildTools\n\n#swift package update #Uncomment this line temporarily to update the version used to the latest matching your BuildTools/Package.swift file\nswift run -c release swiftformat \"$SRCROOT\"\n";
};
6CC0798D250CEE96000D7DAC /* Copy LaunchAtLogin helper */ = {
isa = PBXShellScriptBuildPhase;
Expand Down
1 change: 1 addition & 0 deletions eul/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
appearanceCancellable = preferenceStore.$appearanceMode.sink { mode in
DispatchQueue.main.async {
self.window.appearance = mode.nsAppearance
NSApp.appearance = mode.nsAppearance
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions eul/StatusBar/StatusBarItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ class StatusBarItem: NSObject, NSMenuDelegate {
statusBarMenu.delegate = self
item.autosaveName = named
item.isVisible = false
// Keep the item allocated so button is available on all macOS versions
item.length = 0

if let menuBuilder = config.menuBuilder {
let customItem = NSMenuItem()
Expand Down
16 changes: 12 additions & 4 deletions eul/StatusBar/StatusBarManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,19 @@ class StatusBarManager {
}
}

func render(components _: [EulComponent]) {
item.isVisible = false
func render(components: [EulComponent]) {
let shouldShow = !components.isEmpty

DispatchQueue.main.async {
self.item.isVisible = true
guard item.isVisible != shouldShow else {
return
}

item.isVisible = shouldShow

if shouldShow {
DispatchQueue.main.async {
self.refresh()
}
}
}
}
21 changes: 19 additions & 2 deletions eul/Store/DiskStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,29 @@ class DiskStore: ObservableObject, Refreshable {
return list?.disks.filter { $0.name == config.diskSelection }.first
}

private var rootAttributes: (size: UInt64, free: UInt64)? {
guard
let attrs = try? FileManager.default.attributesOfFileSystem(forPath: "/"),
let size = attrs[.systemSize] as? UInt64,
let free = attrs[.systemFreeSize] as? UInt64
else {
return nil
}
return (size, free)
}

var ceilingBytes: UInt64? {
selectedDisk?.size ?? list?.disks.reduce(0) { $0 + $1.size }
if let selected = selectedDisk {
return selected.size
}
return rootAttributes?.size
}

var freeBytes: UInt64? {
selectedDisk?.freeSize ?? list?.disks.reduce(0) { $0 + $1.freeSize }
if let selected = selectedDisk {
return selected.freeSize
}
return rootAttributes?.free
}

var usageString: String {
Expand Down
15 changes: 11 additions & 4 deletions eul/Store/NetworkStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,24 @@ class NetworkStore: ObservableObject, Refreshable {

networkUsageHasBeenSet = false

// Reset the guard after 10s in case the async command never completes
DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [self] in
networkUsageHasBeenSet = true
}

Info.getNetworkUsage(forDevice: config.networkPortSelection.nilIfEmpty) { [self] current, ports, currentActivePort in
let time = Date().timeIntervalSince1970

if networkUsage.inBytes > 0, current.inBytes >= networkUsage.inBytes {
inSpeedInByte = Double(current.inBytes - networkUsage.inBytes) / (time - lastTimestamp)
if networkUsage.inBytes > 0 {
let delta = current.inBytes >= networkUsage.inBytes ? current.inBytes - networkUsage.inBytes : 0
inSpeedInByte = Double(delta) / (time - lastTimestamp)
} else {
inSpeedInByte = 0
}

if networkUsage.outBytes > 0, current.outBytes >= networkUsage.outBytes {
outSpeedInByte = Double(current.outBytes - networkUsage.outBytes) / (time - lastTimestamp)
if networkUsage.outBytes > 0 {
let delta = current.outBytes >= networkUsage.outBytes ? current.outBytes - networkUsage.outBytes : 0
outSpeedInByte = Double(delta) / (time - lastTimestamp)
} else {
outSpeedInByte = 0
}
Expand Down
2 changes: 1 addition & 1 deletion eul/Store/PreferenceStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ class PreferenceStore: ObservableObject {
}

func writeToContainer() {
Container.set(PreferenceEntry(temperatureUnit: temperatureUnit))
Container.set(PreferenceEntry(temperatureUnit: temperatureUnit, appearanceMode: appearanceMode.rawValue))
if #available(OSX 11, *) {
WidgetCenter.shared.reloadAllTimelines()
}
Expand Down
17 changes: 15 additions & 2 deletions eul/Store/TopStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,26 @@ class TopStore: ObservableObject {
return nil
}

let usage = 100 * (ram / self.memorySizeMB)
// top's rsize: no suffix = KB, suffix K/M/G = that unit
let ramInMB: Double
if let suffix = rawRamString.last, suffix.isLetter {
switch suffix {
case "K": ramInMB = ram / 1024
case "M": ramInMB = ram
case "G": ramInMB = ram * 1024
default: ramInMB = ram / 1024 // treat unknown as KB
}
} else {
ramInMB = ram / 1024 // older macOS: raw KB
}

let usage = 100 * (ramInMB / self.memorySizeMB)

return RamUsage(
pid: pid,
command: Info.getProcessCommand(pid: pid)!,
value: usage,
usageAmount: ram,
usageAmount: ramInMB,
runningApp: runningApps.first(where: { $0.processIdentifier == pid })
)
}
Expand Down
9 changes: 6 additions & 3 deletions eul/Utilities/Info.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ enum Info {
}

static func getActiveInterfaces() -> [String] {
shell("ifconfig")?.split(separator: "\n").map { String($0) }.reduce([InterfaceStatus]()) {
let physicalInterfacePrefixes = ["en", "ap"]
let excludedInterfaces = ["lo0", "awdl", "llw", "utun"]

return shell("ifconfig")?.split(separator: "\n").map { String($0) }.reduce([InterfaceStatus]()) {
// new interface
if !$1.hasPrefix("\t") {
guard let colonIndex = $1.firstIndex(of: ":") else {
Expand All @@ -162,8 +165,8 @@ enum Info {
}

return $0.dropLast().appending(InterfaceStatus(name: lastInterface.name, status: splitted[1]))
}.compactMap {
$0.status == "active" ? $0.name : nil
}.compactMap { iface in
iface.status == "active" && (physicalInterfacePrefixes.contains { iface.name.hasPrefix($0) }) ? iface.name : nil
} ?? []
}

Expand Down
Loading