Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,14 @@ jobs:
- name: Build app bundle
run: ./build_app.sh

- name: Validate app bundle
run: ./scripts/validate_app_bundle.sh dist/ProcessBarMonitor.app

- name: Archive app bundle
run: |
mkdir -p release
ditto -c -k --sequesterRsrc --keepParent dist/ProcessBarMonitor.app release/ProcessBarMonitor-ci-macOS.zip
shasum -a 256 release/ProcessBarMonitor-ci-macOS.zip > release/ProcessBarMonitor-ci-SHA256SUMS.txt

- name: Upload app artifact
uses: actions/upload-artifact@v4
Expand All @@ -37,6 +41,7 @@ jobs:
path: |
dist/ProcessBarMonitor.app
release/ProcessBarMonitor-ci-macOS.zip
release/ProcessBarMonitor-ci-SHA256SUMS.txt

release-validation:
if: startsWith(github.ref, 'refs/tags/v')
Expand Down
9 changes: 7 additions & 2 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,27 @@ Examples:

## What the script does

- builds the app bundle
- builds the app bundle in `release` configuration
- stamps version/build into `Info.plist`
- validates the generated `.app` bundle contents
- zips `dist/ProcessBarMonitor.app`
- writes `SHA256SUMS` for the zip
- creates or updates the Git tag
- creates or updates the GitHub Release
- uploads the zipped release asset
- uploads the zipped release asset and checksum file
- marks the release as latest

## Preconditions

- working tree must be clean
- `gh` must be installed and authenticated
- you need permission to push tags and create releases for the repo
- default release branch is `main` (override intentionally with `ALLOW_NON_MAIN_RELEASE=1`)

## Notes

- release assets are written to `release/`
- release notes are generated into `release/release-notes-vX.Y.Z.md`
- checksum file is generated as `release/ProcessBarMonitor-vX.Y.Z-SHA256SUMS.txt`
- current release helper script: `release.sh`
- bundle validation helper: `scripts/validate_app_bundle.sh`
40 changes: 40 additions & 0 deletions Sources/ProcessBarMonitor/Diagnostics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation

struct ProcessSnapshotDiagnostics {
var attemptCount: Int = 0
var successCount: Int = 0
var failureCount: Int = 0
var lastAttemptAt: Date?
var lastSuccessAt: Date?
var lastFailureAt: Date?
var lastFailureMessage: String?
var lastFailureDetails: String?
var lastSnapshotProcessCount: Int = 0
var lastTopCPUCount: Int = 0
var lastTopMemoryCount: Int = 0

mutating func markAttempt(at date: Date = Date()) {
attemptCount += 1
lastAttemptAt = date
}

mutating func markSuccess(
processCount: Int,
topCPUCount: Int,
topMemoryCount: Int,
at date: Date = Date()
) {
successCount += 1
lastSuccessAt = date
lastSnapshotProcessCount = processCount
lastTopCPUCount = topCPUCount
lastTopMemoryCount = topMemoryCount
}

mutating func markFailure(message: String, details: String, at date: Date = Date()) {
failureCount += 1
lastFailureAt = date
lastFailureMessage = message
lastFailureDetails = details
}
}
78 changes: 78 additions & 0 deletions Sources/ProcessBarMonitor/MonitorViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Foundation
import Combine
import AppKit
import OSLog

private struct SettingsStore {
let defaults: UserDefaults
Expand Down Expand Up @@ -33,6 +35,7 @@ final class MonitorViewModel: ObservableObject {
@Published private(set) var temperatureHistory: [MetricPoint] = []
@Published private(set) var isMenuExpanded = false
@Published var statusMessage: String?
@Published private(set) var processDiagnostics = ProcessSnapshotDiagnostics()

@Published var searchText = ""
@Published var processLimit: Int {
Expand All @@ -48,8 +51,11 @@ final class MonitorViewModel: ObservableObject {
private let metricsProvider = SystemMetricsProvider()
private let processProvider = ProcessSnapshotProvider()
private let settings: SettingsStore
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ai.openclaw.ProcessBarMonitor", category: "process-snapshot")
private var refreshTask: Task<Void, Never>?
private var lastProcessRefresh = Date.distantPast
private var lastLoggedFailureSignature: String?
private var lastLoggedFailureAt = Date.distantPast

private enum Keys {
static let processLimit = "processLimit"
Expand Down Expand Up @@ -142,13 +148,23 @@ final class MonitorViewModel: ObservableObject {
appendHistory(cpu: snapshotSummary.cpuPercent, memory: snapshotSummary.memoryPressurePercent, temperature: snapshotSummary.cpuTemperatureC)

if let processResult = await processTask {
processDiagnostics.markAttempt()
switch processResult {
case .success(let processes):
allProcesses = processes
lastProcessRefresh = Date()
recomputeVisibleProcesses()
processDiagnostics.markSuccess(
processCount: processes.count,
topCPUCount: topCPUProcesses.count,
topMemoryCount: topMemoryProcesses.count
)
statusMessage = nil
case .failure(let error):
let message = error.localizedDescription
let details = String(describing: error)
processDiagnostics.markFailure(message: message, details: details)
logProcessSnapshotFailureIfNeeded(message: message, details: details)
statusMessage = L10n.format("status.failed_to_load_top_apps", error.localizedDescription)
}
}
Expand Down Expand Up @@ -207,6 +223,17 @@ final class MonitorViewModel: ObservableObject {
statusMessage = nil
}

func copyDiagnosticsToPasteboard() {
let report = diagnosticsReport()
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
if pasteboard.setString(report, forType: .string) {
statusMessage = L10n.string("status.diagnostics_copied")
} else {
statusMessage = L10n.string("status.diagnostics_copy_failed")
}
}

func thermalText(_ state: ProcessInfo.ThermalState) -> String {
switch state {
case .nominal: return L10n.string("thermal.nominal")
Expand All @@ -216,4 +243,55 @@ final class MonitorViewModel: ObservableObject {
@unknown default: return L10n.string("thermal.unknown")
}
}

private func logProcessSnapshotFailureIfNeeded(message: String, details: String) {
let signature = "\(message)|\(details)"
let now = Date()
let minInterval: TimeInterval = 60

if signature == lastLoggedFailureSignature, now.timeIntervalSince(lastLoggedFailureAt) < minInterval {
return
}

lastLoggedFailureSignature = signature
lastLoggedFailureAt = now
logger.error("Process snapshot failed. attempts=\(self.processDiagnostics.attemptCount, privacy: .public) failures=\(self.processDiagnostics.failureCount, privacy: .public) message=\(message, privacy: .public) details=\(details, privacy: .public)")
}

private func diagnosticsReport() -> String {
let info = Bundle.main.infoDictionary ?? [:]
let appVersion = (info["CFBundleShortVersionString"] as? String) ?? "unknown"
let appBuild = (info["CFBundleVersion"] as? String) ?? "unknown"
let osVersion = ProcessInfo.processInfo.operatingSystemVersionString
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let now = iso.string(from: Date())

func format(_ date: Date?) -> String {
guard let date else { return "n/a" }
return iso.string(from: date)
}

return """
ProcessBarMonitor Diagnostics
generated_at: \(now)
app_version: \(appVersion)
app_build: \(appBuild)
os: \(osVersion)
process_snapshot_attempts: \(processDiagnostics.attemptCount)
process_snapshot_successes: \(processDiagnostics.successCount)
process_snapshot_failures: \(processDiagnostics.failureCount)
process_snapshot_last_attempt: \(format(processDiagnostics.lastAttemptAt))
process_snapshot_last_success: \(format(processDiagnostics.lastSuccessAt))
process_snapshot_last_failure: \(format(processDiagnostics.lastFailureAt))
process_snapshot_last_process_count: \(processDiagnostics.lastSnapshotProcessCount)
top_cpu_rows: \(processDiagnostics.lastTopCPUCount)
top_memory_rows: \(processDiagnostics.lastTopMemoryCount)
process_snapshot_last_error: \(processDiagnostics.lastFailureMessage ?? "n/a")
process_snapshot_last_error_detail: \(processDiagnostics.lastFailureDetails ?? "n/a")
status_message: \(statusMessage ?? "n/a")
menu_expanded: \(isMenuExpanded)
search_text: \(searchText)
"""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@
"note.temperature.arm64_unavailable" = "Apple Silicon HID sensors were checked for %@, but no valid value was exposed right now.";
"note.temperature.intel_hint" = "Intel: install osx-cpu-temp or istats to show a real CPU temperature.";
"note.temperature.no_source" = "No valid CPU temperature source found.";
"button.copy_diagnostics" = "Copy Diagnostics";
"status.diagnostics_copied" = "Diagnostics copied to clipboard.";
"status.diagnostics_copy_failed" = "Failed to copy diagnostics.";
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@
"note.temperature.arm64_unavailable" = "已检查 %@ 的 Apple Silicon HID 传感器,但当前未返回有效值。";
"note.temperature.intel_hint" = "Intel 机型:安装 osx-cpu-temp 或 istats 以显示真实 CPU 温度。";
"note.temperature.no_source" = "未找到有效的 CPU 温度来源。";
"button.copy_diagnostics" = "复制诊断信息";
"status.diagnostics_copied" = "诊断信息已复制到剪贴板。";
"status.diagnostics_copy_failed" = "复制诊断信息失败。";
4 changes: 4 additions & 0 deletions Sources/ProcessBarMonitor/Views.swift
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,10 @@ struct MenuBarContentView: View {
}
.disabled(viewModel.isRefreshing)

Button(L10n.string("button.copy_diagnostics")) {
viewModel.copyDiagnosticsToPasteboard()
}

Button(L10n.string("button.quit")) {
quitApplication()
}
Expand Down
88 changes: 78 additions & 10 deletions build_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,92 @@ set -euo pipefail

APP_NAME="ProcessBarMonitor"
BUNDLE_ID="ai.openclaw.ProcessBarMonitor"
VERSION="${1:-1.0.0}"
BUILD_NUMBER="${2:-1}"
ROOT="$(cd "$(dirname "$0")" && pwd)"
APP_DIR="$ROOT/dist/$APP_NAME.app"
MACOS_DIR="$APP_DIR/Contents/MacOS"
RESOURCES_DIR="$APP_DIR/Contents/Resources"
BUILD_DIR="$ROOT/.build/arm64-apple-macosx/debug"
RESOURCE_BUNDLE="$BUILD_DIR/${APP_NAME}_${APP_NAME}.bundle"
VERSION="1.0.0"
BUILD_NUMBER="1"
CONFIGURATION="${CONFIGURATION:-release}"
CLEAN_BUILD=0

usage() {
cat <<EOF
Usage: ./build_app.sh [version] [build_number] [--configuration <release|debug>] [--clean]

Examples:
./build_app.sh
./build_app.sh 1.0.2 3
./build_app.sh 1.0.2 3 --configuration debug
EOF
}

POSITIONAL=()
while [[ $# -gt 0 ]]; do
case "$1" in
--configuration)
[[ $# -ge 2 ]] || { echo "--configuration requires a value"; exit 2; }
CONFIGURATION="$2"
shift 2
;;
--configuration=*)
CONFIGURATION="${1#*=}"
shift
;;
--clean)
CLEAN_BUILD=1
shift
;;
-h|--help)
usage
exit 0
;;
-*)
echo "Unknown option: $1"
usage
exit 2
;;
*)
POSITIONAL+=("$1")
shift
;;
esac
done

if [[ ${#POSITIONAL[@]} -ge 1 ]]; then VERSION="${POSITIONAL[1]}"; fi
if [[ ${#POSITIONAL[@]} -ge 2 ]]; then BUILD_NUMBER="${POSITIONAL[2]}"; fi
if [[ ${#POSITIONAL[@]} -gt 2 ]]; then
echo "Too many positional arguments."
usage
exit 2
fi

if [[ "$CONFIGURATION" != "release" && "$CONFIGURATION" != "debug" ]]; then
echo "Invalid configuration: $CONFIGURATION (expected: release or debug)"
exit 2
fi

cd "$ROOT"
if [[ "$CLEAN_BUILD" -eq 1 ]]; then
rm -rf "$ROOT/dist"
fi

swift scripts/generate_icon.swift
swift build
swift build --configuration "$CONFIGURATION"

BIN_DIR="$(swift build --configuration "$CONFIGURATION" --show-bin-path)"
EXECUTABLE_PATH="$BIN_DIR/$APP_NAME"
RESOURCE_BUNDLE="$(find "$BIN_DIR" -maxdepth 1 -type d -name "*_${APP_NAME}.bundle" | head -n 1)"

[[ -x "$EXECUTABLE_PATH" ]] || { echo "Missing built executable: $EXECUTABLE_PATH"; exit 1; }
[[ -f "$ROOT/Resources/ProcessBarMonitor.icns" ]] || { echo "Missing icon file."; exit 1; }
[[ -n "$RESOURCE_BUNDLE" ]] || { echo "Missing SwiftPM resource bundle in $BIN_DIR"; exit 1; }

rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp "$BUILD_DIR/$APP_NAME" "$MACOS_DIR/$APP_NAME"
cp "$EXECUTABLE_PATH" "$MACOS_DIR/$APP_NAME"
cp "$ROOT/Resources/ProcessBarMonitor.icns" "$RESOURCES_DIR/ProcessBarMonitor.icns"
if [ -d "$RESOURCE_BUNDLE" ]; then
cp -R "$RESOURCE_BUNDLE" "$RESOURCES_DIR/$(basename "$RESOURCE_BUNDLE")"
fi
cp -R "$RESOURCE_BUNDLE" "$RESOURCES_DIR/$(basename "$RESOURCE_BUNDLE")"
cat > "$APP_DIR/Contents/Info.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">
Expand All @@ -43,6 +110,7 @@ cat > "$APP_DIR/Contents/Info.plist" <<EOF
</dict>
</plist>
EOF
plutil -lint "$APP_DIR/Contents/Info.plist" >/dev/null

echo "Built app bundle at: $APP_DIR"
echo "Version: $VERSION ($BUILD_NUMBER)"
echo "Version: $VERSION ($BUILD_NUMBER), configuration: $CONFIGURATION"
Loading
Loading