Compare commits

..

128 Commits

Author SHA1 Message Date
tracer b4163d8c8b chore: release 26.1.11 2026-04-21 18:06:39 +02:00
tracer ef6088853c Merge branch 'feat/proxmox-layout' 2026-04-21 18:05:09 +02:00
tracer 44f4206f34 feat: add summary dashboard history charts 2026-04-21 18:03:51 +02:00
tracer 0bb4be861c chore: release 26.1.10 2026-04-21 00:18:18 +02:00
tracer 619545738a Merge branch 'groups' 2026-04-21 00:16:54 +02:00
tracer 44cc620d3d feat: add optional server groups 2026-04-21 00:15:08 +02:00
tracer 08db74f397 chore: release 26.1.9 2026-04-19 23:04:09 +02:00
tracer 9be8d41c94 fix: reduce idle interval indicator work 2026-04-19 23:02:51 +02:00
tracer d978c51fbd chore: release 26.1.8 2026-04-19 22:50:18 +02:00
tracer b8f80932ed fix: serialize ping state updates 2026-04-19 22:48:41 +02:00
tracer 11815fb807 chore: release 26.1.7 2026-04-19 16:54:45 +02:00
tracer afbb425e3b feat: add remote reboot support 2026-04-19 16:53:17 +02:00
tracer 92040e5c5e chore: release 26.1.6 2026-04-19 15:26:22 +02:00
tracer 50410488a1 docs: update install docs and release automation 2026-04-19 15:24:58 +02:00
tracer 8d6334d3b4 chore: release 26.1.5 2026-04-19 12:09:34 +02:00
tracer dc8de5a4ea fix: skip release when HEAD is already a release commit 2026-04-19 12:07:53 +02:00
tracer 3510474764 fix: stop nested push in release hook 2026-04-19 12:07:08 +02:00
tracer 9f42c68279 chore: release 26.1.4 2026-04-19 11:59:17 +02:00
tracer 031781761c fix: preserve release logs during pre-push release 2026-04-19 11:46:11 +02:00
tracer 9413c23cc5 fixed color crash 2026-04-19 11:33:15 +02:00
Micha c8d3289a9b chore: release 26.1.3 2026-01-03 18:17:27 +01:00
Micha c063add947 docs: add 26.1.3 changelog entry 2026-01-03 18:14:32 +01:00
Micha 610c569b83 docs: add 26.1.2 changelog entry 2026-01-03 18:09:50 +01:00
Micha 7d7a79cf13 chore: release 26.1.2 2026-01-03 18:05:28 +01:00
Micha 2775e44f92 docs: add 26.1.1 changelog entry 2026-01-03 18:02:34 +01:00
Micha d5cadf75ca chore: release 26.1.1 2026-01-03 16:34:23 +01:00
Micha 28104c1bc3 fix: improve changelog extraction in publish script
- Simplified awk extraction without aggressive sed cleanup
- Now correctly extracts version-specific changelog entries
- Works with minimal or full changelog sections
2026-01-03 16:32:12 +01:00
Micha 8dae638111 docs: rename 'Unreleased' to 'Prereleases' in changelog 2026-01-03 16:29:33 +01:00
Micha 670c8fe83b docs: reorganize changelog - move auto-populate to 26.1.0, update section order 2026-01-03 16:27:58 +01:00
Micha 846983649a docs: update changelog for 26.1.0 release 2026-01-03 16:25:42 +01:00
Micha 1670b030ba feat: auto-populate release description from CHANGELOG
- Extract changelog entry for current version when creating releases
- Parse CHANGELOG.md and add to release body in Gitea
- Falls back to 'See commit history for details.' if no changelog found
- Keeps release notes synchronized with version
2026-01-03 16:23:24 +01:00
Micha fc43fa9094 chore: release 26.1.0 2026-01-03 16:21:16 +01:00
Micha 4f026d6e1b fix: interval indicator respects refresh interval setting
- ServerDetailView now reads refreshInterval from AppStorage
- Progress bar duration dynamically adjusts based on user's refresh interval setting
- Previously hardcoded to 60 seconds regardless of user preference
2026-01-03 16:18:08 +01:00
Micha 06932cde21 chore: release 26.0.70 2026-01-03 15:49:43 +01:00
Micha 7a286c68e3 feat: add status notifications for server monitoring
- Add notification preferences (Status Notifications and Alert Notifications toggles)
- Implement ping failure/recovery notifications when servers go offline/online
- Track individual service status changes and notify when services fail
- Request notification permissions on app launch
- Services like DNS, FTP, SSH, etc. now trigger alerts when status changes
- Notifications only sent when settings are enabled

Changes:
- PreferencesView: Add NotificationsPreferencesView with two toggles
- PingService: Add notification support with state tracking for ping events
- MainView: Add service status monitoring with change detection
- Track previous service states to detect transitions
2026-01-03 15:48:01 +01:00
Micha e7b776942b fix: use AppStorage values for ping and refresh intervals
Make the ping and refresh intervals from preferences actually control
the timer frequencies. Settings now properly update timers when changed.
- Read pingInterval and refreshInterval from AppStorage
- Recreate timers when settings change
- Setup timers on app appearance with correct intervals
2026-01-03 14:31:51 +01:00
Micha 8cf974118b fix: add connection test feedback in server form
- Show error message when connection test fails
- Disable test button while testing
- Provide clear feedback when API key or hostname are invalid
- Reset error message on successful connection
2026-01-03 14:16:11 +01:00
Micha ae83ea7dab chore: release 26.0.69 2026-01-03 14:00:42 +01:00
Micha 39205230b6 Sparkle test 2026-01-03 13:58:38 +01:00
Micha 55a266014c chore: release 26.0.68 2026-01-03 13:52:34 +01:00
Micha c002cab616 chore: disable sandbox for now to use Sparkle updates
Disable app-sandbox to allow Sparkle auto-updates to work properly.
Keep entitlements file structure for future sandbox re-enablement.
Sandbox integration with Sparkle requires more complex authorization
setup that can be tackled later when preparing for App Store.
2026-01-03 13:50:26 +01:00
Micha 117134cead chore: release 26.0.67 2026-01-03 13:36:30 +01:00
Micha 35711d33c0 Sparkle test 2026-01-03 13:34:50 +01:00
Micha 4f3d56dc3c chore: release 26.0.66 2026-01-03 13:28:45 +01:00
Micha 0d80a0f912 fix: use basic Sparkle updater for sandboxed apps
Disable SUEnableInstallerLauncherService and remove XPC entitlements.
Use Sparkle's standard update mechanism which works with sandboxed apps.
Add file access entitlements for update storage.
2026-01-03 13:26:37 +01:00
Micha b1d6e61f05 chore: release 26.0.65 2025-12-30 20:19:29 +01:00
Micha 2dd2c2154f fix: sign Sparkle framework separately for sandboxed builds
Sign the Sparkle framework before signing the whole app to ensure
proper code signature chain for sandboxed installation.
2025-12-30 20:17:47 +01:00
Micha c6ecbbe511 chore: release 26.0.64 2025-12-30 20:14:03 +01:00
Micha 77a145604c Sparkle test 2025-12-30 20:12:24 +01:00
Micha 0016030ff3 chore: release 26.0.63 2025-12-30 20:06:55 +01:00
Micha 615d664731 fix: configure sandbox for Sparkle installer with proper entitlements
- Add downloads folder read-write access for installer
- Enable SUEnableInstallerLauncherService for sandboxed update installation
- Keep XPC service entitlements for installer communication
2025-12-30 20:04:26 +01:00
Micha 5644fbdfe0 chore: release 26.0.62 2025-12-30 19:55:55 +01:00
Micha 9b5883fe77 Sparkle test 2025-12-30 19:54:08 +01:00
Micha 5d15810802 chore: release 26.0.61 2025-12-30 19:53:12 +01:00
Micha 519d15ed10 Sparkle test 2025-12-30 19:51:35 +01:00
Micha 446bbe7f98 chore: release 26.0.60 2025-12-30 19:50:43 +01:00
Micha b67fffd3f0 fix: re-add XPC service entitlements for sandboxed Sparkle installer
Add back InstallerConnection and InstallerStatus entitlements which are
required for the sandboxed app to communicate with Sparkle's installer
XPC service.
2025-12-30 19:48:44 +01:00
Micha 84935ee8fd chore: release 26.0.59 2025-12-30 19:42:29 +01:00
Micha 0f266f7046 Sparkle test 2025-12-30 19:40:37 +01:00
Micha bff7c44c29 chore: release 26.0.58 2025-12-30 19:36:29 +01:00
Micha f930e8334f chore: remove configuration note from updates preferences
Remove the explanation text about configuring appcast URL and EdDSA key.
This is configuration for developers, not end users.
2025-12-30 19:33:14 +01:00
Micha 0032ad9b57 chore: remove update button from main toolbar
Remove the 'Check for Updates' button from the main window toolbar.
Updates are available via Preferences → Updates, which is sufficient.
2025-12-30 19:29:01 +01:00
Micha b7f5d1a762 chore: remove update logs UI from preferences
Remove the Show/Hide Logs button and logs view from the Updates preferences
tab. Keep the logging infrastructure in SparkleUpdater for diagnostics,
but don't display it in the UI.
2025-12-30 19:27:06 +01:00
Micha 5dc5621871 docs: update changelog with Sparkle updater fixes and improvements
Document the key fixes that made Sparkle updates work:
- Using ditto instead of zip to preserve code signatures
- XPC service entitlements for sandboxed apps
- In-app logging for update debugging
- Re-enabled sandbox with minimal entitlements
2025-12-30 19:23:36 +01:00
Micha 2a848c3251 chore: release 26.0.57 2025-12-30 19:20:11 +01:00
Micha bb4f972d58 feat: re-enable sandbox with minimal entitlements
- Re-enable app-sandbox
- Add network.client entitlement (required for Sparkle updates)
- Keep build script passing entitlements to codesign
- Use ditto for ZIP to preserve code signatures

This is a minimal sandbox configuration focused on security while
keeping updates working.
2025-12-30 19:14:40 +01:00
Micha 62d4a9ac96 chore: release 26.0.56 2025-12-30 19:09:35 +01:00
Micha 75fe670779 fix: use ditto instead of zip to preserve code signatures
zip breaks code signatures on macOS. Use ditto -c -k to create the update
ZIP archive while preserving the embedded code signature of the app bundle.
2025-12-30 19:07:10 +01:00
Micha a961baab28 chore: release 26.0.55 2025-12-30 18:59:42 +01:00
Micha 7246b132f6 Sparkle test 2025-12-30 18:58:04 +01:00
Micha 1afce31641 chore: release 26.0.54 2025-12-30 18:52:40 +01:00
Micha 281016bfc9 fix: remove entitlements from code signing for non-sandboxed app
For non-sandboxed apps, don't pass --entitlements to codesign.
This was causing code signature issues.
2025-12-30 18:50:41 +01:00
Micha 144ad27aa6 chore: release 26.0.53 2025-12-30 18:43:27 +01:00
Micha aa655bb7d6 Sparkle test 2025-12-30 18:41:33 +01:00
Micha 1ac34e1f04 chore: release 26.0.52 2025-12-30 18:33:44 +01:00
Micha 5f045c113a chore: simplify to non-sandboxed app with no entitlements
Remove all sandbox and XPC entitlements to test if Sparkle works without them.
2025-12-30 18:31:17 +01:00
Micha 2dbe739c97 chore: release 26.0.51 2025-12-30 18:24:24 +01:00
Micha da9dd2f7ed Sparkle test 2025-12-30 18:22:30 +01:00
Micha 2ae67b6675 chore: release 26.0.50 2025-12-30 18:10:29 +01:00
Micha 989717539c Sparkle test 2025-12-30 18:08:49 +01:00
Micha 6d05419abb chore: release 26.0.49 2025-12-30 18:03:29 +01:00
Micha be37bf526a Sparkle test 2025-12-30 18:01:57 +01:00
Micha 6c1f5c6d25 chore: release 26.0.48 2025-12-30 17:20:31 +01:00
Micha 32f97ff7d4 Sparkle test 2025-12-30 17:18:45 +01:00
Micha dbbe1752d1 fix: disable InstallerLauncherService for sandboxed app
For sandboxed apps, use standard Sparkle updater instead of the
InstallerLauncherService. Also add Downloader XPC service identifiers.
2025-12-30 16:40:27 +01:00
Micha 2fe9821ac1 chore: release 26.0.47 2025-12-30 16:07:14 +01:00
Micha 87d4bffb99 Sparkle test 2025-12-30 16:05:16 +01:00
Micha 92782716fc chore: release 26.0.46 2025-12-30 16:00:25 +01:00
Micha 002c9e8cf2 Sparkle test 2025-12-30 15:58:45 +01:00
Micha 8820244589 chore: release 26.0.45 2025-12-30 15:51:46 +01:00
Micha 48d2f0ea42 Sparkle test 2025-12-30 15:49:57 +01:00
Micha 1947d05d78 fix: specify exact Sparkle XPC service identifiers
Change XPC entitlements from boolean true to arrays with specific
service identifiers for InstallerConnection and InstallerStatus.
2025-12-30 15:45:10 +01:00
Micha 86039cd5a9 chore: release 26.0.44 2025-12-30 15:35:40 +01:00
Micha 4f9c008498 Sparkle test 2025-12-30 15:33:58 +01:00
Micha 76818578b9 chore: remove duplicate v26.0.43 entry with incorrect size 2025-12-30 15:31:51 +01:00
Micha 9070882f38 fix: add XPC service entitlements for Sparkle installer
Add com.apple.security.xpc.aConnectionServices and
com.apple.security.xpc.aStatusServices entitlements to allow sandboxed
app to communicate with Sparkle's Installer and Downloader XPC services.
2025-12-30 15:27:19 +01:00
Micha 541927c30a chore: release 26.0.43 2025-12-30 15:18:43 +01:00
Micha ab3a7ca469 Sparkle test 2025-12-30 15:17:03 +01:00
Micha ee27efc0d4 chore: release 26.0.42 2025-12-30 14:23:40 +01:00
Micha c3f445e3c3 Sparkle test 2025-12-30 14:21:53 +01:00
Micha 215c24d5a2 improvement: enhance Sparkle error logging with error codes
Add error domain and code to abort error messages to help diagnose installation failures.
2025-12-30 13:27:35 +01:00
Micha b96b018f70 chore: release 26.0.41 2025-12-30 13:18:57 +01:00
Micha 65a65939a7 chore: remove local appcast testing scripts
Remove make_local_appcast.sh and serve_local_appcast.sh as they added
complexity without sufficient benefit. Test updates directly with published releases.
2025-12-30 13:15:27 +01:00
Micha 25723b7f07 feat: add in-app Sparkle update logging
- Add published logMessages array to SparkleUpdater to track all update events
- Display logs in the Updates preferences tab with Show/Hide toggle
- Each log entry is timestamped and shows both info and error messages
- Logs persist during session with max 100 entries
- Users can clear logs manually
- Helps diagnose update failures directly in the app UI
2025-12-30 13:12:27 +01:00
Micha 10683ebc73 chore: release 26.0.40 2025-12-30 13:01:20 +01:00
Micha 393bcf27e1 Sparkle test 2025-12-30 12:59:38 +01:00
Micha 839a513fde chore: release 26.0.39 2025-12-30 12:48:55 +01:00
Micha 77e82753ba chore: remove duplicate appcast entry for v26.0.38
Remove the old build 79 entry (from Dec 8) and keep only the new properly-signed build 80.
2025-12-30 12:46:35 +01:00
Micha bbb0b580b0 chore: release 26.0.38 2025-12-08 19:31:41 +01:00
Micha dd225b2b8e Sparkle fixes 2025-12-08 19:30:12 +01:00
Micha 76b01352ac chore: release 26.0.37 2025-12-08 19:07:36 +01:00
Micha fcca8cee38 Sparkle fixes 2025-12-08 19:05:49 +01:00
Micha 94d1b3fec4 chore: release 26.0.36 2025-12-08 18:45:18 +01:00
Micha 4352ae1476 Sparkle fixes 2025-12-08 18:43:48 +01:00
Micha 846e0b149b chore: release 26.0.35 2025-12-08 18:37:49 +01:00
Micha 11ca4dbede Sparkle fixes 2025-12-08 18:36:23 +01:00
Micha 1d8bdfe491 chore: release 26.0.34 2025-12-08 18:27:08 +01:00
Micha 4f5a07822f Sparkle fixes 2025-12-08 18:25:34 +01:00
Micha 67709dfda6 chore: release 26.0.33 2025-12-07 20:22:09 +01:00
Micha 6753226087 Sparkle fixes 2025-12-07 20:20:34 +01:00
Micha a3671acf38 chore: release 26.0.32 2025-12-07 20:18:04 +01:00
Micha 0aa773a0b3 Sparkle fixes 2025-12-07 20:16:37 +01:00
Micha adbc061d0b chore: release 26.0.31 2025-12-07 17:52:45 +01:00
Micha 4deae63d43 Sparkle fixes 2025-12-07 17:50:58 +01:00
Micha b570006074 chore: release 26.0.30 2025-12-07 17:48:38 +01:00
Micha fd0d8d1adb Sparkle fixes 2025-12-07 17:46:59 +01:00
Micha 78d5bd9bd5 chore: release 26.0.29 2025-12-07 17:07:33 +01:00
Micha 091fd4ef38 Sparkle fixes 2025-12-07 17:05:51 +01:00
34 changed files with 2202 additions and 348 deletions
+1
View File
@@ -5,3 +5,4 @@ build/
Build/ Build/
dist/ dist/
.signing.env .signing.env
homebrew-tap/
+64 -2
View File
@@ -1,6 +1,68 @@
# Changelog # Changelog
## Unreleased ## Unreleased (2026-04-21)
- Added a new `Summary` tab with a denser dashboard layout inspired by infrastructure monitoring tools.
- Added persisted metric history with SwiftData for CPU, memory, disk, and swap charts.
- Added `Hour`, `Day`, `Week`, and `Month` ranges to summary charts.
- Added CPU, memory, disk, and swap history widgets that expand across the available summary width.
- Reworked `General` to remain the more traditional detailed information tab while `Summary` focuses on quick status and trends.
- Isolated metric history into an app-specific SwiftData store and recover cleanly from incompatible history stores.
- Fixed the summary CPU chart to use the summary payload's reported CPU percentage and allow values above `100%` with a dynamic Y axis.
- Fixed excessive summary redraws by moving the interval indicator timer out of the main detail view so charts no longer refresh every second.
- Added optional sidebar groups for hosts, including group creation, editing, deletion, and host assignment.
- Added grouped host ordering, group reordering via drag and drop, and clearer visual feedback while moving groups.
- Improved group header styling to better distinguish groups and ungrouped hosts in the sidebar.
- Fixed a launch crash in grouped builds caused by async ping tasks writing back to stale array indexes after the server list changed.
## 26.1.9 (2026-04-19)
- Reduced idle CPU usage and energy impact by changing the interval indicator from a permanent 60 FPS timer to a 1-second update cadence.
- Reset the interval indicator cleanly when the refresh interval changes or when the indicator is hidden.
## 26.1.8 (2026-04-19)
- Fixed a crash in `PingService` caused by concurrent mutation of shared ping state from multiple async ping tasks.
- Moved ping state tracking and reboot suppression windows into an actor so ping success/failure handling is serialized safely.
## 26.1.7 (2026-04-19)
- Added remote reboot support for hosts running KeyHelp API 2.14 or newer.
- Added a dedicated `APIv2_14` client and mapped 2.14+ hosts to it instead of treating them as API 2.13.
- Fixed the reboot request to call `/api/v2/server/reboot` with the required JSON confirmation payload.
- Changed the reboot confirmation and result UI to non-blocking sheets/banner feedback so failures no longer trap the app in modal dialogs.
- Improved API error messages by surfacing the server response body instead of only generic HTTP status codes.
- Reduced expected reboot noise by suppressing ping checks for a short grace period after a reboot request.
## 26.1.6 (2026-04-19)
- Publish Gitea releases as stable by default instead of pre-releases.
- Update the Homebrew tap automatically after each successful release by rewriting the cask version and DMG checksum, then pushing the tap repo.
- Simplified the README for end users by adding clear install options and trimming internal release-engineering details.
- Ignore the local `homebrew-tap/` checkout in the main app repository.
## 26.1.3 (2026-01-03)
- Fixed version handling for changelogs.
## 26.1.2 (2026-01-03)
- Synced version.json to 26.1.2.
## 26.1.1 (2026-01-03)
- Fixed changelog extraction in publish script.
## 26.1.0 (2026-01-03)
- Auto-populate release description from CHANGELOG when publishing to Gitea.
## Prereleases
### Fixed
- Fixed excessive refresh timer resets by properly cancelling previous subscriptions and validating value changes before recreating timers.
- Fixed interval indicator to respect user's refresh interval setting instead of always assuming 60 seconds.
### Added
- Added status notifications for server monitoring in Preferences → Notifications:
- "Status Notifications" toggle: alerts when servers go offline/online or services change status.
- "Alert Notifications" toggle: (placeholder for future alert threshold configuration).
- Ping status changes now trigger notifications (Server Online/Offline).
- Service status monitoring tracks individual services (DNS, FTP, SSH, HTTP, HTTPS, etc.) and alerts when they go offline or come back online.
- Notification permissions are requested automatically when the app launches.
### Previous Changes
- Flattened the project structure so sources live at the repository root instead of the nested `iKeyMon/` folder and updated the Xcode project accordingly. - Flattened the project structure so sources live at the repository root instead of the nested `iKeyMon/` folder and updated the Xcode project accordingly.
- Fixed build settings (entitlements, preview assets) and placeholder previews to work with the new layout. - Fixed build settings (entitlements, preview assets) and placeholder previews to work with the new layout.
- Migrated the updated API layer and unified `ServerInfo` model from the previous branch. - Migrated the updated API layer and unified `ServerInfo` model from the previous branch.
@@ -10,5 +72,5 @@
- Introduced repository-wide version management via `version.json` + `scripts/sync_version.sh`, ensuring Xcode targets and release artifacts stay aligned. - Introduced repository-wide version management via `version.json` + `scripts/sync_version.sh`, ensuring Xcode targets and release artifacts stay aligned.
- Enhanced `scripts/build_release.sh` to timestamp/harden signatures, notarize DMGs, and optionally publish tagged releases (pre-release by default) with ZIP/DMG assets directly to Gitea when credentials are configured. - Enhanced `scripts/build_release.sh` to timestamp/harden signatures, notarize DMGs, and optionally publish tagged releases (pre-release by default) with ZIP/DMG assets directly to Gitea when credentials are configured.
- Integrated Sparkle (via Swift Package Manager) to handle automatic update checks, downloads, signature verification, and relaunches, replacing the previous custom updater UI. Preferences now simply surface Sparkle's check/download toggles. - Integrated Sparkle (via Swift Package Manager) to handle automatic update checks, downloads, signature verification, and relaunches, replacing the previous custom updater UI. Preferences now simply surface Sparkle's check/download toggles.
- `scripts/build_release.sh` can optionally run Sparkles `generate_appcast` (when signing key and download prefix env vars are set), producing a ready-to-host `appcast.xml` alongside the ZIP/DMG artifacts. - `scripts/build_release.sh` can optionally run Sparkle's `generate_appcast` (when signing key and download prefix env vars are set), producing a ready-to-host `appcast.xml` alongside the ZIP/DMG artifacts.
- Further reduced MainView console noise by removing redundant refresh/onAppear logs. - Further reduced MainView console noise by removing redundant refresh/onAppear logs.
-2
View File
@@ -5,5 +5,3 @@
add a marker for "reboot required" add a marker for "reboot required"
dummy22
+25 -76
View File
@@ -11,7 +11,9 @@ iKeyMon is a native macOS app written in SwiftUI that provides live monitoring f
- Automatic refreshes: - Automatic refreshes:
- Ping every 10 seconds - Ping every 10 seconds
- Server info every 60 seconds - Server info every 60 seconds
- Preferences dialog
- Built-in Sparkle updater (automatic checks, downloads, and relaunch once a signed release is available) - Built-in Sparkle updater (automatic checks, downloads, and relaunch once a signed release is available)
- macOS notifications if servers or services become unavailable
- Organized layout using tabs: General / Resources / Services - Organized layout using tabs: General / Resources / Services
- Stores API keys securely in the macOS Keychain - Stores API keys securely in the macOS Keychain
- Native macOS look & feel using SwiftUI - Native macOS look & feel using SwiftUI
@@ -24,11 +26,30 @@ iKeyMon is a native macOS app written in SwiftUI that provides live monitoring f
## 🛠️ Planned Features ## 🛠️ Planned Features
- Preferences dialog - iOS support is a possibility, but it is unclear if or when that will happen
- macOS notifications if servers or services become unavailable
- Optional iOS support if there is demand
## 🚀 How to Run ## 📦 Install
### Option 1: Download the app
Download the latest release from:
https://git.24unix.net/tracer/iKeyMon/releases
Then open the DMG and move `iKeyMon.app` to your `Applications` folder.
### Option 2: Install with Homebrew
```bash
brew tap tracer/tap https://git.24unix.net/tracer/homebrew-tap.git
brew install --cask ikeymon
```
### Option 3: Build it yourself
Clone the repository, open it in Xcode, and run the app locally on macOS 14+.
## 🚀 How to Build
Clone the repo and open it in [Xcode](https://developer.apple.com/xcode/). You can build and run the app on macOS 14+. Clone the repo and open it in [Xcode](https://developer.apple.com/xcode/). You can build and run the app on macOS 14+.
@@ -38,78 +59,6 @@ cd iKeyMon
open iKeyMon.xcodeproj open iKeyMon.xcodeproj
``` ```
### Local release build
Use the helper script to produce distributables in `dist/`:
```bash
./scripts/build_release.sh
```
It cleans previous artifacts, builds the `Release` configuration, and drops both `iKeyMon-<version>.zip` and `iKeyMon-<version>.dmg` into the `dist` folder (ignored by git). To enable codesigning + notarization, copy `signing.env.example` to `.signing.env`, fill in your Developer ID identity, Apple ID, team ID, and app-specific password. The script sources that file locally (it remains gitignored) and performs signing/notarization when the values are present.
To auto-publish the artifacts as a Gitea release, extend `.signing.env` with:
```
GITEA_TOKEN="..."
GITEA_OWNER="tracer"
GITEA_REPO="iKeyMon"
# optional: GITEA_API_BASE="https://git.24unix.net/api/v1"
# optional: GITEA_TARGET_COMMIT="master"
# optional: GITEA_PRERELEASE="false" # defaults to true until preferences are done
# optional Sparkle feed helpers:
# SPARKLE_EDDSA_KEY_FILE="$HOME/.config/Sparkle/iKeyMon.key"
# SPARKLE_DOWNLOAD_BASE_TEMPLATE="https://git.24unix.net/tracer/iKeyMon/releases/download/v{{VERSION}}"
# SPARKLE_APPCAST_OUTPUT="$ROOT_DIR/Sparkle/appcast.xml" # default
```
`GITEA_TARGET_COMMIT` defaults to the current `HEAD` commit, so overriding it lets you publish from another branch if needed. Whenever those variables are set, the script will create (or reuse) tag `v<version>` and upload both ZIP and DMG as release assets automatically.
If you re-run the release script for the same version, it removes any existing assets with the same filenames before uploading, so you never end up with duplicate ZIP/DMG files on the release page.
### Sparkle updates
iKeyMon uses [Sparkle](https://sparkle-project.org/) for macOS-safe updates.
1. Generate an EdDSA key pair once (`./Packages/Sparkle/bin/generate_keys`). Store the private key on-disk (for example `~/.config/Sparkle/iKeyMon.key`, which the build script expects) and copy the public key into the `SUPublicEDKey` entry (see Info.plist notes below).
2. `./scripts/build_release.sh` signs the ZIP with Sparkles `sign_update` tool and invokes `generate_appcast` automatically when the Sparkle variables are present. The generated feed is written to `Sparkle/appcast.xml`, so commit that file after every release. Point `SPARKLE_DOWNLOAD_BASE_TEMPLATE` at your release-download prefix (e.g. `https://git.24unix.net/tracer/iKeyMon/releases/download/v{{VERSION}}`) so the generated URLs already match Giteas asset paths. The feed stays inside the repo (it is not uploaded as a release asset).
3. Set `SUFeedURL` in Info.plist (or the corresponding build setting) to the raw URL of `Sparkle/appcast.xml` inside this repo (e.g. `https://git.24unix.net/tracer/iKeyMon/raw/branch/master/Sparkle/appcast.xml`).
Preferences expose Sparkles built-in toggles for “Automatically check” and “Automatically download”, and the toolbar button simply calls Sparkles “Check for Updates…” sheet.
> `./scripts/build_release.sh` will call `generate_appcast` for you when `SPARKLE_EDDSA_KEY_FILE` and `SPARKLE_DOWNLOAD_BASE_TEMPLATE` (or `SPARKLE_DOWNLOAD_BASE_URL`) are set. It tries to locate Sparkles CLI in DerivedData automatically, but you can override the path via `SPARKLE_GENERATE_APPCAST`. The resulting feed is written to `SPARKLE_APPCAST_OUTPUT` (defaults to `Sparkle/appcast.xml`).
> Build settings include `INFOPLIST_KEY_SUFeedURL` and `INFOPLIST_KEY_SUPublicEDKey`. Make sure to fill both before shipping a build so Sparkle knows where to fetch updates and how to verify them.
#### Debugging Sparkle updates
Launch the shipped app via CLI with `SPARKLE_VERBOSE_LOGGING=1` to mirror Sparkles activity in stdout/stderr:
```bash
SPARKLE_VERBOSE_LOGGING=1 /Applications/iKeyMon.app/Contents/MacOS/iKeyMon
```
Sparkles installer helper runs in a separate `SUPipedBinary` process. If the installer fails, collect additional details with `log show --process SUPipedBinary --last 5m`.
### Automated release push
If you want `git push origin master` to build/sign/notarize/upload automatically, enable the provided pre-push hook:
```bash
git config core.hooksPath hooks
```
The hook (see `hooks/pre-push`) watches for pushes that include `refs/heads/master`, automatically bumps `marketing_version` (incrementing the last component), runs `scripts/build_release.sh`, stages `version.json`, `iKeyMon.xcodeproj/project.pbxproj`, and `Sparkle/appcast.xml`, then creates a commit `chore: release <version>`. It performs its own `git push` behind the scenes and cancels the original push command so you don't upload the same refs twice—once you see “Release … pushed. Original push cancelled”, you're done (Git will report the original push failed; that's expected). To skip the automation temporarily, prepend `SKIP_RELEASE=1` to your `git push` command.
The bumping logic lives in `scripts/bump_version.sh` (feel free to run it manually if you need to create a release without pushing).
### Versioning workflow
- The canonical marketing version lives in `version.json` and follows the format `YY.major.minor` (example: `26.1.2`). Update that file manually whenever you cut a new release branch.
- The build number is derived automatically from the git commit count on the current branch (you can override it by exporting `BUILD_NUMBER` before running the script if needed).
- Run `./scripts/sync_version.sh` anytime after editing `version.json` (the release script already calls it). The helper updates `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` inside `iKeyMon.xcodeproj`, keeping Xcode, the app bundle, and release artifacts in sync.
- `scripts/build_release.sh` reads the same `version.json` for naming the generated ZIP/DMG, so the artifact names, Info.plist values, and UI displays all stay aligned.
## 📦 License ## 📦 License
MIT — see [LICENSE](LICENSE) for details. MIT — see [LICENSE](LICENSE) for details.
+16 -2
View File
@@ -10,6 +10,7 @@ import Foundation
enum APIVersion: String, CaseIterable { enum APIVersion: String, CaseIterable {
case v2_12 = "2.12" case v2_12 = "2.12"
case v2_13 = "2.13" case v2_13 = "2.13"
case v2_14 = "2.14"
static func from(versionString: String) -> APIVersion? { static func from(versionString: String) -> APIVersion? {
if let version = APIVersion(rawValue: versionString) { if let version = APIVersion(rawValue: versionString) {
@@ -24,7 +25,8 @@ enum APIVersion: String, CaseIterable {
switch (major, minor) { switch (major, minor) {
case (2, 12): return .v2_12 case (2, 12): return .v2_12
case (2, 13...): return .v2_13 case (2, 13): return .v2_13
case (2, 14...): return .v2_14
default: return nil default: return nil
} }
} }
@@ -35,7 +37,9 @@ protocol AnyServerAPI {
func fetchLoadData() async throws -> Any func fetchLoadData() async throws -> Any
func fetchMemoryData() async throws -> Any func fetchMemoryData() async throws -> Any
func fetchUtilizationData() async throws -> Any func fetchUtilizationData() async throws -> Any
func fetchCPUUtilizationPercent(apiKey: String) async throws -> Double
func fetchServerSummary(apiKey: String) async throws -> ServerInfo func fetchServerSummary(apiKey: String) async throws -> ServerInfo
func restartServer(apiKey: String) async throws
} }
private struct AnyServerAPIWrapper<T: ServerAPIProtocol>: AnyServerAPI { private struct AnyServerAPIWrapper<T: ServerAPIProtocol>: AnyServerAPI {
@@ -61,9 +65,17 @@ private struct AnyServerAPIWrapper<T: ServerAPIProtocol>: AnyServerAPI {
return try await wrapped.fetchUtilization() return try await wrapped.fetchUtilization()
} }
func fetchCPUUtilizationPercent(apiKey: String) async throws -> Double {
return try await wrapped.fetchCPUUtilizationPercent(apiKey: apiKey)
}
func fetchServerSummary(apiKey: String) async throws -> ServerInfo { func fetchServerSummary(apiKey: String) async throws -> ServerInfo {
return try await wrapped.fetchServerSummary(apiKey: apiKey) return try await wrapped.fetchServerSummary(apiKey: apiKey)
} }
func restartServer(apiKey: String) async throws {
try await wrapped.restartServer(apiKey: apiKey)
}
} }
class APIFactory { class APIFactory {
@@ -73,6 +85,8 @@ class APIFactory {
return AnyServerAPIWrapper(APIv2_12(baseURL: baseURL)) return AnyServerAPIWrapper(APIv2_12(baseURL: baseURL))
case .v2_13: case .v2_13:
return AnyServerAPIWrapper(APIv2_13(baseURL: baseURL)) return AnyServerAPIWrapper(APIv2_13(baseURL: baseURL))
case .v2_14:
return AnyServerAPIWrapper(APIv2_14(baseURL: baseURL))
} }
} }
@@ -104,7 +118,7 @@ class APIFactory {
} }
} }
return AnyServerAPIWrapper(APIv2_13(baseURL: baseURL)) return AnyServerAPIWrapper(APIv2_14(baseURL: baseURL))
} }
} }
+51 -4
View File
@@ -17,7 +17,9 @@ protocol ServerAPIProtocol {
func fetchLoad() async throws -> LoadType func fetchLoad() async throws -> LoadType
func fetchMemory() async throws -> MemoryType func fetchMemory() async throws -> MemoryType
func fetchUtilization() async throws -> UtilizationType func fetchUtilization() async throws -> UtilizationType
func fetchCPUUtilizationPercent(apiKey: String) async throws -> Double
func fetchServerSummary(apiKey: String) async throws -> ServerInfo func fetchServerSummary(apiKey: String) async throws -> ServerInfo
func restartServer(apiKey: String) async throws
} }
struct SystemInfo: Codable { struct SystemInfo: Codable {
@@ -36,6 +38,15 @@ class BaseAPIClient {
} }
func performRequest<T: Codable>(_ request: URLRequest, responseType: T.Type) async throws -> T { func performRequest<T: Codable>(_ request: URLRequest, responseType: T.Type) async throws -> T {
let (data, _) = try await performDataRequest(request)
return try JSONDecoder().decode(T.self, from: data)
}
func performRequestWithoutBody(_ request: URLRequest) async throws {
_ = try await performDataRequest(request)
}
private func performDataRequest(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request) let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
@@ -43,25 +54,61 @@ class BaseAPIClient {
} }
guard 200...299 ~= httpResponse.statusCode else { guard 200...299 ~= httpResponse.statusCode else {
throw APIError.httpError(httpResponse.statusCode) throw APIError.httpError(
httpResponse.statusCode,
BaseAPIClient.extractErrorMessage(from: data)
)
} }
return try JSONDecoder().decode(T.self, from: data) return (data, httpResponse)
}
private static func extractErrorMessage(from data: Data) -> String? {
guard !data.isEmpty else { return nil }
if let envelope = try? JSONDecoder().decode(APIErrorEnvelope.self, from: data) {
let parts = [envelope.code, envelope.message]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if !parts.isEmpty {
return parts.joined(separator: " ")
}
}
if let text = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
return text
}
return nil
} }
} }
enum APIError: Error, LocalizedError { enum APIError: Error, LocalizedError {
case invalidURL case invalidURL
case invalidResponse case invalidResponse
case httpError(Int) case httpError(Int, String?)
case decodingError(Error) case decodingError(Error)
case unsupportedFeature(String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .invalidURL: return "Invalid URL" case .invalidURL: return "Invalid URL"
case .invalidResponse: return "Invalid response" case .invalidResponse: return "Invalid response"
case .httpError(let code): return "HTTP Error: \(code)" case .httpError(let code, let message):
if let message, !message.isEmpty {
return "HTTP Error: \(code)\n\(message)"
}
return "HTTP Error: \(code)"
case .decodingError(let error): return "Decoding error: \(error.localizedDescription)" case .decodingError(let error): return "Decoding error: \(error.localizedDescription)"
case .unsupportedFeature(let feature): return "\(feature) is not supported by this host"
} }
} }
} }
private struct APIErrorEnvelope: Decodable {
let code: String?
let message: String?
}
+90 -5
View File
@@ -1,7 +1,18 @@
import Foundation import Foundation
import UserNotifications
enum PingService { enum PingService {
static func ping(hostname: String, apiKey: String) async -> Bool { private static let stateStore = PingStateStore()
static func suppressChecks(for hostname: String, duration: TimeInterval) async {
await stateStore.suppressChecks(for: hostname, duration: duration)
}
static func ping(hostname: String, apiKey: String, notificationsEnabled: Bool = true) async -> Bool {
if await stateStore.shouldSkipPing(for: hostname) {
return false
}
guard let url = URL(string: "https://\(hostname)/api/v2/ping") else { guard let url = URL(string: "https://\(hostname)/api/v2/ping") else {
print("❌ [PingService] Invalid URL for \(hostname)") print("❌ [PingService] Invalid URL for \(hostname)")
return false return false
@@ -15,20 +26,94 @@ enum PingService {
do { do {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
if let responseString = String(data: data, encoding: .utf8) { await handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled)
print("❌ [PingService] HTTP \(httpResponse.statusCode): \(responseString)")
}
return false return false
} }
if let result = try? JSONDecoder().decode([String: String].self, from: data), result["response"] == "pong" { if let result = try? JSONDecoder().decode([String: String].self, from: data), result["response"] == "pong" {
await handlePingSuccess(for: hostname, notificationsEnabled: notificationsEnabled)
return true return true
} else { } else {
await handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled)
return false return false
} }
} catch { } catch {
print("❌ [PingService] Error pinging \(hostname): \(error)") await handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled)
return false return false
} }
} }
private static func handlePingSuccess(for hostname: String, notificationsEnabled: Bool) async {
if let notification = await stateStore.recordSuccess(for: hostname, notificationsEnabled: notificationsEnabled) {
sendNotification(title: notification.title, body: notification.body)
}
}
private static func handlePingFailure(for hostname: String, notificationsEnabled: Bool) async {
if let notification = await stateStore.recordFailure(for: hostname, notificationsEnabled: notificationsEnabled) {
sendNotification(title: notification.title, body: notification.body)
}
}
private static func sendNotification(title: String, body: String) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
}
private actor PingStateStore {
private var previousPingStates: [String: Bool] = [:]
private var suppressedUntil: [String: Date] = [:]
func suppressChecks(for hostname: String, duration: TimeInterval) {
suppressedUntil[hostname] = Date().addingTimeInterval(duration)
previousPingStates[hostname] = false
}
func shouldSkipPing(for hostname: String) -> Bool {
if let suppressedUntil = suppressedUntil[hostname], suppressedUntil > Date() {
return true
}
suppressedUntil.removeValue(forKey: hostname)
return false
}
func recordSuccess(for hostname: String, notificationsEnabled: Bool) -> PingNotification? {
let wasPreviouslyDown = previousPingStates[hostname] == false
previousPingStates[hostname] = true
guard wasPreviouslyDown, notificationsEnabled else {
return nil
}
return PingNotification(
title: "Server Online",
body: "\(hostname) is now online"
)
}
func recordFailure(for hostname: String, notificationsEnabled: Bool) -> PingNotification? {
let wasPreviouslyUp = previousPingStates[hostname] != false
previousPingStates[hostname] = false
guard wasPreviouslyUp, notificationsEnabled else {
return nil
}
return PingNotification(
title: "Server Offline",
body: "\(hostname) is offline"
)
}
}
private struct PingNotification {
let title: String
let body: String
} }
+8 -3
View File
@@ -10,12 +10,14 @@ import Foundation
struct Server: Identifiable, Codable, Hashable, Equatable { struct Server: Identifiable, Codable, Hashable, Equatable {
let id: UUID let id: UUID
var hostname: String var hostname: String
var groupID: UUID?
var info: ServerInfo? var info: ServerInfo?
var pingable: Bool var pingable: Bool
init(id: UUID = UUID(), hostname: String, info: ServerInfo? = nil, pingable: Bool = false) { init(id: UUID = UUID(), hostname: String, groupID: UUID? = nil, info: ServerInfo? = nil, pingable: Bool = false) {
self.id = id self.id = id
self.hostname = hostname self.hostname = hostname
self.groupID = groupID
self.info = info self.info = info
self.pingable = pingable self.pingable = pingable
} }
@@ -23,24 +25,26 @@ struct Server: Identifiable, Codable, Hashable, Equatable {
// MARK: - Manual conformance // MARK: - Manual conformance
static func == (lhs: Server, rhs: Server) -> Bool { static func == (lhs: Server, rhs: Server) -> Bool {
lhs.id == rhs.id && lhs.hostname == rhs.hostname && lhs.info == rhs.info && lhs.pingable == rhs.pingable lhs.id == rhs.id && lhs.hostname == rhs.hostname && lhs.groupID == rhs.groupID && lhs.info == rhs.info && lhs.pingable == rhs.pingable
} }
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
hasher.combine(hostname) hasher.combine(hostname)
hasher.combine(groupID)
hasher.combine(info) hasher.combine(info)
hasher.combine(pingable) hasher.combine(pingable)
} }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, hostname, info, pingable case id, hostname, groupID, info, pingable
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id) id = try container.decode(UUID.self, forKey: .id)
hostname = try container.decode(String.self, forKey: .hostname) hostname = try container.decode(String.self, forKey: .hostname)
groupID = try container.decodeIfPresent(UUID.self, forKey: .groupID)
info = try container.decodeIfPresent(ServerInfo.self, forKey: .info) info = try container.decodeIfPresent(ServerInfo.self, forKey: .info)
pingable = try container.decodeIfPresent(Bool.self, forKey: .pingable) ?? false pingable = try container.decodeIfPresent(Bool.self, forKey: .pingable) ?? false
} }
@@ -49,6 +53,7 @@ struct Server: Identifiable, Codable, Hashable, Equatable {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id) try container.encode(id, forKey: .id)
try container.encode(hostname, forKey: .hostname) try container.encode(hostname, forKey: .hostname)
try container.encodeIfPresent(groupID, forKey: .groupID)
try container.encodeIfPresent(info, forKey: .info) try container.encodeIfPresent(info, forKey: .info)
try container.encode(pingable, forKey: .pingable) try container.encode(pingable, forKey: .pingable)
} }
+11
View File
@@ -0,0 +1,11 @@
import Foundation
struct ServerGroup: Identifiable, Codable, Hashable, Equatable {
let id: UUID
var name: String
init(id: UUID = UUID(), name: String) {
self.id = id
self.name = name
}
}
+44
View File
@@ -19,6 +19,16 @@ struct ServerInfo: Codable, Hashable, Equatable {
self.cpuCount = cpuCount self.cpuCount = cpuCount
self.level = level self.level = level
} }
var displayPercent: Double {
let clampedPercent = min(max(percent, 0), 100)
guard clampedPercent != percent else {
return clampedPercent
}
let normalized = (minute1 / Double(max(cpuCount, 1))) * 100
return min(max(normalized, 0), 100)
}
} }
struct Memory: Codable, Hashable, Equatable { struct Memory: Codable, Hashable, Equatable {
@@ -155,6 +165,7 @@ struct ServerInfo: Codable, Hashable, Equatable {
var memory: Memory var memory: Memory
var swap: Memory var swap: Memory
var diskSpace: DiskSpace var diskSpace: DiskSpace
var cpuUtilizationPercent: Double?
var panelVersion: String var panelVersion: String
var panelBuild: String var panelBuild: String
var apiVersion: String var apiVersion: String
@@ -185,6 +196,17 @@ struct ServerInfo: Codable, Hashable, Equatable {
].filter { !$0.isEmpty } ].filter { !$0.isEmpty }
return components.isEmpty ? nil : components.joined(separator: "") return components.isEmpty ? nil : components.joined(separator: "")
} }
var supportsRestartCommand: Bool {
ServerInfo.version(apiVersion, isAtLeast: "2.14")
}
var summaryCPUPercent: Double {
if let cpuUtilizationPercent {
return min(max(cpuUtilizationPercent, 0), 100)
}
return load.displayPercent
}
} }
// MARK: - Helpers & Sample Data // MARK: - Helpers & Sample Data
@@ -226,6 +248,27 @@ extension ServerInfo {
return normalized return normalized
} }
private static func version(_ value: String, isAtLeast minimum: String) -> Bool {
let lhs = value
.split(separator: ".")
.compactMap { Int($0) }
let rhs = minimum
.split(separator: ".")
.compactMap { Int($0) }
let count = max(lhs.count, rhs.count)
for index in 0..<count {
let left = index < lhs.count ? lhs[index] : 0
let right = index < rhs.count ? rhs[index] : 0
if left != right {
return left > right
}
}
return true
}
static let placeholder = ServerInfo( static let placeholder = ServerInfo(
hostname: "preview.example.com", hostname: "preview.example.com",
ipAddresses: ["192.168.1.1", "fe80::1"], ipAddresses: ["192.168.1.1", "fe80::1"],
@@ -259,6 +302,7 @@ extension ServerInfo {
memory: Memory(free: 8_000_000_000, used: 4_000_000_000, total: 12_000_000_000, percent: 33.3), memory: Memory(free: 8_000_000_000, used: 4_000_000_000, total: 12_000_000_000, percent: 33.3),
swap: Memory(free: 4_000_000_000, used: 1_000_000_000, total: 5_000_000_000, percent: 20.0), swap: Memory(free: 4_000_000_000, used: 1_000_000_000, total: 5_000_000_000, percent: 20.0),
diskSpace: DiskSpace(free: 100_000_000_000, used: 50_000_000_000, total: 150_000_000_000, percent: 33.3), diskSpace: DiskSpace(free: 100_000_000_000, used: 50_000_000_000, total: 150_000_000_000, percent: 33.3),
cpuUtilizationPercent: 12.5,
panelVersion: "25.0", panelVersion: "25.0",
panelBuild: "3394", panelBuild: "3394",
apiVersion: "2", apiVersion: "2",
+17 -1
View File
@@ -168,6 +168,17 @@ class APIv2_12: BaseAPIClient, ServerAPIProtocol {
return try await performRequest(request, responseType: UtilizationType.self) return try await performRequest(request, responseType: UtilizationType.self)
} }
func fetchCPUUtilizationPercent(apiKey: String) async throws -> Double {
let url = Endpoint.utilization.url(baseURL: baseURL)
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY")
request.timeoutInterval = 30
let utilization = try await performRequest(request, responseType: UtilizationType.self)
return utilization.cpu.overall
}
func fetchServerSummary(apiKey: String) async throws -> ServerInfo { func fetchServerSummary(apiKey: String) async throws -> ServerInfo {
let summaryURL = baseURL.appendingPathComponent("api/v2/server") let summaryURL = baseURL.appendingPathComponent("api/v2/server")
var request = URLRequest(url: summaryURL) var request = URLRequest(url: summaryURL)
@@ -181,7 +192,7 @@ class APIv2_12: BaseAPIClient, ServerAPIProtocol {
} }
guard httpResponse.statusCode == 200 else { guard httpResponse.statusCode == 200 else {
throw APIError.httpError(httpResponse.statusCode) throw APIError.httpError(httpResponse.statusCode, nil)
} }
let decoder = JSONDecoder() let decoder = JSONDecoder()
@@ -189,6 +200,10 @@ class APIv2_12: BaseAPIClient, ServerAPIProtocol {
let envelope = try decoder.decode(ServerSummaryEnvelope.self, from: data) let envelope = try decoder.decode(ServerSummaryEnvelope.self, from: data)
return envelope.toDomain() return envelope.toDomain()
} }
func restartServer(apiKey: String) async throws {
throw APIError.unsupportedFeature("Server reboot")
}
} }
// MARK: - Server Summary Mapping // MARK: - Server Summary Mapping
@@ -393,6 +408,7 @@ private extension APIv2_12 {
total: disk.total, total: disk.total,
percent: disk.percent percent: disk.percent
), ),
cpuUtilizationPercent: nil,
panelVersion: meta.panelVersion, panelVersion: meta.panelVersion,
panelBuild: String(meta.panelBuild), panelBuild: String(meta.panelBuild),
apiVersion: meta.apiVersion, apiVersion: meta.apiVersion,
+17 -1
View File
@@ -168,6 +168,17 @@ class APIv2_13: BaseAPIClient, ServerAPIProtocol {
return try await performRequest(request, responseType: UtilizationType.self) return try await performRequest(request, responseType: UtilizationType.self)
} }
func fetchCPUUtilizationPercent(apiKey: String) async throws -> Double {
let url = Endpoint.utilization.url(baseURL: baseURL)
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY")
request.timeoutInterval = 30
let utilization = try await performRequest(request, responseType: UtilizationType.self)
return utilization.cpu.overall
}
func fetchServerSummary(apiKey: String) async throws -> ServerInfo { func fetchServerSummary(apiKey: String) async throws -> ServerInfo {
let summaryURL = baseURL.appendingPathComponent("api/v2/server") let summaryURL = baseURL.appendingPathComponent("api/v2/server")
var request = URLRequest(url: summaryURL) var request = URLRequest(url: summaryURL)
@@ -181,7 +192,7 @@ class APIv2_13: BaseAPIClient, ServerAPIProtocol {
} }
guard httpResponse.statusCode == 200 else { guard httpResponse.statusCode == 200 else {
throw APIError.httpError(httpResponse.statusCode) throw APIError.httpError(httpResponse.statusCode, nil)
} }
let decoder = JSONDecoder() let decoder = JSONDecoder()
@@ -189,6 +200,10 @@ class APIv2_13: BaseAPIClient, ServerAPIProtocol {
let envelope = try decoder.decode(ServerSummaryEnvelope.self, from: data) let envelope = try decoder.decode(ServerSummaryEnvelope.self, from: data)
return envelope.toDomain() return envelope.toDomain()
} }
func restartServer(apiKey: String) async throws {
throw APIError.unsupportedFeature("Server reboot")
}
} }
// MARK: - Server Summary Mapping // MARK: - Server Summary Mapping
@@ -393,6 +408,7 @@ private extension APIv2_13 {
total: disk.total, total: disk.total,
percent: disk.percent percent: disk.percent
), ),
cpuUtilizationPercent: nil,
panelVersion: meta.panelVersion, panelVersion: meta.panelVersion,
panelBuild: String(meta.panelBuild), panelBuild: String(meta.panelBuild),
apiVersion: meta.apiVersion, apiVersion: meta.apiVersion,
+30
View File
@@ -0,0 +1,30 @@
//
// APIv2_14.swift
// iKeyMon
//
// Created by tracer on 19.04.26.
//
import Foundation
class APIv2_14: APIv2_13 {
private enum Endpoint: String {
case serverReboot = "/api/v2/server/reboot"
func url(baseURL: URL) -> URL {
baseURL.appendingPathComponent(rawValue)
}
}
override func restartServer(apiKey: String) async throws {
var request = URLRequest(url: Endpoint.serverReboot.url(baseURL: baseURL))
request.httpMethod = "POST"
request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 30
request.httpBody = #"{"confirm":true}"#.data(using: .utf8)
try await performRequestWithoutBody(request)
}
}
+35
View File
@@ -0,0 +1,35 @@
//
// MetricSample.swift
// iKeyMon
//
// Created by tracer on 21.04.26.
//
import Foundation
import SwiftData
@Model
final class MetricSample {
var serverID: UUID
var timestamp: Date
var cpuPercent: Double
var memoryPercent: Double
var swapPercent: Double
var diskPercent: Double
init(
serverID: UUID,
timestamp: Date = .now,
cpuPercent: Double,
memoryPercent: Double,
swapPercent: Double,
diskPercent: Double
) {
self.serverID = serverID
self.timestamp = timestamp
self.cpuPercent = cpuPercent
self.memoryPercent = memoryPercent
self.swapPercent = swapPercent
self.diskPercent = diskPercent
}
}
+53 -19
View File
@@ -9,6 +9,7 @@ final class SparkleUpdater: NSObject, ObservableObject {
}() }()
private let logger = Logger(subsystem: "net.24unix.iKeyMon", category: "Sparkle") private let logger = Logger(subsystem: "net.24unix.iKeyMon", category: "Sparkle")
private let verboseLogging: Bool private let verboseLogging: Bool
@Published var logMessages: [String] = []
override init() { override init() {
self.verboseLogging = ProcessInfo.processInfo.environment["SPARKLE_VERBOSE_LOGGING"] == "1" self.verboseLogging = ProcessInfo.processInfo.environment["SPARKLE_VERBOSE_LOGGING"] == "1"
@@ -34,6 +35,7 @@ final class SparkleUpdater: NSObject, ObservableObject {
private func log(_ message: String) { private func log(_ message: String) {
logger.log("\(message, privacy: .public)") logger.log("\(message, privacy: .public)")
addLogMessage("[INFO] \(message)")
if verboseLogging { if verboseLogging {
print("[Sparkle] \(message)") print("[Sparkle] \(message)")
} }
@@ -41,11 +43,21 @@ final class SparkleUpdater: NSObject, ObservableObject {
private func logError(_ message: String) { private func logError(_ message: String) {
logger.error("\(message, privacy: .public)") logger.error("\(message, privacy: .public)")
addLogMessage("[ERROR] \(message)")
if verboseLogging { if verboseLogging {
fputs("[Sparkle][error] \(message)\n", stderr) fputs("[Sparkle][error] \(message)\n", stderr)
} }
} }
private func addLogMessage(_ message: String) {
let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
let timestampedMessage = "[\(timestamp)] \(message)"
logMessages.append(timestampedMessage)
if logMessages.count > 100 {
logMessages.removeFirst()
}
}
private func describe(update item: SUAppcastItem) -> String { private func describe(update item: SUAppcastItem) -> String {
let short = item.displayVersionString let short = item.displayVersionString
let build = item.versionString let build = item.versionString
@@ -53,41 +65,63 @@ final class SparkleUpdater: NSObject, ObservableObject {
} }
} }
@MainActor
extension SparkleUpdater: SPUUpdaterDelegate { extension SparkleUpdater: SPUUpdaterDelegate {
func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) { nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) {
log("Loaded Sparkle appcast containing \(appcast.items.count) item(s).") Task { @MainActor in
log("Loaded Sparkle appcast containing \(appcast.items.count) item(s).")
}
} }
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { nonisolated func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
log("Found valid update \(describe(update: item))") Task { @MainActor in
log("Found valid update \(describe(update: item))")
}
} }
func updaterDidNotFindUpdate(_ updater: SPUUpdater) { nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
log("No updates available.") Task { @MainActor in
log("No updates available.")
}
} }
func updater(_ updater: SPUUpdater, willDownloadUpdate item: SUAppcastItem, with request: NSMutableURLRequest) { nonisolated func updater(_ updater: SPUUpdater, willDownloadUpdate item: SUAppcastItem, with request: NSMutableURLRequest) {
log("Downloading \(describe(update: item)) from \(request.url?.absoluteString ?? "unknown URL")") Task { @MainActor in
log("Downloading \(describe(update: item)) from \(request.url?.absoluteString ?? "unknown URL")")
}
} }
func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
log("Finished downloading \(describe(update: item))") Task { @MainActor in
log("Finished downloading \(describe(update: item))")
}
} }
func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { nonisolated func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) {
logError("Failed to download \(describe(update: item)): \(error.localizedDescription)") Task { @MainActor in
logError("Failed to download \(describe(update: item)): \(error.localizedDescription)")
}
} }
func userDidCancelDownload(_ updater: SPUUpdater) { nonisolated func userDidCancelDownload(_ updater: SPUUpdater) {
log("User cancelled Sparkle download.") Task { @MainActor in
log("User cancelled Sparkle download.")
}
} }
func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) { nonisolated func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) {
log("Will install update \(describe(update: item))") Task { @MainActor in
log("Will install update \(describe(update: item))")
}
} }
func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
logError("Sparkle aborted: \(error.localizedDescription)") Task { @MainActor in
let errorDescription = error as NSError
let details = "Domain: \(errorDescription.domain), Code: \(errorDescription.code), Description: \(error.localizedDescription)"
logError("Sparkle aborted: \(details)")
if let underlying = errorDescription.userInfo[NSUnderlyingErrorKey] as? NSError {
logError("Underlying error: Domain: \(underlying.domain), Code: \(underlying.code), Description: \(underlying.localizedDescription)")
}
}
} }
} }
+1
View File
@@ -27,6 +27,7 @@ struct InfoCell: View {
} }
} }
// if let subtext { // if let subtext {
// Text(subtext) // Text(subtext)
// .font(.caption) // .font(.caption)
+80
View File
@@ -0,0 +1,80 @@
import SwiftUI
struct GroupFormView: View {
enum Mode {
case add
case edit(ServerGroup)
}
let mode: Mode
@Binding var groups: [ServerGroup]
let onSave: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var name: String
init(mode: Mode, groups: Binding<[ServerGroup]>, onSave: @escaping () -> Void) {
self.mode = mode
self._groups = groups
self.onSave = onSave
switch mode {
case .add:
self._name = State(initialValue: "")
case .edit(let group):
self._name = State(initialValue: group.name)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(title)
.font(.headline)
TextField("Group name", text: $name)
.textFieldStyle(.roundedBorder)
HStack {
Button("Cancel") {
dismiss()
}
Spacer()
Button("Save") {
save()
}
.disabled(trimmedName.isEmpty)
}
}
.padding()
.frame(width: 320)
}
private var title: String {
switch mode {
case .add:
return "Add Group"
case .edit:
return "Edit Group"
}
}
private var trimmedName: String {
name.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func save() {
switch mode {
case .add:
groups.append(ServerGroup(name: trimmedName))
case .edit(let group):
if let index = groups.firstIndex(where: { $0.id == group.id }) {
groups[index].name = trimmedName
}
}
onSave()
dismiss()
}
}
+598 -59
View File
@@ -6,24 +6,46 @@
// //
import SwiftUI import SwiftUI
import Combine
import UserNotifications
import SwiftData
import AppKit
import UniformTypeIdentifiers
struct MainView: View { struct MainView: View {
private static let serverOrderKeyStatic = "serverOrder" private static let serverOrderKeyStatic = "serverOrder"
private static let storedServersKeyStatic = "storedServers" private static let storedServersKeyStatic = "storedServers"
private static let storedGroupsKeyStatic = "storedGroups"
@EnvironmentObject private var sparkleUpdater: SparkleUpdater
@State var showAddServerSheet: Bool = false @State var showAddServerSheet: Bool = false
@State private var showAddGroupSheet: Bool = false
@State private var serverBeingEdited: Server? @State private var serverBeingEdited: Server?
@State private var groupBeingEdited: ServerGroup?
@State private var serverToDelete: Server? @State private var serverToDelete: Server?
@State private var groupToDelete: ServerGroup?
@State private var showDeleteConfirmation = false @State private var showDeleteConfirmation = false
@State private var showDeleteGroupConfirmation = false
@State private var isFetchingInfo: Bool = false @State private var isFetchingInfo: Bool = false
@State private var refreshTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() @AppStorage("pingInterval") private var pingInterval: Int = 10
@AppStorage("refreshInterval") private var refreshInterval: Int = 60
@AppStorage("enableStatusNotifications") private var enableStatusNotifications: Bool = true
@AppStorage("enableAlertNotifications") private var enableAlertNotifications: Bool = true
@State private var refreshTimer: Timer.TimerPublisher?
@State private var refreshSubscription: AnyCancellable?
@State private var pingTimer: Timer? @State private var pingTimer: Timer?
@State private var restartingServerID: UUID?
@State private var draggedGroupID: UUID?
@State private var groupDropIndicator: GroupDropIndicator?
@State private var lastRefreshInterval: Int?
@State private var lastMetricPrune: Date?
@State private var previousServiceStates: [String: String] = [:]
private let serverOrderKey = MainView.serverOrderKeyStatic private let serverOrderKey = MainView.serverOrderKeyStatic
private let storedServersKey = MainView.storedServersKeyStatic private let storedGroupsKey = MainView.storedGroupsKeyStatic
@Environment(\.modelContext) private var modelContext
@State private var servers: [Server] = MainView.loadStoredServers() @State private var servers: [Server] = MainView.loadStoredServers()
@State private var groups: [ServerGroup] = MainView.loadStoredGroups()
// @State private var selectedServer: Server? // @State private var selectedServer: Server?
@State private var selectedServerID: UUID? @State private var selectedServerID: UUID?
@@ -31,87 +53,109 @@ struct MainView: View {
var body: some View { var body: some View {
var mainContent: some View { var mainContent: some View {
NavigationSplitView { NavigationSplitView {
List(selection: $selectedServerID) { ZStack {
ForEach(servers) { server in SidebarMaterialView()
HStack {
Image(systemName: "dot.circle.fill") List(selection: $selectedServerID) {
.foregroundColor(server.pingable ? .green : .red) sidebarContent
Text(server.hostname)
} }
.tag(server) .listStyle(.sidebar)
.contextMenu { .scrollContentBackground(.hidden)
Button("Edit") { .background(Color.clear)
print("Editing:", server.hostname) }
serverBeingEdited = server .background(
} RoundedRectangle(cornerRadius: 0, style: .continuous)
Divider() .fill(.ultraThinMaterial)
Button("Delete", role: .destructive) { )
serverToDelete = server .overlay(alignment: .trailing) {
showDeleteConfirmation = true Rectangle()
.fill(Color.white.opacity(0.08))
.frame(width: 1)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button("Add Host") {
showAddServerSheet = true
}
Button("Add Group") {
showAddGroupSheet = true
}
} label: {
Image(systemName: "plus")
} }
.help("Add Host or Group")
} }
} }
.onMove(perform: moveServer) .navigationTitle("Servers")
} .onChange(of: selectedServerID) {
.toolbar { if let selectedServerID {
ToolbarItem(placement: .primaryAction) { UserDefaults.standard.set(selectedServerID.uuidString, forKey: "selectedServerID")
Button(action: { showAddServerSheet = true }) { fetchServerInfo(for: selectedServerID)
Image(systemName: "plus")
} }
.help("Add Host")
} }
ToolbarItem { } detail: {
Button { if let selectedServerID,
sparkleUpdater.checkForUpdates() let index = servers.firstIndex(where: { selectedServerID == $0.id }) {
} label: { let serverID = servers[index].id
Image(systemName: "square.and.arrow.down") ServerDetailView(
server: $servers[index],
isFetching: isFetchingInfo,
canRestart: servers[index].info?.supportsRestartCommand == true,
isRestarting: restartingServerID == serverID
) {
await restartServer(for: serverID)
} }
.help("Check for Updates") } else {
ContentUnavailableView("No Server Selected", systemImage: "server.rack")
} }
} }
.navigationTitle("Servers")
.onChange(of: selectedServerID) {
if let selectedServerID {
UserDefaults.standard.set(selectedServerID.uuidString, forKey: "selectedServerID")
fetchServerInfo(for: selectedServerID)
}
}
} detail: {
if let selectedServerID,
let index = servers.firstIndex(where: { selectedServerID == $0.id }) {
ServerDetailView(server: $servers[index], isFetching: isFetchingInfo)
} else {
ContentUnavailableView("No Server Selected", systemImage: "server.rack")
}
}
} }
return mainContent return mainContent
.sheet(isPresented: $showAddServerSheet) { .sheet(isPresented: $showAddServerSheet) {
ServerFormView( ServerFormView(
mode: .add, mode: .add,
servers: $servers, servers: $servers,
groups: $groups,
dismiss: { showAddServerSheet = false } dismiss: { showAddServerSheet = false }
) )
} }
.sheet(isPresented: $showAddGroupSheet) {
GroupFormView(mode: .add, groups: $groups) {
saveGroups()
}
}
.sheet(item: $serverBeingEdited) { server in .sheet(item: $serverBeingEdited) { server in
ServerFormView( ServerFormView(
mode: .edit(server), mode: .edit(server),
servers: $servers, servers: $servers,
groups: $groups,
dismiss: { serverBeingEdited = nil } dismiss: { serverBeingEdited = nil }
) )
} }
.sheet(item: $groupBeingEdited) { group in
GroupFormView(mode: .edit(group), groups: $groups) {
saveGroups()
}
}
.alert("Are you sure you want to delete this server?", isPresented: $showDeleteConfirmation, presenting: serverToDelete) { server in .alert("Are you sure you want to delete this server?", isPresented: $showDeleteConfirmation, presenting: serverToDelete) { server in
Button("Delete", role: .destructive) { Button("Delete", role: .destructive) {
ServerFormView.delete(server: server, from: &servers) ServerFormView.delete(server: server, from: &servers)
saveServers()
} }
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
} }
.onReceive(refreshTimer) { _ in .alert("Are you sure you want to delete this group?", isPresented: $showDeleteGroupConfirmation, presenting: groupToDelete) { group in
for server in servers { Button("Delete", role: .destructive) {
fetchServerInfo(for: server.id) deleteGroup(group)
} }
Button("Cancel", role: .cancel) {}
} message: { group in
Text("Servers in \(group.name) will remain available and become ungrouped.")
} }
.onAppear { .onAppear {
requestNotificationPermissions()
let initialID: UUID? let initialID: UUID?
if let storedID = UserDefaults.standard.string(forKey: "selectedServerID"), if let storedID = UserDefaults.standard.string(forKey: "selectedServerID"),
let uuid = UUID(uuidString: storedID), let uuid = UUID(uuidString: storedID),
@@ -133,14 +177,156 @@ struct MainView: View {
await prefetchOtherServers(activeID: initialID) await prefetchOtherServers(activeID: initialID)
} }
} }
pingAllServers() setupTimers()
pingTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in }
pingAllServers() .onChange(of: pingInterval) { _, _ in
setupPingTimer()
}
.onChange(of: refreshInterval) { oldValue, newValue in
if oldValue != newValue {
setupRefreshTimer()
} }
} }
.onChange(of: groups) { _, _ in
saveGroups()
}
.frame(minWidth: 800, minHeight: 450) .frame(minWidth: 800, minHeight: 450)
} }
@ViewBuilder
private var sidebarContent: some View {
if groups.isEmpty {
ForEach(servers) { server in
sidebarRow(for: server)
}
.onMove(perform: moveServer)
} else {
ForEach(groups) { group in
Section {
ForEach(servers(in: group)) { server in
sidebarRow(for: server)
}
.onMove { source, destination in
moveServers(in: group.id, from: source, to: destination)
}
} header: {
groupHeader(for: group)
}
}
if !ungroupedServers.isEmpty {
Section {
ForEach(ungroupedServers) { server in
sidebarRow(for: server)
}
.onMove { source, destination in
moveServers(in: nil, from: source, to: destination)
}
} header: {
sidebarSectionHeader("Ungrouped")
}
}
}
}
private func sidebarSectionHeader(_ title: String) -> some View {
HStack {
Text(title)
.font(.system(size: NSFont.systemFontSize + 1, weight: .bold))
.foregroundStyle(Color.accentColor)
Spacer(minLength: 0)
}
.padding(.vertical, 4)
}
private func sidebarRow(for server: Server) -> some View {
HStack {
Image(systemName: "dot.circle.fill")
.foregroundColor(server.pingable ? .green : .red)
Text(server.hostname)
}
.tag(server.id)
.contextMenu {
Button("Edit") {
print("Editing:", server.hostname)
serverBeingEdited = server
}
Divider()
Button("Delete", role: .destructive) {
serverToDelete = server
showDeleteConfirmation = true
}
}
}
private func groupHeader(for group: ServerGroup) -> some View {
let activePlacement = groupDropIndicator?.groupID == group.id ? groupDropIndicator?.placement : nil
return VStack(spacing: 0) {
if activePlacement == .before {
dropIndicator
}
sidebarSectionHeader(group.name)
.contentShape(Rectangle())
.background {
if activePlacement != nil {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.accentColor.opacity(0.12))
}
}
if activePlacement == .after {
dropIndicator
}
}
.onDrag {
draggedGroupID = group.id
return NSItemProvider(object: group.id.uuidString as NSString)
}
.onDrop(
of: [UTType.text],
delegate: GroupDropDelegate(
targetGroup: group,
groups: $groups,
draggedGroupID: $draggedGroupID,
indicator: $groupDropIndicator
)
)
.contextMenu {
Button("Edit Group") {
groupBeingEdited = group
}
Button("Delete Group", role: .destructive) {
groupToDelete = group
showDeleteGroupConfirmation = true
}
}
}
private var dropIndicator: some View {
VStack(spacing: 4) {
Capsule()
.fill(Color.accentColor)
.frame(height: 3)
.shadow(color: Color.accentColor.opacity(0.25), radius: 1, y: 0)
Color.clear
.frame(height: 4)
}
.padding(.vertical, 2)
}
private var ungroupedServers: [Server] {
servers.filter { server in
guard let groupID = server.groupID else { return true }
return groups.contains(where: { $0.id == groupID }) == false
}
}
private func servers(in group: ServerGroup) -> [Server] {
servers.filter { $0.groupID == group.id }
}
private func fetchServerInfo(for id: UUID) { private func fetchServerInfo(for id: UUID) {
guard let server = servers.first(where: { $0.id == id }) else { guard let server = servers.first(where: { $0.id == id }) else {
print("❌ [MainView] fetchServerInfo: server not found for id \(id)") print("❌ [MainView] fetchServerInfo: server not found for id \(id)")
@@ -168,6 +354,8 @@ struct MainView: View {
var updated = servers[index] var updated = servers[index]
updated.info = info updated.info = info
servers[index] = updated servers[index] = updated
recordMetricSample(for: id, info: info)
checkServiceStatusChanges(for: server.hostname, newInfo: info)
} }
} }
} catch { } catch {
@@ -202,6 +390,7 @@ struct MainView: View {
var updated = servers[index] var updated = servers[index]
updated.info = info updated.info = info
servers[index] = updated servers[index] = updated
recordMetricSample(for: id, info: info)
} }
} }
} catch { } catch {
@@ -211,6 +400,41 @@ struct MainView: View {
private func moveServer(from source: IndexSet, to destination: Int) { private func moveServer(from source: IndexSet, to destination: Int) {
servers.move(fromOffsets: source, toOffset: destination) servers.move(fromOffsets: source, toOffset: destination)
saveServers()
saveServerOrder()
}
private func moveServers(in groupID: UUID?, from source: IndexSet, to destination: Int) {
let matchingServers = servers.filter { server in
if let groupID {
return server.groupID == groupID
}
return server.groupID == nil || groups.contains(where: { $0.id == server.groupID }) == false
}
var reorderedServers = matchingServers
reorderedServers.move(fromOffsets: source, toOffset: destination)
let replacements = Dictionary(uniqueKeysWithValues: reorderedServers.map { ($0.id, $0) })
var reorderedIDs = reorderedServers.map(\.id)
servers = servers.map { server in
let belongsInSection: Bool
if let groupID {
belongsInSection = server.groupID == groupID
} else {
belongsInSection = server.groupID == nil || groups.contains(where: { $0.id == server.groupID }) == false
}
guard belongsInSection, let nextID = reorderedIDs.first else {
return server
}
reorderedIDs.removeFirst()
return replacements[nextID] ?? server
}
saveServers()
saveServerOrder() saveServerOrder()
} }
@@ -220,21 +444,117 @@ struct MainView: View {
print("💾 [MainView] Saved server order with \(ids.count) entries") print("💾 [MainView] Saved server order with \(ids.count) entries")
} }
private func saveServers() {
if let data = try? JSONEncoder().encode(servers) {
UserDefaults.standard.set(data, forKey: MainView.storedServersKeyStatic)
}
}
private func saveGroups() {
if let data = try? JSONEncoder().encode(groups) {
UserDefaults.standard.set(data, forKey: storedGroupsKey)
}
}
private func recordMetricSample(for serverID: UUID, info: ServerInfo) {
let sample = MetricSample(
serverID: serverID,
cpuPercent: info.load.percent,
memoryPercent: info.memory.percent,
swapPercent: info.swap.percent,
diskPercent: info.diskSpace.percent
)
modelContext.insert(sample)
do {
try modelContext.save()
} catch {
print("❌ [MainView] Failed to save metric sample: \(error)")
}
pruneOldMetricSamplesIfNeeded()
}
private func pruneOldMetricSamplesIfNeeded() {
let now = Date()
if let lastMetricPrune, now.timeIntervalSince(lastMetricPrune) < 3600 {
return
}
let cutoff = now.addingTimeInterval(-30 * 24 * 60 * 60)
let descriptor = FetchDescriptor<MetricSample>(
predicate: #Predicate { sample in
sample.timestamp < cutoff
}
)
do {
let expiredSamples = try modelContext.fetch(descriptor)
for sample in expiredSamples {
modelContext.delete(sample)
}
if !expiredSamples.isEmpty {
try modelContext.save()
}
lastMetricPrune = now
} catch {
print("❌ [MainView] Failed to prune metric samples: \(error)")
}
}
private func deleteGroup(_ group: ServerGroup) {
groups.removeAll { $0.id == group.id }
for index in servers.indices {
if servers[index].groupID == group.id {
servers[index].groupID = nil
}
}
saveGroups()
saveServers()
}
private struct PingResponse: Codable { private struct PingResponse: Codable {
let response: String let response: String
} }
func pingAllServers() { func pingAllServers() {
for (index, server) in servers.enumerated() { let pingTargets = servers.map { ($0.id, $0.hostname) }
for (serverID, hostname) in pingTargets {
Task { Task {
let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let apiKey = KeychainHelper.loadApiKey(for: hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let pingable = await PingService.ping(hostname: server.hostname, apiKey: apiKey) let pingable = await PingService.ping(hostname: hostname, apiKey: apiKey, notificationsEnabled: enableStatusNotifications)
await MainActor.run { await MainActor.run {
guard let index = servers.firstIndex(where: { $0.id == serverID }) else {
return
}
servers[index].pingable = pingable servers[index].pingable = pingable
} }
if !pingable { }
print("📶 [MainView] Ping \(server.hostname): offline") }
} }
private func setupTimers() {
setupPingTimer()
setupRefreshTimer()
}
private func setupPingTimer() {
pingTimer?.invalidate()
pingAllServers()
pingTimer = Timer.scheduledTimer(withTimeInterval: Double(pingInterval), repeats: true) { _ in
pingAllServers()
}
}
private func setupRefreshTimer() {
refreshSubscription?.cancel()
refreshSubscription = nil
refreshTimer = Timer.publish(every: Double(refreshInterval), on: .main, in: .common)
refreshSubscription = refreshTimer?.autoconnect().sink { _ in
for server in servers {
fetchServerInfo(for: server.id)
} }
} }
} }
@@ -265,6 +585,225 @@ struct MainView: View {
return [] return []
} }
} }
private static func loadStoredGroups() -> [ServerGroup] {
let defaults = UserDefaults.standard
guard let data = defaults.data(forKey: storedGroupsKeyStatic) else {
return []
}
do {
return try JSONDecoder().decode([ServerGroup].self, from: data)
} catch {
print("❌ [MainView] Failed to decode stored groups: \(error)")
return []
}
}
private func requestNotificationPermissions() {
Task {
do {
try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])
} catch {
print("❌ [MainView] Failed to request notification permissions: \(error)")
}
}
}
private func checkServiceStatusChanges(for hostname: String, newInfo: ServerInfo) {
guard let ports = newInfo.ports else { return }
for port in ports {
let key = "\(hostname)-\(port.id)"
let previousStatus = previousServiceStates[key]
let currentStatus = port.status
previousServiceStates[key] = currentStatus
if let previousStatus, previousStatus != currentStatus {
if currentStatus == "offline" && enableStatusNotifications {
sendServiceNotification(service: port.service, hostname: hostname, status: "offline")
} else if currentStatus == "online" && previousStatus == "offline" && enableStatusNotifications {
sendServiceNotification(service: port.service, hostname: hostname, status: "online")
}
}
}
}
private func sendServiceNotification(service: String, hostname: String, status: String) {
let content = UNMutableNotificationContent()
content.title = "\(service) \(status.uppercased())"
content.body = "\(service) on \(hostname) is \(status)"
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
private func restartServer(for id: UUID) async -> ServerActionFeedback {
guard let server = servers.first(where: { $0.id == id }) else {
return ServerActionFeedback(
title: "Reboot Failed",
message: "The selected server could not be found."
)
}
guard server.info?.supportsRestartCommand == true else {
return ServerActionFeedback(
title: "Reboot Unavailable",
message: "\(server.hostname) does not support remote reboot via the API."
)
}
guard let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines),
!apiKey.isEmpty else {
return ServerActionFeedback(
title: "Reboot Failed",
message: "No API key is configured for \(server.hostname)."
)
}
guard let baseURL = URL(string: "https://\(server.hostname)") else {
return ServerActionFeedback(
title: "Reboot Failed",
message: "The server URL for \(server.hostname) is invalid."
)
}
restartingServerID = id
defer { restartingServerID = nil }
do {
let api: AnyServerAPI
if let versionString = server.info?.apiVersion,
let versionedAPI = APIFactory.createAPI(baseURL: baseURL, versionString: versionString) {
api = versionedAPI
} else {
api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL, apiKey: apiKey)
}
try await api.restartServer(apiKey: apiKey)
await PingService.suppressChecks(for: server.hostname, duration: 90)
return ServerActionFeedback(
title: "Reboot Requested",
message: "The reboot command was sent to \(server.hostname). The host may become unavailable briefly while it restarts."
)
} catch let error as URLError where Self.isExpectedRestartDisconnect(error) {
await PingService.suppressChecks(for: server.hostname, duration: 90)
return ServerActionFeedback(
title: "Reboot Requested",
message: "The reboot command appears to have been accepted by \(server.hostname). The connection dropped while the host was going away, which is expected during a reboot."
)
} catch APIError.httpError(404, let message) {
return ServerActionFeedback(
title: "Reboot Unavailable",
message: message ?? "\(server.hostname) returned 404 for /api/v2/server/reboot."
)
} catch {
return ServerActionFeedback(
title: "Reboot Failed",
message: error.localizedDescription
)
}
}
private static func isExpectedRestartDisconnect(_ error: URLError) -> Bool {
switch error.code {
case .timedOut,
.cannotConnectToHost,
.networkConnectionLost,
.notConnectedToInternet,
.cannotFindHost,
.dnsLookupFailed:
return true
default:
return false
}
}
}
private struct SidebarMaterialView: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.blendingMode = .behindWindow
view.material = .sidebar
view.state = .active
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
nsView.state = .active
}
}
private struct GroupDropDelegate: DropDelegate {
enum Placement {
case before
case after
}
let targetGroup: ServerGroup
@Binding var groups: [ServerGroup]
@Binding var draggedGroupID: UUID?
@Binding var indicator: GroupDropIndicator?
func dropEntered(info: DropInfo) {
updateIndicator(with: info)
}
func dropUpdated(info: DropInfo) -> DropProposal? {
updateIndicator(with: info)
return DropProposal(operation: .move)
}
func dropExited(info: DropInfo) {
indicator = nil
}
func performDrop(info: DropInfo) -> Bool {
defer {
draggedGroupID = nil
indicator = nil
}
guard
let draggedGroupID,
draggedGroupID != targetGroup.id,
let fromIndex = groups.firstIndex(where: { $0.id == draggedGroupID }),
let toIndex = groups.firstIndex(where: { $0.id == targetGroup.id })
else {
return false
}
let placement = placement(for: info)
let proposedIndex = placement == .after ? toIndex + 1 : toIndex
groups.move(
fromOffsets: IndexSet(integer: fromIndex),
toOffset: proposedIndex > fromIndex ? proposedIndex + 1 : proposedIndex
)
return true
}
private func updateIndicator(with info: DropInfo) {
guard let draggedGroupID, draggedGroupID != targetGroup.id else {
indicator = nil
return
}
indicator = GroupDropIndicator(
groupID: targetGroup.id,
placement: placement(for: info)
)
}
private func placement(for info: DropInfo) -> Placement {
info.location.y > 12 ? .after : .before
}
}
private struct GroupDropIndicator: Equatable {
let groupID: UUID
let placement: GroupDropDelegate.Placement
} }
#Preview { #Preview {
+38 -16
View File
@@ -23,6 +23,7 @@ struct PreferencesView: View {
} }
} }
@EnvironmentObject private var sparkleUpdater: SparkleUpdater @EnvironmentObject private var sparkleUpdater: SparkleUpdater
@Environment(\.colorScheme) private var colorScheme
@AppStorage("pingInterval") private var storedPingInterval: Int = 10 @AppStorage("pingInterval") private var storedPingInterval: Int = 10
@AppStorage("refreshInterval") private var storedRefreshInterval: Int = 60 @AppStorage("refreshInterval") private var storedRefreshInterval: Int = 60
@@ -143,12 +144,39 @@ struct PreferencesView: View {
} }
private func backgroundColor(for tab: Tab) -> Color { private func backgroundColor(for tab: Tab) -> Color {
if selection == tab { if selection == tab {
return Color.accentColor return sidebarSelectionColor
} }
if hoveredTab == tab { if hoveredTab == tab {
return Color.accentColor.opacity(0.2) return sidebarHoverColor
}
return sidebarBaseColor
}
private var sidebarSelectionColor: Color {
switch colorScheme {
case .dark:
return Color(red: 0.22, green: 0.45, blue: 0.88)
default:
return Color(red: 0.10, green: 0.39, blue: 0.90)
}
}
private var sidebarHoverColor: Color {
switch colorScheme {
case .dark:
return Color(red: 0.20, green: 0.22, blue: 0.27)
default:
return Color(red: 0.87, green: 0.91, blue: 0.98)
}
}
private var sidebarBaseColor: Color {
switch colorScheme {
case .dark:
return Color(red: 0.15, green: 0.16, blue: 0.19)
default:
return Color(red: 0.96, green: 0.97, blue: 0.99)
} }
return Color.accentColor.opacity(0.08)
} }
} }
@@ -252,11 +280,6 @@ private struct UpdatesPreferencesView: View {
Label("Check for Updates Now", systemImage: "sparkles") Label("Check for Updates Now", systemImage: "sparkles")
} }
Text("Updates are delivered via Sparkle. Configure your appcast URL and public EdDSA key in Info.plist (keys `SUFeedURL` and `SUPublicEDKey`).")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 4)
Spacer() Spacer()
} }
.toggleStyle(.switch) .toggleStyle(.switch)
@@ -265,19 +288,18 @@ private struct UpdatesPreferencesView: View {
} }
private struct NotificationsPreferencesView: View { private struct NotificationsPreferencesView: View {
var body: some View { @AppStorage("enableStatusNotifications") private var enableStatusNotifications: Bool = true
VStack(alignment: .leading, spacing: 12) { @AppStorage("enableAlertNotifications") private var enableAlertNotifications: Bool = true
Text("Notifications")
.font(.headline)
.padding(.bottom)
Text("Configure notification behavior here.") var body: some View {
.foregroundColor(.secondary) VStack(alignment: .leading, spacing: 18) {
Toggle("Status Notifications", isOn: $enableStatusNotifications)
Toggle("Alert Notifications", isOn: $enableAlertNotifications)
Spacer() Spacer()
} }
.toggleStyle(.switch)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding()
} }
} }
+147 -13
View File
@@ -7,32 +7,54 @@
import SwiftUI import SwiftUI
struct ServerActionFeedback: Identifiable {
let id = UUID()
let title: String
let message: String
}
struct ServerDetailView: View { struct ServerDetailView: View {
@Binding var server: Server @Binding var server: Server
var isFetching: Bool var isFetching: Bool
var canRestart: Bool = false
var isRestarting: Bool = false
var onRestart: (() async -> ServerActionFeedback)? = nil
@AppStorage("showIntervalIndicator") private var showIntervalIndicator: Bool = true @AppStorage("showIntervalIndicator") private var showIntervalIndicator: Bool = true
private var showPlaceholder: Bool { private var showPlaceholder: Bool {
server.info == nil server.info == nil
} }
@State private var progress: Double = 0 @State private var showRestartSheet = false
let timer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect() @State private var restartFeedback: ServerActionFeedback?
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
if showIntervalIndicator { if showIntervalIndicator {
ProgressView(value: progress) RefreshIntervalIndicator()
.progressViewStyle(LinearProgressViewStyle())
.padding(.horizontal)
.frame(height: 2)
} }
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer().frame(height: 6) Spacer().frame(height: 6)
TabView { TabView {
GeneralView(server: resolvedBinding) SummaryView(
server: resolvedBinding,
canRestart: canRestart,
isRestarting: isRestarting
) {
showRestartSheet = true
}
.tabItem {
Text("Summary").unredacted()
}
GeneralView(
server: resolvedBinding,
canRestart: canRestart,
isRestarting: isRestarting
) {
showRestartSheet = true
}
.tabItem { .tabItem {
Text("General").unredacted() Text("General").unredacted()
} }
@@ -56,13 +78,36 @@ struct ServerDetailView: View {
} }
.padding(0) .padding(0)
} }
.onReceive(timer) { _ in .overlay(alignment: .bottomTrailing) {
guard showIntervalIndicator else { return } if let feedback = restartFeedback {
withAnimation(.linear(duration: 1.0 / 60.0)) { RestartFeedbackBanner(
progress += 1.0 / (60.0 * 60.0) feedback: feedback,
if progress >= 1 { progress = 0 } onDismiss: {
restartFeedback = nil
}
)
.padding()
} }
} }
.sheet(isPresented: $showRestartSheet) {
RestartConfirmationSheet(
hostname: server.hostname,
isRestarting: isRestarting,
onCancel: {
showRestartSheet = false
},
onConfirm: {
guard let onRestart else { return }
showRestartSheet = false
Task {
let feedback = await onRestart()
await MainActor.run {
restartFeedback = feedback
}
}
}
)
}
} }
private var resolvedBinding: Binding<Server> { private var resolvedBinding: Binding<Server> {
@@ -77,10 +122,35 @@ struct ServerDetailView: View {
} }
} }
private struct RefreshIntervalIndicator: View {
@AppStorage("refreshInterval") private var refreshInterval: Int = 60
@State private var progress: Double = 0
private let indicatorTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
ProgressView(value: progress)
.progressViewStyle(LinearProgressViewStyle())
.padding(.horizontal)
.frame(height: 2)
.onReceive(indicatorTimer) { _ in
withAnimation(.linear(duration: 1)) {
progress += 1.0 / Double(max(refreshInterval, 1))
if progress >= 1 {
progress = 0
}
}
}
.onChange(of: refreshInterval) { _, _ in
progress = 0
}
}
}
#Preview { #Preview {
ServerDetailView( ServerDetailView(
server: .constant(Server(id: UUID(), hostname: "preview.example.com", info: ServerInfo.placeholder)), server: .constant(Server(id: UUID(), hostname: "preview.example.com", info: ServerInfo.placeholder)),
isFetching: false isFetching: false,
canRestart: true
) )
} }
@@ -96,3 +166,67 @@ private struct LoadingBadge: View {
.background(.ultraThinMaterial, in: Capsule()) .background(.ultraThinMaterial, in: Capsule())
} }
} }
private struct RestartConfirmationSheet: View {
let hostname: String
let isRestarting: Bool
let onCancel: () -> Void
let onConfirm: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Reboot this server?")
.font(.title3.weight(.semibold))
Text("This will send a reboot command to \(hostname).")
.foregroundColor(.secondary)
HStack {
Spacer()
Button("Cancel") {
onCancel()
}
.keyboardShortcut(.cancelAction)
.disabled(isRestarting)
Button("Reboot", role: .destructive) {
onConfirm()
}
.keyboardShortcut(.defaultAction)
.disabled(isRestarting)
}
}
.padding(24)
.frame(width: 420)
}
}
private struct RestartFeedbackBanner: View {
let feedback: ServerActionFeedback
let onDismiss: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(feedback.title)
.font(.headline)
Text(feedback.message)
.font(.subheadline)
.foregroundColor(.secondary)
HStack {
Spacer()
Button("OK") {
onDismiss()
}
.keyboardShortcut(.defaultAction)
}
}
.frame(maxWidth: 360, alignment: .leading)
.padding(24)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(radius: 12)
}
}
+47 -25
View File
@@ -16,10 +16,14 @@ struct ServerFormView: View {
var mode: Mode var mode: Mode
@Binding var servers: [Server] @Binding var servers: [Server]
@Binding var groups: [ServerGroup]
@State private var hostname: String @State private var hostname: String
@State private var apiKey: String @State private var apiKey: String
@State private var selectedGroupID: UUID?
@State private var connectionOK: Bool = false @State private var connectionOK: Bool = false
@State private var testingConnection: Bool = false
@State private var connectionError: String = ""
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -27,25 +31,30 @@ struct ServerFormView: View {
init( init(
mode: Mode, mode: Mode,
servers: Binding<[Server]>, servers: Binding<[Server]>,
groups: Binding<[ServerGroup]>,
dismiss: @escaping () -> Void dismiss: @escaping () -> Void
) { ) {
self.mode = mode self.mode = mode
self._servers = servers self._servers = servers
self._groups = groups
switch mode { switch mode {
case .add: case .add:
self._hostname = State(initialValue: "") self._hostname = State(initialValue: "")
self._apiKey = State(initialValue: "") self._apiKey = State(initialValue: "")
self._selectedGroupID = State(initialValue: nil)
case .edit(let server): case .edit(let server):
self._hostname = State(initialValue: server.hostname) self._hostname = State(initialValue: server.hostname)
self._apiKey = State(initialValue: KeychainHelper.loadApiKey(for: server.hostname) ?? "") self._apiKey = State(initialValue: KeychainHelper.loadApiKey(for: server.hostname) ?? "")
self._selectedGroupID = State(initialValue: server.groupID)
self._connectionOK = State(initialValue: true)
} }
} }
var body: some View { var body: some View {
VStack { VStack {
Text("Edit Server") Text(modeTitle)
.font(.headline) .font(.headline)
TextField("Hostname", text: $hostname) TextField("Hostname", text: $hostname)
@@ -55,6 +64,20 @@ struct ServerFormView: View {
SecureField("API Key", text: $apiKey) SecureField("API Key", text: $apiKey)
.textFieldStyle(RoundedBorderTextFieldStyle()) .textFieldStyle(RoundedBorderTextFieldStyle())
Picker("Group", selection: $selectedGroupID) {
Text("No Group").tag(nil as UUID?)
ForEach(groups) { group in
Text(group.name).tag(Optional(group.id))
}
}
.pickerStyle(.menu)
if !connectionError.isEmpty {
Text(connectionError)
.foregroundColor(.red)
.font(.caption)
}
HStack { HStack {
Button("Cancel") { Button("Cancel") {
dismiss() dismiss()
@@ -65,10 +88,10 @@ struct ServerFormView: View {
await testConnection() await testConnection()
} }
} }
.disabled(hostname.isEmpty || apiKey.isEmpty || testingConnection)
Button("Save") { Button("Save") {
saveServer() saveServer()
updateServer()
saveServers()
dismiss() dismiss()
} }
.disabled(hostname.isEmpty || apiKey.isEmpty || !connectionOK) .disabled(hostname.isEmpty || apiKey.isEmpty || !connectionOK)
@@ -83,6 +106,8 @@ struct ServerFormView: View {
print("serve \(server)") print("serve \(server)")
hostname = server.hostname hostname = server.hostname
apiKey = KeychainHelper.loadApiKey(for: server.hostname) ?? "" apiKey = KeychainHelper.loadApiKey(for: server.hostname) ?? ""
selectedGroupID = server.groupID
connectionOK = true
print("💡 Loaded server: \(hostname)") print("💡 Loaded server: \(hostname)")
} }
} }
@@ -102,9 +127,21 @@ struct ServerFormView: View {
let host = hostname.trimmingCharacters(in: .whitespacesAndNewlines) let host = hostname.trimmingCharacters(in: .whitespacesAndNewlines)
let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
await MainActor.run {
testingConnection = true
connectionError = ""
}
let reachable = await PingService.ping(hostname: host, apiKey: key) let reachable = await PingService.ping(hostname: host, apiKey: key)
await MainActor.run { await MainActor.run {
connectionOK = reachable testingConnection = false
if reachable {
connectionOK = true
connectionError = ""
} else {
connectionOK = false
connectionError = "Connection failed. Check hostname and API key."
}
} }
// //
// guard let url = URL(string: "https://\(host)/api/v2/ping") else { // guard let url = URL(string: "https://\(host)/api/v2/ping") else {
@@ -183,40 +220,22 @@ struct ServerFormView: View {
switch mode { switch mode {
case .add: case .add:
print("adding server") print("adding server")
let newServer = Server(hostname: trimmedHost) let newServer = Server(hostname: trimmedHost, groupID: selectedGroupID)
servers.append(newServer) servers.append(newServer)
KeychainHelper.saveApiKey(trimmedKey, for: trimmedHost) KeychainHelper.saveApiKey(trimmedKey, for: trimmedHost)
saveServers()
case .edit(let oldServer): case .edit(let oldServer):
if let index = servers.firstIndex(where: { $0.id == oldServer.id }) { if let index = servers.firstIndex(where: { $0.id == oldServer.id }) {
let oldHostname = servers[index].hostname let oldHostname = servers[index].hostname
servers[index].hostname = trimmedHost servers[index].hostname = trimmedHost
servers[index].groupID = selectedGroupID
if oldHostname != trimmedHost { if oldHostname != trimmedHost {
KeychainHelper.deleteApiKey(for: oldHostname) KeychainHelper.deleteApiKey(for: oldHostname)
} }
KeychainHelper.saveApiKey(trimmedKey, for: trimmedHost) KeychainHelper.saveApiKey(trimmedKey, for: trimmedHost)
} }
} }
}
private func updateServer() { saveServers()
print ("in edit server")
guard case let .edit(server) = mode else {
return
}
if let index = servers.firstIndex(where: { $0.id == server.id }) {
// Only replace hostname if changed
let oldHostname = servers[index].hostname
servers[index].hostname = hostname
// Update Keychain
if oldHostname != hostname {
KeychainHelper.deleteApiKey(for: oldHostname)
}
KeychainHelper.saveApiKey(apiKey, for: hostname)
saveServers()
}
} }
private func saveServers() { private func saveServers() {
@@ -243,6 +262,9 @@ struct ServerFormView: View {
servers: .constant([ servers: .constant([
Server(hostname: "example.com") Server(hostname: "example.com")
]), ]),
groups: .constant([
ServerGroup(name: "Production")
]),
dismiss: {} dismiss: {}
) )
} }
+148 -56
View File
@@ -9,6 +9,9 @@ import SwiftUI
struct GeneralView: View { struct GeneralView: View {
@Binding var server: Server @Binding var server: Server
var canRestart: Bool = false
var isRestarting: Bool = false
var onRestart: (() -> Void)? = nil
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
@@ -46,53 +49,76 @@ struct GeneralView: View {
TableRowView { TableRowView {
Text("Operating system") Text("Operating system")
} value: {
InfoCell(value: operatingSystemRows, monospaced: true)
}
TableRowView {
Text("CPU")
} value: { } value: {
InfoCell( InfoCell(
value: { value: [
guard let os = server.info?.operatingSystem else { return [] } "\(server.info?.cpuCores ?? 0) cores",
var rows: [String] = [] String(format: "Load %.2f%% (%.2f / %.2f / %.2f)",
server.info?.load.percent ?? 0,
let distro = [os.distribution, os.version] server.info?.load.minute1 ?? 0,
.filter { !$0.isEmpty } server.info?.load.minute5 ?? 0,
.joined(separator: " ") server.info?.load.minute15 ?? 0)
.trimmingCharacters(in: .whitespacesAndNewlines) ],
var description = os.label.trimmingCharacters(in: .whitespacesAndNewlines)
if description.isEmpty {
description = distro
} else if !distro.isEmpty && description.range(of: distro, options: [.caseInsensitive]) == nil {
description += "\(distro)"
}
if !os.architecture.isEmpty &&
description.range(of: os.architecture, options: [.caseInsensitive]) == nil {
description += " (\(os.architecture))"
}
if !description.isEmpty {
rows.append(description)
}
if let updates = os.updates {
var updateDescription = "Updates: \(updates.updateCount)"
if updates.securityUpdateCount > 0 {
updateDescription += "\(updates.securityUpdateCount) security"
}
rows.append(updateDescription)
if updates.rebootRequired {
rows.append("Reboot required")
}
}
if os.endOfLife {
rows.append("End-of-life release")
}
return rows
}(),
monospaced: true monospaced: true
) )
} }
TableRowView { TableRowView {
Text("Sytem PHP version") Text("Memory")
} value: {
InfoCell(
value: [
"Used \(server.info?.memory.used ?? 0) / Total \(server.info?.memory.total ?? 0)",
String(format: "%.2f %%", server.info?.memory.percent ?? 0)
].map { line in
line
.replacingOccurrences(of: "\(server.info?.memory.used ?? 0)", with: (server.info?.memory.used ?? 0).toNiceBinaryUnit())
.replacingOccurrences(of: "\(server.info?.memory.total ?? 0)", with: (server.info?.memory.total ?? 0).toNiceBinaryUnit())
},
monospaced: true
)
}
TableRowView {
Text("Swap")
} value: {
InfoCell(
value: [
"Used \(server.info?.swap.used ?? 0) / Total \(server.info?.swap.total ?? 0)",
String(format: "%.2f %%", server.info?.swap.percent ?? 0)
].map { line in
line
.replacingOccurrences(of: "\(server.info?.swap.used ?? 0)", with: (server.info?.swap.used ?? 0).toNiceBinaryUnit())
.replacingOccurrences(of: "\(server.info?.swap.total ?? 0)", with: (server.info?.swap.total ?? 0).toNiceBinaryUnit())
},
monospaced: true
)
}
TableRowView {
Text("Disk space")
} value: {
InfoCell(
value: [
"Used \(server.info?.diskSpace.used ?? 0) / Total \(server.info?.diskSpace.total ?? 0)",
String(format: "%.2f %%", server.info?.diskSpace.percent ?? 0)
].map { line in
line
.replacingOccurrences(of: "\(server.info?.diskSpace.used ?? 0)", with: (server.info?.diskSpace.used ?? 0).toNiceBinaryUnit())
.replacingOccurrences(of: "\(server.info?.diskSpace.total ?? 0)", with: (server.info?.diskSpace.total ?? 0).toNiceBinaryUnit())
},
monospaced: true
)
}
TableRowView {
Text("System PHP version")
} value: { } value: {
InfoCell(value: [server.info?.phpVersion ?? ""], monospaced: true) InfoCell(value: [server.info?.phpVersion ?? ""], monospaced: true)
} }
@@ -100,22 +126,34 @@ struct GeneralView: View {
TableRowView(showDivider: false) { TableRowView(showDivider: false) {
Text("Additional PHP interpreters") Text("Additional PHP interpreters")
} value: { } value: {
InfoCell( InfoCell(value: additionalPHPRows, monospaced: true)
value: { }
let interpreters = server.info?.additionalPHPInterpreters ?? []
if interpreters.isEmpty { if canRestart, let onRestart {
return ["None"] TableRowView(showDivider: false) {
Text("Actions")
} value: {
VStack(alignment: .leading, spacing: 8) {
Button(role: .destructive) {
onRestart()
} label: {
if isRestarting {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Rebooting…")
}
} else {
Label("Reboot Server", systemImage: "arrow.clockwise.circle")
}
} }
let versions = interpreters .disabled(isRestarting)
.map { $0.fullVersion }
.filter { !$0.isEmpty } Text("Sends a reboot command to the selected host.")
if versions.isEmpty { .font(.caption)
return ["None"] .foregroundColor(.secondary)
} }
return [versions.joined(separator: "")] }
}(),
monospaced: true
)
} }
} }
.padding() .padding()
@@ -125,6 +163,60 @@ struct GeneralView: View {
.scrollDisabled(true) .scrollDisabled(true)
} }
} }
private var operatingSystemRows: [String] {
guard let os = server.info?.operatingSystem else { return [] }
var rows: [String] = []
let distro = [os.distribution, os.version]
.filter { !$0.isEmpty }
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
var description = os.label.trimmingCharacters(in: .whitespacesAndNewlines)
if description.isEmpty {
description = distro
} else if !distro.isEmpty && description.range(of: distro, options: [.caseInsensitive]) == nil {
description += "\(distro)"
}
if !os.architecture.isEmpty &&
description.range(of: os.architecture, options: [.caseInsensitive]) == nil {
description += " (\(os.architecture))"
}
if !description.isEmpty {
rows.append(description)
}
if let updates = os.updates {
var updateDescription = "Updates: \(updates.updateCount)"
if updates.securityUpdateCount > 0 {
updateDescription += "\(updates.securityUpdateCount) security"
}
rows.append(updateDescription)
if updates.rebootRequired {
rows.append("Reboot required")
}
}
if os.endOfLife {
rows.append("End-of-life release")
}
return rows
}
private var additionalPHPRows: [String] {
let interpreters = server.info?.additionalPHPInterpreters ?? []
let versions = interpreters
.map { $0.fullVersion }
.filter { !$0.isEmpty }
if versions.isEmpty {
return ["None"]
}
return [versions.joined(separator: "")]
}
} }
#Preview { #Preview {
@@ -132,7 +224,7 @@ struct GeneralView: View {
@State var previewServer = Server(hostname: "example.com", info: .placeholder) @State var previewServer = Server(hostname: "example.com", info: .placeholder)
var body: some View { var body: some View {
GeneralView(server: $previewServer) GeneralView(server: $previewServer, canRestart: true)
} }
} }
+20 -16
View File
@@ -17,6 +17,10 @@ import SwiftUI
struct ResourcesView: View { struct ResourcesView: View {
@Binding var server: Server @Binding var server: Server
private var displayInfo: ServerInfo {
server.info ?? .placeholder
}
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
ScrollView { ScrollView {
@@ -25,40 +29,40 @@ struct ResourcesView: View {
Text("CPU Load") Text("CPU Load")
} value: { } value: {
LoadBarCell( LoadBarCell(
percent: (server.info?.load.percent)!, percent: displayInfo.load.percent,
load1: (server.info?.load.minute1)!, load1: displayInfo.load.minute1,
load5: (server.info?.load.minute5)!, load5: displayInfo.load.minute5,
load15: (server.info?.load.minute15)! load15: displayInfo.load.minute15
) )
} }
TableRowView { TableRowView {
Text("Memory") Text("Memory")
} value: { } value: {
UsageBarCell( UsageBarCell(
free: (server.info?.memory.free)!, free: displayInfo.memory.free,
used: (server.info?.memory.used)!, used: displayInfo.memory.used,
total: (server.info?.memory.total)!, total: displayInfo.memory.total,
percent: (server.info?.memory.percent)! percent: displayInfo.memory.percent
) )
} }
TableRowView { TableRowView {
Text("Swap") Text("Swap")
} value: { } value: {
UsageBarCell( UsageBarCell(
free: (server.info?.swap.free)!, free: displayInfo.swap.free,
used: (server.info?.swap.used)!, used: displayInfo.swap.used,
total: (server.info?.swap.total)!, total: displayInfo.swap.total,
percent: (server.info?.swap.percent)! percent: displayInfo.swap.percent
) )
} }
TableRowView { TableRowView {
Text("SSD") Text("SSD")
} value: { } value: {
UsageBarCell( UsageBarCell(
free: (server.info?.diskSpace.free)!, free: displayInfo.diskSpace.free,
used: (server.info?.diskSpace.used)!, used: displayInfo.diskSpace.used,
total: (server.info?.diskSpace.total)!, total: displayInfo.diskSpace.total,
percent: (server.info?.diskSpace.percent)! percent: displayInfo.diskSpace.percent
) )
} }
} }
+434
View File
@@ -0,0 +1,434 @@
//
// SummaryView.swift
// iKeyMon
//
// Created by tracer on 21.04.26.
//
import SwiftUI
import SwiftData
import Charts
struct SummaryView: View {
private enum TimeRange: String, CaseIterable, Identifiable {
case hour = "Hour"
case day = "Day"
case week = "Week"
case month = "Month"
var id: String { rawValue }
var duration: TimeInterval {
switch self {
case .hour:
return 60 * 60
case .day:
return 24 * 60 * 60
case .week:
return 7 * 24 * 60 * 60
case .month:
return 30 * 24 * 60 * 60
}
}
var axisLabelFormat: Date.FormatStyle {
switch self {
case .hour:
return .dateTime.hour().minute()
case .day:
return .dateTime.hour()
case .week:
return .dateTime.month(.abbreviated).day()
case .month:
return .dateTime.month(.abbreviated).day()
}
}
}
@Binding var server: Server
var canRestart: Bool = false
var isRestarting: Bool = false
var onRestart: (() -> Void)? = nil
@Query(sort: \MetricSample.timestamp, order: .forward) private var metricSamples: [MetricSample]
@State private var selectedRange: TimeRange = .hour
private let cardSpacing: CGFloat = 16
private let minCardWidth: CGFloat = 260
var body: some View {
GeometryReader { geometry in
let contentWidth = max(geometry.size.width - 36, 0)
let chartColumns = summaryColumns(for: contentWidth)
ScrollView {
VStack(alignment: .leading, spacing: 18) {
summaryHeader
HStack {
Spacer()
Picker("Time Range", selection: $selectedRange) {
ForEach(TimeRange.allCases) { range in
Text(range.rawValue)
.tag(range)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 260)
}
.frame(maxWidth: .infinity)
LazyVGrid(columns: chartColumns, spacing: cardSpacing) {
historyChartCard(
title: "CPU usage",
currentValue: server.info?.load.percent ?? 0,
tint: loadTint,
samples: filteredSamples,
value: \.cpuPercent,
yAxisDomain: cpuChartDomain,
yAxisValues: nil,
clampValuesToDomain: false,
footer: [
("1 min", String(format: "%.2f", server.info?.load.minute1 ?? 0)),
("5 min", String(format: "%.2f", server.info?.load.minute5 ?? 0)),
("15 min", String(format: "%.2f", server.info?.load.minute15 ?? 0))
]
)
historyChartCard(
title: "Memory usage",
currentValue: server.info?.memory.percent ?? 0,
tint: .blue,
samples: filteredSamples,
value: \.memoryPercent,
footer: [
("Used", (server.info?.memory.used ?? 0).toNiceBinaryUnit()),
("Free", (server.info?.memory.free ?? 0).toNiceBinaryUnit()),
("Total", (server.info?.memory.total ?? 0).toNiceBinaryUnit())
]
)
historyChartCard(
title: "Disk usage",
currentValue: server.info?.diskSpace.percent ?? 0,
tint: .green,
samples: filteredSamples,
value: \.diskPercent,
footer: [
("Used", (server.info?.diskSpace.used ?? 0).toNiceBinaryUnit()),
("Free", (server.info?.diskSpace.free ?? 0).toNiceBinaryUnit()),
("Total", (server.info?.diskSpace.total ?? 0).toNiceBinaryUnit())
]
)
historyChartCard(
title: "Swap usage",
currentValue: server.info?.swap.percent ?? 0,
tint: .orange,
samples: filteredSamples,
value: \.swapPercent,
footer: [
("Used", (server.info?.swap.used ?? 0).toNiceBinaryUnit()),
("Free", (server.info?.swap.free ?? 0).toNiceBinaryUnit()),
("Total", (server.info?.swap.total ?? 0).toNiceBinaryUnit())
]
)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(18)
.frame(width: contentWidth, alignment: .leading)
}
}
}
private func summaryColumns(for width: CGFloat) -> [GridItem] {
let count = max(1, min(4, Int((width + cardSpacing) / (minCardWidth + cardSpacing))))
return Array(
repeating: GridItem(.flexible(minimum: minCardWidth), spacing: cardSpacing, alignment: .top),
count: count
)
}
private var selectedRangeStart: Date {
Date().addingTimeInterval(-selectedRange.duration)
}
private var filteredSamples: [MetricSample] {
return metricSamples.filter { sample in
sample.serverID == server.id && sample.timestamp >= selectedRangeStart
}
}
private var cpuChartDomain: ClosedRange<Double> {
let values = filteredSamples.map(\.cpuPercent) + [server.info?.load.percent ?? 0]
let maximum = max(values.max() ?? 0, 100)
let roundedUpperBound = ceil(maximum / 25) * 25
return 0 ... roundedUpperBound
}
private var summaryHeader: some View {
dashboardCard {
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 10) {
Circle()
.fill(server.pingable ? Color.green : Color.red)
.frame(width: 10, height: 10)
Text(server.hostname)
.font(.system(size: 22, weight: .semibold))
statusBadge(server.pingable ? "Online" : "Offline", tint: server.pingable ? .green : .red)
}
HStack(spacing: 8) {
statusBadge(panelBadgeText, tint: .orange)
statusBadge(apiBadgeText, tint: .blue)
if let operatingSystemSummary = server.info?.operatingSystemSummary, !operatingSystemSummary.isEmpty {
statusBadge(operatingSystemSummary, tint: .secondary)
}
}
Text(summaryLine)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 12)
if canRestart, let onRestart {
Button(role: .destructive) {
onRestart()
} label: {
if isRestarting {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Rebooting…")
}
} else {
Label("Reboot Server", systemImage: "arrow.clockwise.circle")
}
}
.disabled(isRestarting)
}
}
}
}
private func historyChartCard(
title: String,
currentValue: Double,
tint: Color,
samples: [MetricSample],
value: KeyPath<MetricSample, Double>,
yAxisDomain: ClosedRange<Double> = 0...100,
yAxisValues: [Double]? = [0, 25, 50, 75, 100],
clampValuesToDomain: Bool = true,
footer: [(String, String)],
caption: String? = nil
) -> some View {
dashboardCard {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline) {
Text(title)
.font(.headline)
Spacer()
Text(String(format: "%.2f %%", currentValue))
.font(.title3.weight(.semibold))
.foregroundStyle(tint)
.monospacedDigit()
}
if samples.isEmpty {
ContentUnavailableView(
"No chart data yet",
systemImage: "chart.xyaxis.line",
description: Text("History appears after a few refresh cycles.")
)
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
} else {
Chart(samples) { sample in
let rawValue = sample[keyPath: value]
let sampleValue = clampValuesToDomain
? min(max(rawValue, yAxisDomain.lowerBound), yAxisDomain.upperBound)
: rawValue
AreaMark(
x: .value("Time", sample.timestamp),
y: .value(title, sampleValue)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(
LinearGradient(
colors: [
tint.opacity(0.55),
tint.opacity(0.12)
],
startPoint: .top,
endPoint: .bottom
)
)
LineMark(
x: .value("Time", sample.timestamp),
y: .value(title, sampleValue)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(tint)
.lineStyle(StrokeStyle(lineWidth: 2))
}
.chartYScale(domain: yAxisDomain)
.chartXScale(domain: selectedRangeStart ... Date())
.chartXAxis {
AxisMarks(values: .automatic(desiredCount: 6)) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(.secondary.opacity(0.25))
AxisTick()
.foregroundStyle(.secondary.opacity(0.7))
AxisValueLabel(format: selectedRange.axisLabelFormat)
}
}
.chartYAxis {
if let yAxisValues {
AxisMarks(position: .leading, values: yAxisValues) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(.secondary.opacity(0.2))
AxisTick()
.foregroundStyle(.secondary.opacity(0.7))
AxisValueLabel {
if let percent = value.as(Double.self) {
Text("\(Int(percent))%")
}
}
}
} else {
AxisMarks(position: .leading, values: .automatic(desiredCount: 5)) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(.secondary.opacity(0.2))
AxisTick()
.foregroundStyle(.secondary.opacity(0.7))
AxisValueLabel {
if let percent = value.as(Double.self) {
Text("\(Int(percent))%")
}
}
}
}
}
.frame(height: 220)
}
HStack(spacing: 16) {
ForEach(Array(footer.enumerated()), id: \.offset) { _, item in
metric(item.0, value: item.1)
}
}
if let caption {
Text(caption)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private func metric(_ title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
Text(value)
.font(.system(.body, design: .monospaced))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func statusBadge(_ text: String, tint: Color) -> some View {
Text(text)
.font(.caption.weight(.semibold))
.foregroundStyle(tint == .secondary ? .secondary : tint)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background {
Capsule(style: .continuous)
.fill(tint == .secondary ? Color.secondary.opacity(0.12) : tint.opacity(0.14))
}
}
private func dashboardCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 0) {
content()
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.white.opacity(0.03))
)
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.white.opacity(0.06), lineWidth: 1)
}
}
private var apiBadgeText: String {
guard let apiVersion = server.info?.apiVersion, !apiVersion.isEmpty else {
return "API unknown"
}
return "API \(apiVersion)"
}
private var panelBadgeText: String {
guard let panelVersion = server.info?.panelVersion, !panelVersion.isEmpty else {
return "KeyHelp unknown"
}
return "KeyHelp \(panelVersion)"
}
private var summaryLine: String {
var parts: [String] = []
if let uptime = server.info?.uptime, !uptime.isEmpty {
parts.append("Uptime \(uptime)")
}
if let serverTime = server.info?.formattedServerTime, !serverTime.isEmpty {
parts.append("Server time \(serverTime)")
}
if parts.isEmpty {
return "Live summary for \(server.hostname)"
}
return parts.joined(separator: "")
}
private var loadTint: Color {
switch server.info?.load.level.lowercased() {
case "warning":
return .orange
case "critical":
return .red
default:
return .green
}
}
}
#Preview {
struct PreviewWrapper: View {
@State var previewServer = Server(hostname: "example.com", info: .placeholder)
var body: some View {
SummaryView(server: $previewServer, canRestart: true)
.padding()
.frame(width: 1100, height: 760)
}
}
return PreviewWrapper()
}
+56
View File
@@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import SwiftData
#if os(macOS) #if os(macOS)
import AppKit import AppKit
#endif #endif
@@ -13,6 +14,7 @@ import AppKit
@main @main
struct iKeyMonApp: App { struct iKeyMonApp: App {
@StateObject private var sparkleUpdater = SparkleUpdater() @StateObject private var sparkleUpdater = SparkleUpdater()
private let modelContainer: ModelContainer
init() { init() {
#if os(macOS) #if os(macOS)
@@ -20,6 +22,8 @@ struct iKeyMonApp: App {
NSApplication.shared.applicationIconImage = customIcon NSApplication.shared.applicationIconImage = customIcon
} }
#endif #endif
self.modelContainer = Self.makeModelContainer()
} }
var body: some Scene { var body: some Scene {
@@ -30,6 +34,7 @@ struct iKeyMonApp: App {
NSApp.terminate(nil) NSApp.terminate(nil)
} }
} }
.modelContainer(modelContainer)
.windowResizability(.contentMinSize) .windowResizability(.contentMinSize)
Settings { Settings {
@@ -38,4 +43,55 @@ struct iKeyMonApp: App {
.environmentObject(sparkleUpdater) .environmentObject(sparkleUpdater)
} }
} }
private static func makeModelContainer() -> ModelContainer {
let schema = Schema([MetricSample.self])
let storeURL = metricStoreURL()
let configuration = ModelConfiguration(url: storeURL)
do {
return try ModelContainer(for: schema, configurations: [configuration])
} catch {
print("⚠️ [SwiftData] Failed to open metric store at \(storeURL.path): \(error)")
resetMetricStore(at: storeURL)
do {
return try ModelContainer(for: schema, configurations: [configuration])
} catch {
fatalError("Unable to create metric history store: \(error)")
}
}
}
private static func metricStoreURL() -> URL {
let fileManager = FileManager.default
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let directory = appSupport
.appendingPathComponent(Bundle.main.bundleIdentifier ?? "net.24unix.iKeyMon", isDirectory: true)
do {
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
} catch {
fatalError("Unable to create application support directory: \(error)")
}
return directory.appendingPathComponent("metric-history.store")
}
private static func resetMetricStore(at url: URL) {
let fileManager = FileManager.default
let sidecarURLs = [
url,
url.appendingPathExtension("shm"),
url.appendingPathExtension("wal")
]
for sidecarURL in sidecarURLs where fileManager.fileExists(atPath: sidecarURL.path) {
do {
try fileManager.removeItem(at: sidecarURL)
} catch {
print("⚠️ [SwiftData] Failed to remove \(sidecarURL.path): \(error)")
}
}
}
} }
+15 -15
View File
@@ -3,28 +3,28 @@
<channel> <channel>
<title>iKeyMon</title> <title>iKeyMon</title>
<item> <item>
<title>26.0.28</title> <title>26.1.11</title>
<pubDate>Sun, 07 Dec 2025 17:02:18 +0100</pubDate> <pubDate>Tue, 21 Apr 2026 18:06:37 +0200</pubDate>
<sparkle:version>59</sparkle:version> <sparkle:version>187</sparkle:version>
<sparkle:shortVersionString>26.0.28</sparkle:shortVersionString> <sparkle:shortVersionString>26.1.11</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion> <sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/iKeyMon-26.0.28.zip" length="4811473" type="application/octet-stream" sparkle:edSignature="tF188T0m/j22MStCVbVkrf2JllyZ9wiEmc++kgF4GTKhoAuHURYvT/Euy+ivEodmQ/LzeFolN4lViqaVa3y+DQ=="/> <enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.1.11/iKeyMon-26.1.11.zip" length="3292064" type="application/octet-stream" sparkle:edSignature="8+eCOcRbetd/LCrEPYNDD6K1F+V+8lflUORk1piWrWtGkKD4Q1GQhJ9rNTZ1gcGKHaqXf024y4h4uoHeo4/yBw=="/>
</item> </item>
<item> <item>
<title>26.0.27</title> <title>26.1.10</title>
<pubDate>Sun, 07 Dec 2025 16:47:33 +0100</pubDate> <pubDate>Tue, 21 Apr 2026 00:18:16 +0200</pubDate>
<sparkle:version>57</sparkle:version> <sparkle:version>184</sparkle:version>
<sparkle:shortVersionString>26.0.27</sparkle:shortVersionString> <sparkle:shortVersionString>26.1.10</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion> <sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/iKeyMon-26.0.27.zip" length="4811492" type="application/octet-stream" sparkle:edSignature="6aEv0ii20pAkIl8kYWNkHM7+8APyDQtsus0SkF3C7/7q2X73HAsrsskNXjiiq0YF6bPVNAEs5y8G8GpwmerrCw=="/> <enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.1.10/iKeyMon-26.1.10.zip" length="3193339" type="application/octet-stream" sparkle:edSignature="SwXVc4kmCUAec7VcDcoxDySDhGTXwdLz30q9SiyvaGK/P7cel3iljlvDd6Rc4/M1wtHoOZXXo+93lFhnLMB+Aw=="/>
</item> </item>
<item> <item>
<title>26.0.21</title> <title>26.1.9</title>
<pubDate>Wed, 26 Nov 2025 18:44:41 +0100</pubDate> <pubDate>Sun, 19 Apr 2026 23:04:07 +0200</pubDate>
<sparkle:version>49</sparkle:version> <sparkle:version>181</sparkle:version>
<sparkle:shortVersionString>26.0.21</sparkle:shortVersionString> <sparkle:shortVersionString>26.1.9</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion> <sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.21/iKeyMon-26.0.21.zip" length="4802995" type="application/octet-stream" sparkle:edSignature="bYXN15YyKlSmHKNXPizEW2WrVXQSgD5XOgbtzOYNL+maG8DB/jZ08A+cYtGgqUeSRd+X6Z5Ue+Tpdn4/ewsFBw=="/> <enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.1.9/iKeyMon-26.1.9.zip" length="3109488" type="application/octet-stream" sparkle:edSignature="ZV96uUMdYC/X90H3G10FMzmZHKUEWpe1geSe/5IBJ7EOCUmx7Mz352i6VMWumFnCtDD4jHo173W9eySUX9KvDA=="/>
</item> </item>
</channel> </channel>
</rss> </rss>
+11 -9
View File
@@ -4,13 +4,19 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REMOTE_NAME="${1:-origin}" REMOTE_NAME="${1:-origin}"
QUIET_RELEASE="${QUIET_RELEASE:-1}" QUIET_RELEASE="${QUIET_RELEASE:-1}"
RELEASE_LOG="${RELEASE_LOG:-$ROOT_DIR/build/release.log}" RELEASE_LOG="${RELEASE_LOG:-$ROOT_DIR/.git/release.log}"
if [[ -n "${SKIP_RELEASE:-}" ]]; then if [[ -n "${SKIP_RELEASE:-}" ]]; then
echo "release: skipped (SKIP_RELEASE=1)" echo "release: skipped (SKIP_RELEASE=1)"
exit 0 exit 0
fi fi
head_subject="$(git -C "$ROOT_DIR" log -1 --pretty=%s 2>/dev/null || true)"
if [[ "$head_subject" =~ ^chore:\ release\ ]]; then
echo "release: skipped (HEAD is already a release commit)"
exit 0
fi
should_release=false should_release=false
release_local_ref="" release_local_ref=""
release_remote_ref="" release_remote_ref=""
@@ -67,7 +73,8 @@ fi
git -C "$ROOT_DIR" add "$ROOT_DIR/version.json" "$ROOT_DIR/iKeyMon.xcodeproj/project.pbxproj" "$ROOT_DIR/Sparkle/appcast.xml" git -C "$ROOT_DIR" add "$ROOT_DIR/version.json" "$ROOT_DIR/iKeyMon.xcodeproj/project.pbxproj" "$ROOT_DIR/Sparkle/appcast.xml"
if git -C "$ROOT_DIR" diff --cached --quiet; then if git -C "$ROOT_DIR" diff --cached --quiet; then
echo "release: no changes detected; skipping commit" echo "release: no changes detected; continuing push"
exit 0
else else
run_logged git -C "$ROOT_DIR" commit -m "chore: release ${NEW_VERSION}" || { run_logged git -C "$ROOT_DIR" commit -m "chore: release ${NEW_VERSION}" || {
echo "release: commit failed (log: $RELEASE_LOG)" echo "release: commit failed (log: $RELEASE_LOG)"
@@ -75,10 +82,5 @@ else
} }
fi fi
if SKIP_RELEASE=1 git -C "$ROOT_DIR" push --quiet "$REMOTE_NAME" "${release_local_ref:-refs/heads/master}:${release_remote_ref:-refs/heads/master}"; then echo "release: created commit for v${NEW_VERSION}; run 'git push' again to push the new release commit"
echo "release: success v${NEW_VERSION}" exit 1
exit 1
else
echo "release: push failed (log: $RELEASE_LOG)"
exit 1
fi
+2
View File
@@ -6,5 +6,7 @@
<string>https://git.24unix.net/tracer/iKeyMon/raw/branch/master/Sparkle/appcast.xml</string> <string>https://git.24unix.net/tracer/iKeyMon/raw/branch/master/Sparkle/appcast.xml</string>
<key>SUPublicEDKey</key> <key>SUPublicEDKey</key>
<string>EgJgrOGQ79L5me616jA7kDCEOgx+Rg11uYLYLLIyzTI=</string> <string>EgJgrOGQ79L5me616jA7kDCEOgx+Rg11uYLYLLIyzTI=</string>
<key>SUEnableInstallerLauncherService</key>
<false/>
</dict> </dict>
</plist> </plist>
+5 -3
View File
@@ -3,10 +3,12 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <false/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict> </dict>
</plist> </plist>
+16 -4
View File
@@ -7,6 +7,7 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
5221016D2EE5E82700D04952 /* appcast.xml in Resources */ = {isa = PBXBuildFile; fileRef = 5221016B2EE5E82700D04952 /* appcast.xml */; };
52A9B79F2EC8E7EE004DD4A2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */; }; 52A9B79F2EC8E7EE004DD4A2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */; };
52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */; }; 52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */; };
52A9B9722ECF751C004DD4A2 /* signing.env.example in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B9712ECF751C004DD4A2 /* signing.env.example */; }; 52A9B9722ECF751C004DD4A2 /* signing.env.example in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B9712ECF751C004DD4A2 /* signing.env.example */; };
@@ -28,6 +29,7 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
5203C24D2D997D2800576D4A /* iKeyMon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iKeyMon.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5203C24D2D997D2800576D4A /* iKeyMon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iKeyMon.app; sourceTree = BUILT_PRODUCTS_DIR; };
5221016B2EE5E82700D04952 /* appcast.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = appcast.xml; sourceTree = "<group>"; };
52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
52A9B7882EC8E7EE004DD4A2 /* iKeyMon.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iKeyMon.entitlements; sourceTree = "<group>"; }; 52A9B7882EC8E7EE004DD4A2 /* iKeyMon.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iKeyMon.entitlements; sourceTree = "<group>"; };
52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = "<group>"; }; 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = "<group>"; };
@@ -76,6 +78,7 @@
52A9B9712ECF751C004DD4A2 /* signing.env.example */, 52A9B9712ECF751C004DD4A2 /* signing.env.example */,
52A9BEC92ED3874F004DD4A2 /* README.md */, 52A9BEC92ED3874F004DD4A2 /* README.md */,
52A9BD122ED37E08004DD4A2 /* Frameworks */, 52A9BD122ED37E08004DD4A2 /* Frameworks */,
5221016C2EE5E82700D04952 /* Sparkle */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -87,6 +90,14 @@
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
5221016C2EE5E82700D04952 /* Sparkle */ = {
isa = PBXGroup;
children = (
5221016B2EE5E82700D04952 /* appcast.xml */,
);
path = Sparkle;
sourceTree = "<group>";
};
52A9BD122ED37E08004DD4A2 /* Frameworks */ = { 52A9BD122ED37E08004DD4A2 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -166,6 +177,7 @@
files = ( files = (
52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.md in Resources */, 52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.md in Resources */,
52A9BECA2ED3874F004DD4A2 /* README.md in Resources */, 52A9BECA2ED3874F004DD4A2 /* README.md in Resources */,
5221016D2EE5E82700D04952 /* appcast.xml in Resources */,
52A9B79F2EC8E7EE004DD4A2 /* Assets.xcassets in Resources */, 52A9B79F2EC8E7EE004DD4A2 /* Assets.xcassets in Resources */,
52A9B9722ECF751C004DD4A2 /* signing.env.example in Resources */, 52A9B9722ECF751C004DD4A2 /* signing.env.example in Resources */,
); );
@@ -310,7 +322,7 @@
CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements; CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 59; CURRENT_PROJECT_VERSION = 187;
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
DEVELOPMENT_TEAM = Q5486ZVAFT; DEVELOPMENT_TEAM = Q5486ZVAFT;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -325,7 +337,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 26.0.28; MARKETING_VERSION = 26.1.11;
PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon; PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -341,7 +353,7 @@
CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements; CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 59; CURRENT_PROJECT_VERSION = 187;
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
DEVELOPMENT_TEAM = Q5486ZVAFT; DEVELOPMENT_TEAM = Q5486ZVAFT;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -356,7 +368,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 26.0.28; MARKETING_VERSION = 26.1.11;
PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon; PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
+23 -9
View File
@@ -3,6 +3,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_DIR="$ROOT_DIR/build" BUILD_DIR="$ROOT_DIR/build"
DERIVED_DATA_DIR="$BUILD_DIR/derived-data"
ARTIFACTS_DIR="$ROOT_DIR/dist" ARTIFACTS_DIR="$ROOT_DIR/dist"
SCHEME="iKeyMon" SCHEME="iKeyMon"
PROJECT="iKeyMon.xcodeproj" PROJECT="iKeyMon.xcodeproj"
@@ -32,8 +33,13 @@ generate_appcast() {
local download_prefix="" local download_prefix=""
if [[ -n "${SPARKLE_DOWNLOAD_BASE_TEMPLATE:-}" ]]; then if [[ -n "${SPARKLE_DOWNLOAD_BASE_TEMPLATE:-}" ]]; then
download_prefix="${SPARKLE_DOWNLOAD_BASE_TEMPLATE//\{\{VERSION\}\}/$VERSION}" download_prefix="${SPARKLE_DOWNLOAD_BASE_TEMPLATE//\{\{VERSION\}\}/$VERSION}"
else elif [[ -n "${SPARKLE_DOWNLOAD_BASE_URL:-}" ]]; then
download_prefix="${SPARKLE_DOWNLOAD_BASE_URL:-}" download_prefix="${SPARKLE_DOWNLOAD_BASE_URL%/}/v${VERSION}"
fi
# Ensure the version segment is present to match Gitea's /download/vX.Y.Z/ layout.
if [[ -n "$download_prefix" ]] && [[ "$download_prefix" != *"/$VERSION"* ]]; then
download_prefix="${download_prefix%/}/v${VERSION}"
fi fi
if [[ -z "$generator" || -z "${SPARKLE_EDDSA_KEY_FILE:-}" || -z "$download_prefix" ]]; then if [[ -z "$generator" || -z "${SPARKLE_EDDSA_KEY_FILE:-}" || -z "$download_prefix" ]]; then
@@ -136,24 +142,32 @@ export SPARKLE_APPCAST_OUTPUT
"$ROOT_DIR/scripts/sync_version.sh" "$ROOT_DIR/scripts/sync_version.sh"
rm -rf "$BUILD_DIR" "$ARTIFACTS_DIR" rm -rf "$DERIVED_DATA_DIR" "$ARTIFACTS_DIR"
mkdir -p "$ARTIFACTS_DIR" mkdir -p "$BUILD_DIR" "$ARTIFACTS_DIR"
xcodebuild \ xcodebuild \
-project "$ROOT_DIR/$PROJECT" \ -project "$ROOT_DIR/$PROJECT" \
-scheme "$SCHEME" \ -scheme "$SCHEME" \
-configuration Release \ -configuration Release \
-derivedDataPath "$BUILD_DIR" \ -derivedDataPath "$DERIVED_DATA_DIR" \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
clean build clean build
APP_PATH="$BUILD_DIR/Build/Products/Release/iKeyMon.app" APP_PATH="$DERIVED_DATA_DIR/Build/Products/Release/iKeyMon.app"
if [[ ! -d "$APP_PATH" ]]; then if [[ ! -d "$APP_PATH" ]]; then
echo "❌ Failed to find built app at $APP_PATH" echo "❌ Failed to find built app at $APP_PATH"
exit 1 exit 1
fi fi
if [[ -n "${CODESIGN_IDENTITY:-}" ]]; then if [[ -n "${CODESIGN_IDENTITY:-}" ]]; then
echo "🔏 Codesigning Sparkle framework..."
codesign \
--force \
--options runtime \
--timestamp \
--sign "$CODESIGN_IDENTITY" \
"$APP_PATH/Contents/Frameworks/Sparkle.framework"
echo "🔏 Codesigning app with identity: $CODESIGN_IDENTITY" echo "🔏 Codesigning app with identity: $CODESIGN_IDENTITY"
codesign \ codesign \
--deep \ --deep \
@@ -184,9 +198,7 @@ print(data.get("marketing_version", "dev"))
PY PY
)" )"
ZIP_NAME="iKeyMon-${VERSION}.zip" ZIP_NAME="iKeyMon-${VERSION}.zip"
pushd "$(dirname "$APP_PATH")" >/dev/null ditto -c -k --keepParent "$APP_PATH" "$ARTIFACTS_DIR/$ZIP_NAME"
zip -r "$ARTIFACTS_DIR/$ZIP_NAME" "$(basename "$APP_PATH")"
popd >/dev/null
DMG_NAME="iKeyMon-${VERSION}.dmg" DMG_NAME="iKeyMon-${VERSION}.dmg"
hdiutil create -volname "iKeyMon" -srcfolder "$STAGING_DIR" -ov -format UDZO "$ARTIFACTS_DIR/$DMG_NAME" hdiutil create -volname "iKeyMon" -srcfolder "$STAGING_DIR" -ov -format UDZO "$ARTIFACTS_DIR/$DMG_NAME"
@@ -204,8 +216,10 @@ generate_appcast
if [[ -n "${GITEA_TOKEN:-}" && -n "${GITEA_OWNER:-}" && -n "${GITEA_REPO:-}" ]]; then if [[ -n "${GITEA_TOKEN:-}" && -n "${GITEA_OWNER:-}" && -n "${GITEA_REPO:-}" ]]; then
"$ROOT_DIR/scripts/publish_release.sh" "$VERSION" "$ARTIFACTS_DIR/$ZIP_NAME" "$ARTIFACTS_DIR/$DMG_NAME" "$ROOT_DIR/scripts/publish_release.sh" "$VERSION" "$ARTIFACTS_DIR/$ZIP_NAME" "$ARTIFACTS_DIR/$DMG_NAME"
"$ROOT_DIR/scripts/update_homebrew_tap.sh" "$VERSION" "$ARTIFACTS_DIR/$DMG_NAME"
else else
echo "️ Skipping Gitea release publishing (GITEA_* variables not fully set)." echo "️ Skipping Gitea release publishing (GITEA_* variables not fully set)."
echo "️ Skipping Homebrew tap update because release publishing was skipped."
fi fi
echo "✅ Build complete. Artifacts:" echo "✅ Build complete. Artifacts:"
+31 -2
View File
@@ -15,20 +15,49 @@ API_BASE="${GITEA_API_BASE:-https://git.24unix.net/api/v1}"
API_BASE="${API_BASE%/}" API_BASE="${API_BASE%/}"
RELEASE_TAG="v${VERSION}" RELEASE_TAG="v${VERSION}"
API_URL="${API_BASE}/repos/${GITEA_OWNER}/${GITEA_REPO}" API_URL="${API_BASE}/repos/${GITEA_OWNER}/${GITEA_REPO}"
CHANGELOG_FILE="$ROOT_DIR/CHANGELOG.md"
if ! command -v jq >/dev/null 2>&1; then if ! command -v jq >/dev/null 2>&1; then
echo "❌ jq is required to parse Gitea responses." >&2 echo "❌ jq is required to parse Gitea responses." >&2
exit 1 exit 1
fi fi
PRERELEASE_FLAG="${GITEA_PRERELEASE:-true}" # Extract changelog for this version
extract_changelog() {
local version="$1"
local changelog_file="$2"
if [[ ! -f "$changelog_file" ]]; then
echo ""
return
fi
awk -v ver="## $version" '
/^## / {
if (found) exit
if ($0 ~ ver) {
found=1
next
}
}
found { print }
' "$changelog_file"
}
CHANGELOG_BODY="$(extract_changelog "$VERSION" "$CHANGELOG_FILE")"
if [[ -z "$CHANGELOG_BODY" ]]; then
CHANGELOG_BODY="See commit history for details."
fi
PRERELEASE_FLAG="${GITEA_PRERELEASE:-false}"
create_payload="$(jq -n \ create_payload="$(jq -n \
--arg tag "$RELEASE_TAG" \ --arg tag "$RELEASE_TAG" \
--arg name "iKeyMon ${VERSION}" \ --arg name "iKeyMon ${VERSION}" \
--arg target "$TARGET_COMMIT" \ --arg target "$TARGET_COMMIT" \
--arg body "$CHANGELOG_BODY" \
--argjson prerelease "$PRERELEASE_FLAG" \ --argjson prerelease "$PRERELEASE_FLAG" \
'{ tag_name: $tag, name: $name, target_commitish: $target, draft: false, prerelease: $prerelease }')" '{ tag_name: $tag, name: $name, target_commitish: $target, body: $body, draft: false, prerelease: $prerelease }')"
response_file="$(mktemp)" response_file="$(mktemp)"
http_code=$(curl -sS -w "%{http_code}" -o "$response_file" \ http_code=$(curl -sS -w "%{http_code}" -o "$response_file" \
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="${1:?usage: update_homebrew_tap.sh <version> <dmg_path>}"
DMG_PATH="${2:?usage: update_homebrew_tap.sh <version> <dmg_path>}"
TAP_DIR="${HOMEBREW_TAP_DIR:-$ROOT_DIR/homebrew-tap}"
CASK_FILE="$TAP_DIR/Casks/ikeymon.rb"
if [[ ! -d "$TAP_DIR/.git" ]]; then
echo "❌ Homebrew tap repo not found at $TAP_DIR"
exit 1
fi
if [[ ! -f "$CASK_FILE" ]]; then
echo "❌ Homebrew cask file not found at $CASK_FILE"
exit 1
fi
if [[ ! -f "$DMG_PATH" ]]; then
echo "❌ DMG artifact not found at $DMG_PATH"
exit 1
fi
if [[ -n "$(git -C "$TAP_DIR" status --porcelain)" ]]; then
echo "❌ Homebrew tap repo has uncommitted changes: $TAP_DIR"
exit 1
fi
SHA256="$(shasum -a 256 "$DMG_PATH" | awk '{print $1}')"
python3 - <<'PY' "$CASK_FILE" "$VERSION" "$SHA256"
from pathlib import Path
import re
import sys
cask_path = Path(sys.argv[1])
version = sys.argv[2]
sha256 = sys.argv[3]
content = cask_path.read_text(encoding="utf-8")
content, version_count = re.subn(r'version "[^"]+"', f'version "{version}"', content, count=1)
content, sha_count = re.subn(r'sha256 "[^"]+"', f'sha256 "{sha256}"', content, count=1)
if version_count != 1 or sha_count != 1:
raise SystemExit("Failed to update version or sha256 in cask file")
cask_path.write_text(content, encoding="utf-8")
PY
ruby -c "$CASK_FILE" >/dev/null
if git -C "$TAP_DIR" diff --quiet -- "$CASK_FILE"; then
echo "️ Homebrew tap already points to iKeyMon ${VERSION}"
exit 0
fi
git -C "$TAP_DIR" add "$CASK_FILE"
git -C "$TAP_DIR" commit -m "cask: update ikeymon to ${VERSION}"
git -C "$TAP_DIR" push origin master
echo "✅ Updated Homebrew tap to iKeyMon ${VERSION}"
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"marketing_version": "26.0.28" "marketing_version": "26.1.11"
} }