148 Commits

Author SHA1 Message Date
11815fb807 chore: release 26.1.7 2026-04-19 16:54:45 +02:00
afbb425e3b feat: add remote reboot support 2026-04-19 16:53:17 +02:00
92040e5c5e chore: release 26.1.6 2026-04-19 15:26:22 +02:00
50410488a1 docs: update install docs and release automation 2026-04-19 15:24:58 +02:00
8d6334d3b4 chore: release 26.1.5 2026-04-19 12:09:34 +02:00
dc8de5a4ea fix: skip release when HEAD is already a release commit 2026-04-19 12:07:53 +02:00
3510474764 fix: stop nested push in release hook 2026-04-19 12:07:08 +02:00
9f42c68279 chore: release 26.1.4 2026-04-19 11:59:17 +02:00
031781761c fix: preserve release logs during pre-push release 2026-04-19 11:46:11 +02:00
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
Micha
656d6403fd chore: release 26.0.28 2025-12-07 17:02:20 +01:00
Micha
db4c2aa930 Sparkle fixes 2025-12-07 16:52:13 +01:00
Micha
8f72fd0fea chore: release 26.0.27 2025-12-07 16:47:34 +01:00
Micha
ace1a008ef chore: release 26.0.26 2025-11-26 20:18:06 +01:00
Micha
354488d623 more Sparkle tests 2025-11-26 20:16:34 +01:00
Micha
61392a16a2 more Sparkle tests 2025-11-26 20:14:59 +01:00
Micha
d46e0450bf more Sparkle tests 2025-11-26 20:11:32 +01:00
Micha
5905ae5aa7 more Sparkle tests 2025-11-26 20:09:06 +01:00
Micha
9a6609df7b chore: release 26.0.22 2025-11-26 18:54:55 +01:00
Micha
eadd8e9d28 more Sparkle tests 2025-11-26 18:53:14 +01:00
Micha
473354a50a chore: release 26.0.21 2025-11-26 18:44:42 +01:00
Micha
1945b1dedb more Sparkle tests 2025-11-26 18:43:01 +01:00
Micha
dc7a516b6d chore: release 26.0.20 2025-11-26 18:36:43 +01:00
Micha
0d013e64f7 more Sparkle tests 2025-11-26 18:35:05 +01:00
Micha
c2cd66d2a3 chore: release 26.0.19 2025-11-26 18:03:44 +01:00
Micha
ad50433406 more Sparkle tests 2025-11-26 18:02:13 +01:00
Micha
fd83208760 chore: release 26.0.18 2025-11-25 19:17:04 +01:00
Micha
05017ffd5e more Sparkle tests 2025-11-25 19:15:25 +01:00
Micha
24794a1d63 chore: release 26.0.17 2025-11-25 19:04:13 +01:00
Micha
d65ec99cfb more Sparkle tests 2025-11-25 19:03:05 +01:00
Micha
57dc68f434 chore: release 26.0.16 2025-11-25 18:34:20 +01:00
Micha
e3b14e87fe more Sparkle tests 2025-11-25 18:33:17 +01:00
Micha
1c7e14a9db more Sparkle tests 2025-11-25 18:28:55 +01:00
Micha
fc3e55294a more Sparkle tests 2025-11-25 18:26:04 +01:00
Micha
02828c8d5b more Sparkle tests 2025-11-25 18:11:47 +01:00
Micha
3f849d0db6 more Sparkle tests 2025-11-25 17:59:58 +01:00
Micha
69904c07ce removed .gitkeep 2025-11-25 17:22:00 +01:00
Micha
01c89de738 add Sparkle appcast 2025-11-25 16:21:07 +01:00
Micha
dc9560e31a test update 2025-11-22 19:01:36 +01:00
Micha
dc710d53aa included Sparkle 2025-11-22 18:56:55 +01:00
34 changed files with 1570 additions and 145 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ build/
Build/ Build/
dist/ dist/
.signing.env .signing.env
homebrew-tap/

View File

@@ -1,10 +1,54 @@
# Changelog # Changelog
## Unreleased ## 26.1.7
- 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
- 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
- Fixed version handling for changelogs.
## 26.1.2 (2025-01-03)
- Synced version.json to 26.1.2.
## 26.1.1 (2025-01-03)
- Fixed changelog extraction in publish script.
## 26.1.0 (2025-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.
- Added verbose logging in `MainView` to trace server loading, selection, and fetch/ping activity when the list appears empty. - Added verbose logging in `MainView` to trace server loading, selection, and fetch/ping activity when the list appears empty.
- Switched `MainView` and `ServerFormView` to the version-aware API client (`APIFactory`/`APIv2_12`) for server summaries and introduced a shared `PingService`. - Switched `MainView` and `ServerFormView` to the version-aware API client (`APIFactory`/`APIv2_12`) for server summaries and introduced a shared `PingService`.
- Detection now probes `meta.api_version` so future API versions are selected automatically, and the ping loop logs only failures to keep output quiet. - Detection now probes `meta.api_version` so future API versions are selected automatically, and the ping loop logs only failures to keep output quiet.
- 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.
- 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 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.

View File

@@ -3,6 +3,5 @@
dynamic Data dynamic Data
static Data static Data
add a merker for "reboot required" add a marker for "reboot required"
Add dmg download option for macOS
Add versioning

View File

@@ -8,9 +8,12 @@ iKeyMon is a native macOS app written in SwiftUI that provides live monitoring f
- Shows CPU load, memory usage, swap usage, and disk usage - Shows CPU load, memory usage, swap usage, and disk usage
- Periodic ping via `/api/ping` endpoint to check if a server is reachable - Periodic ping via `/api/ping` endpoint to check if a server is reachable
- Colored status indicator for each server in the list - Colored status indicator for each server in the list
- Automatic updates: - 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)
- 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
@@ -23,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+.
@@ -37,15 +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.
## 📦 License ## 📦 License
MIT — see [LICENSE](LICENSE) for details. MIT — see [LICENSE](LICENSE) for details.

View File

@@ -15,13 +15,12 @@ struct ShimmerModifier: ViewModifier {
guard active else { return } guard active else { return }
animate() animate()
} }
.onChange(of: active) { isActive in .onChange(of: active) {
if isActive { guard active else { return }
phase = -1 phase = -1
animate() animate()
} }
} }
}
private var shimmer: some View { private var shimmer: some View {
LinearGradient( LinearGradient(

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
} }
} }
@@ -36,6 +38,7 @@ protocol AnyServerAPI {
func fetchMemoryData() async throws -> Any func fetchMemoryData() async throws -> Any
func fetchUtilizationData() async throws -> Any func fetchUtilizationData() async throws -> Any
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 {
@@ -64,6 +67,10 @@ private struct AnyServerAPIWrapper<T: ServerAPIProtocol>: AnyServerAPI {
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 +80,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 +113,7 @@ class APIFactory {
} }
} }
return AnyServerAPIWrapper(APIv2_13(baseURL: baseURL)) return AnyServerAPIWrapper(APIv2_14(baseURL: baseURL))
} }
} }

View File

@@ -18,6 +18,7 @@ protocol ServerAPIProtocol {
func fetchMemory() async throws -> MemoryType func fetchMemory() async throws -> MemoryType
func fetchUtilization() async throws -> UtilizationType func fetchUtilization() async throws -> UtilizationType
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 +37,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 +53,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?
}

View File

@@ -1,7 +1,21 @@
import Foundation import Foundation
import UserNotifications
enum PingService { enum PingService {
static func ping(hostname: String, apiKey: String) async -> Bool { private static var previousPingStates: [String: Bool] = [:]
private static var suppressedUntil: [String: Date] = [:]
static func suppressChecks(for hostname: String, duration: TimeInterval) {
suppressedUntil[hostname] = Date().addingTimeInterval(duration)
}
static func ping(hostname: String, apiKey: String, notificationsEnabled: Bool = true) async -> Bool {
if let suppressedUntil = suppressedUntil[hostname], suppressedUntil > Date() {
return false
} else {
suppressedUntil.removeValue(forKey: hostname)
}
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 +29,48 @@ 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) { 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" {
handlePingSuccess(for: hostname, notificationsEnabled: notificationsEnabled)
return true return true
} else { } else {
handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled)
return false return false
} }
} catch { } catch {
print("❌ [PingService] Error pinging \(hostname): \(error)") handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled)
return false return false
} }
} }
private static func handlePingSuccess(for hostname: String, notificationsEnabled: Bool) {
let wasPreviouslyDown = previousPingStates[hostname] == false
previousPingStates[hostname] = true
if wasPreviouslyDown && notificationsEnabled {
sendNotification(title: "Server Online", body: "\(hostname) is now online")
}
}
private static func handlePingFailure(for hostname: String, notificationsEnabled: Bool) {
let wasPreviouslyUp = previousPingStates[hostname] != false
previousPingStates[hostname] = false
if wasPreviouslyUp && notificationsEnabled {
sendNotification(title: "Server Offline", body: "\(hostname) is offline")
}
}
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)
}
} }

View File

@@ -65,8 +65,9 @@ struct ServerInfo: Codable, Hashable, Equatable {
} }
struct PHPInterpreter: Codable, Hashable, Identifiable, Equatable { struct PHPInterpreter: Codable, Hashable, Identifiable, Equatable {
var id: String { versionFull } var id: String { [fullVersion, path ?? ""].joined(separator: "|") }
let version: String let version: String
let fullVersion: String
let path: String? let path: String?
let configFile: String? let configFile: String?
let extensions: [String] let extensions: [String]
@@ -75,6 +76,7 @@ struct ServerInfo: Codable, Hashable, Equatable {
init( init(
version: String, version: String,
fullVersion: String? = nil,
path: String? = nil, path: String? = nil,
configFile: String? = nil, configFile: String? = nil,
extensions: [String] = [], extensions: [String] = [],
@@ -82,6 +84,7 @@ struct ServerInfo: Codable, Hashable, Equatable {
maxExecutionTime: String? = nil maxExecutionTime: String? = nil
) { ) {
self.version = version self.version = version
self.fullVersion = fullVersion ?? version
self.path = path self.path = path
self.configFile = configFile self.configFile = configFile
self.extensions = extensions self.extensions = extensions
@@ -89,8 +92,8 @@ struct ServerInfo: Codable, Hashable, Equatable {
self.maxExecutionTime = maxExecutionTime self.maxExecutionTime = maxExecutionTime
} }
var versionFull: String { var versionWithPath: String {
var components = [version] var components = [fullVersion]
if let path, !path.isEmpty { if let path, !path.isEmpty {
components.append(path) components.append(path)
} }
@@ -162,12 +165,18 @@ struct ServerInfo: Codable, Hashable, Equatable {
} }
var formattedServerTime: String { var formattedServerTime: String {
guard let date = ServerInfo.isoFormatter.date(from: serverTime) else { let normalizedServerTime = ServerInfo.normalizedServerTime(serverTime)
return serverTime
} if let date = ServerInfo.serverTimeParsers
.lazy
.compactMap({ $0.date(from: normalizedServerTime) })
.first {
return ServerInfo.displayFormatter.string(from: date) return ServerInfo.displayFormatter.string(from: date)
} }
return serverTime
}
var operatingSystemSummary: String? { var operatingSystemSummary: String? {
guard let operatingSystem else { return nil } guard let operatingSystem else { return nil }
let components = [ let components = [
@@ -176,24 +185,72 @@ 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")
}
} }
// MARK: - Helpers & Sample Data // MARK: - Helpers & Sample Data
extension ServerInfo { extension ServerInfo {
private static let isoFormatter: ISO8601DateFormatter = { private static let serverTimeParsers: [ISO8601DateFormatter] = {
let formatter = ISO8601DateFormatter() let withFractional = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds, .withColonSeparatorInTimeZone] withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
let withoutFractional = ISO8601DateFormatter()
withoutFractional.formatOptions = [.withInternetDateTime]
let noColonTimeZone = ISO8601DateFormatter()
noColonTimeZone.formatOptions = [.withFullDate, .withTime, .withDashSeparatorInDate, .withColonSeparatorInTime, .withTimeZone]
return [withFractional, withoutFractional, noColonTimeZone]
}() }()
private static let displayFormatter: DateFormatter = { private static let displayFormatter: DateFormatter = {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.locale = .autoupdatingCurrent
formatter.dateStyle = .medium formatter.dateStyle = .medium
formatter.timeStyle = .medium formatter.timeStyle = .medium
return formatter return formatter
}() }()
private static func normalizedServerTime(_ value: String) -> String {
if value.range(of: #"[+-]\d{2}:\d{2}$"#, options: .regularExpression) != nil {
return value
}
guard value.range(of: #"[+-]\d{4}$"#, options: .regularExpression) != nil else {
return value
}
var normalized = value
let insertionIndex = normalized.index(normalized.endIndex, offsetBy: -2)
normalized.insert(":", at: insertionIndex)
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"],

View File

@@ -181,7 +181,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 +189,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
@@ -278,6 +282,7 @@ private extension APIv2_12 {
struct AdditionalInterpreter: Decodable { struct AdditionalInterpreter: Decodable {
let version: String let version: String
let versionFull: String?
let path: String? let path: String?
let configFile: String? let configFile: String?
} }
@@ -398,6 +403,7 @@ private extension APIv2_12 {
additionalPHPInterpreters: additionalPhpInterpreters?.map { additionalPHPInterpreters: additionalPhpInterpreters?.map {
ServerInfo.PHPInterpreter( ServerInfo.PHPInterpreter(
version: $0.version, version: $0.version,
fullVersion: $0.versionFull,
path: $0.path, path: $0.path,
configFile: $0.configFile, configFile: $0.configFile,
extensions: [], extensions: [],

View File

@@ -181,7 +181,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 +189,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
@@ -278,6 +282,7 @@ private extension APIv2_13 {
struct AdditionalInterpreter: Decodable { struct AdditionalInterpreter: Decodable {
let version: String let version: String
let versionFull: String?
let path: String? let path: String?
let configFile: String? let configFile: String?
} }
@@ -398,6 +403,7 @@ private extension APIv2_13 {
additionalPHPInterpreters: additionalPhpInterpreters?.map { additionalPHPInterpreters: additionalPhpInterpreters?.map {
ServerInfo.PHPInterpreter( ServerInfo.PHPInterpreter(
version: $0.version, version: $0.version,
fullVersion: $0.versionFull,
path: $0.path, path: $0.path,
configFile: $0.configFile, configFile: $0.configFile,
extensions: [], extensions: [],

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)
}
}

View File

@@ -0,0 +1,127 @@
import Sparkle
import Foundation
import OSLog
@MainActor
final class SparkleUpdater: NSObject, ObservableObject {
private lazy var controller: SPUStandardUpdaterController = {
SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil)
}()
private let logger = Logger(subsystem: "net.24unix.iKeyMon", category: "Sparkle")
private let verboseLogging: Bool
@Published var logMessages: [String] = []
override init() {
self.verboseLogging = ProcessInfo.processInfo.environment["SPARKLE_VERBOSE_LOGGING"] == "1"
super.init()
_ = controller
log("Sparkle updater initialized (verbose=\(verboseLogging)).")
}
var automaticallyChecksForUpdates: Bool {
get { controller.updater.automaticallyChecksForUpdates }
set { controller.updater.automaticallyChecksForUpdates = newValue }
}
var automaticallyDownloadsUpdates: Bool {
get { controller.updater.automaticallyDownloadsUpdates }
set { controller.updater.automaticallyDownloadsUpdates = newValue }
}
func checkForUpdates() {
log("Manual check for updates triggered.")
controller.checkForUpdates(nil)
}
private func log(_ message: String) {
logger.log("\(message, privacy: .public)")
addLogMessage("[INFO] \(message)")
if verboseLogging {
print("[Sparkle] \(message)")
}
}
private func logError(_ message: String) {
logger.error("\(message, privacy: .public)")
addLogMessage("[ERROR] \(message)")
if verboseLogging {
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 {
let short = item.displayVersionString
let build = item.versionString
return "\(short) (build \(build))"
}
}
extension SparkleUpdater: SPUUpdaterDelegate {
nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) {
Task { @MainActor in
log("Loaded Sparkle appcast containing \(appcast.items.count) item(s).")
}
}
nonisolated func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
Task { @MainActor in
log("Found valid update \(describe(update: item))")
}
}
nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
Task { @MainActor in
log("No updates available.")
}
}
nonisolated func updater(_ updater: SPUUpdater, willDownloadUpdate item: SUAppcastItem, with request: NSMutableURLRequest) {
Task { @MainActor in
log("Downloading \(describe(update: item)) from \(request.url?.absoluteString ?? "unknown URL")")
}
}
nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
Task { @MainActor in
log("Finished downloading \(describe(update: item))")
}
}
nonisolated func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) {
Task { @MainActor in
logError("Failed to download \(describe(update: item)): \(error.localizedDescription)")
}
}
nonisolated func userDidCancelDownload(_ updater: SPUUpdater) {
Task { @MainActor in
log("User cancelled Sparkle download.")
}
}
nonisolated func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) {
Task { @MainActor in
log("Will install update \(describe(update: item))")
}
}
nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
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)")
}
}
}
}

View File

@@ -5,12 +5,6 @@
// Created by tracer on 03.04.25. // Created by tracer on 03.04.25.
// //
//
// ResourcesBarRow.swift
// iKeyMon
//
// Created by tracer on 31.03.25.
//
import SwiftUI import SwiftUI
@@ -32,6 +26,8 @@ struct InfoCell: View {
.font(monospaced ? .system(.body, design: .monospaced) : .body) .font(monospaced ? .system(.body, design: .monospaced) : .body)
} }
} }
// if let subtext { // if let subtext {
// Text(subtext) // Text(subtext)
// .font(.caption) // .font(.caption)

View File

@@ -6,6 +6,8 @@
// //
import SwiftUI import SwiftUI
import Combine
import UserNotifications
struct MainView: View { struct MainView: View {
@@ -17,10 +19,16 @@ struct MainView: View {
@State private var serverToDelete: Server? @State private var serverToDelete: Server?
@State private var showDeleteConfirmation = false @State private var showDeleteConfirmation = 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
@State private var progress: Double = 0 @AppStorage("refreshInterval") private var refreshInterval: Int = 60
@State private var lastRefresh = Date() @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 lastRefreshInterval: Int?
@State private var previousServiceStates: [String: String] = [:]
private let serverOrderKey = MainView.serverOrderKeyStatic private let serverOrderKey = MainView.serverOrderKeyStatic
private let storedServersKey = MainView.storedServersKeyStatic private let storedServersKey = MainView.storedServersKeyStatic
@@ -30,7 +38,7 @@ struct MainView: View {
@State private var selectedServerID: UUID? @State private var selectedServerID: UUID?
var body: some View { var body: some View {
var mainContent: some View {
NavigationSplitView { NavigationSplitView {
List(selection: $selectedServerID) { List(selection: $selectedServerID) {
ForEach(servers) { server in ForEach(servers) { server in
@@ -72,11 +80,21 @@ struct MainView: View {
} detail: { } detail: {
if let selectedServerID, if let selectedServerID,
let index = servers.firstIndex(where: { selectedServerID == $0.id }) { let index = servers.firstIndex(where: { selectedServerID == $0.id }) {
ServerDetailView(server: $servers[index], isFetching: isFetchingInfo) let serverID = servers[index].id
ServerDetailView(
server: $servers[index],
isFetching: isFetchingInfo,
canRestart: servers[index].info?.supportsRestartCommand == true,
isRestarting: restartingServerID == serverID
) {
await restartServer(for: serverID)
}
} else { } else {
ContentUnavailableView("No Server Selected", systemImage: "server.rack") ContentUnavailableView("No Server Selected", systemImage: "server.rack")
} }
} }
}
return mainContent
.sheet(isPresented: $showAddServerSheet) { .sheet(isPresented: $showAddServerSheet) {
ServerFormView( ServerFormView(
mode: .add, mode: .add,
@@ -97,12 +115,9 @@ struct MainView: View {
} }
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
} }
.onReceive(refreshTimer) { _ in
for server in servers {
fetchServerInfo(for: server.id)
}
}
.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),
@@ -124,13 +139,17 @@ 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()
} }
} }
.frame(minWidth: 800, minHeight: 450) .frame(minWidth: 800, minHeight: 450)
} }
private func fetchServerInfo(for id: UUID) { private func fetchServerInfo(for id: UUID) {
@@ -160,6 +179,7 @@ struct MainView: View {
var updated = servers[index] var updated = servers[index]
updated.info = info updated.info = info
servers[index] = updated servers[index] = updated
checkServiceStatusChanges(for: server.hostname, newInfo: info)
} }
} }
} catch { } catch {
@@ -220,15 +240,36 @@ struct MainView: View {
for (index, server) in servers.enumerated() { for (index, server) in servers.enumerated() {
Task { Task {
let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let pingable = await PingService.ping(hostname: server.hostname, apiKey: apiKey) let pingable = await PingService.ping(hostname: server.hostname, apiKey: apiKey, notificationsEnabled: enableStatusNotifications)
await MainActor.run { await MainActor.run {
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)
}
}
} }
private static func loadStoredServers() -> [Server] { private static func loadStoredServers() -> [Server] {
@@ -257,8 +298,130 @@ struct MainView: View {
return [] 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)
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) {
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
}
}
} }
#Preview { #Preview {
MainView() MainView()
.environmentObject(SparkleUpdater())
} }

View File

@@ -2,13 +2,14 @@ import SwiftUI
struct PreferencesView: View { struct PreferencesView: View {
private enum Tab: CaseIterable { private enum Tab: CaseIterable {
case monitor, notifications, alerts case monitor, notifications, alerts, updates
var title: String { var title: String {
switch self { switch self {
case .monitor: return "Monitor" case .monitor: return "Monitor"
case .notifications: return "Notifications" case .notifications: return "Notifications"
case .alerts: return "Alerts" case .alerts: return "Alerts"
case .updates: return "Updates"
} }
} }
@@ -17,9 +18,12 @@ struct PreferencesView: View {
case .monitor: return "waveform.path.ecg" case .monitor: return "waveform.path.ecg"
case .notifications: return "bell.badge" case .notifications: return "bell.badge"
case .alerts: return "exclamationmark.triangle" case .alerts: return "exclamationmark.triangle"
case .updates: return "square.and.arrow.down"
} }
} }
} }
@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
@@ -77,14 +81,14 @@ struct PreferencesView: View {
.padding(.vertical, 8) .padding(.vertical, 8)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.focusable(false)
.contentShape(Capsule())
.background( .background(
Capsule(style: .continuous) Capsule(style: .continuous)
.fill(backgroundColor(for: tab)) .fill(backgroundColor(for: tab))
) )
}
.buttonStyle(.plain)
.focusable(false)
.contentShape(Capsule())
.foregroundColor(selection == tab ? .white : .primary) .foregroundColor(selection == tab ? .white : .primary)
.onHover { isHovering in .onHover { isHovering in
hoveredTab = isHovering ? tab : (hoveredTab == tab ? nil : hoveredTab) hoveredTab = isHovering ? tab : (hoveredTab == tab ? nil : hoveredTab)
@@ -120,6 +124,9 @@ struct PreferencesView: View {
NotificationsPreferencesView() NotificationsPreferencesView()
case .alerts: case .alerts:
AlertsPreferencesView() AlertsPreferencesView()
case .updates:
UpdatesPreferencesView()
.environmentObject(sparkleUpdater)
} }
} }
} }
@@ -137,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)
} }
} }
@@ -220,20 +254,52 @@ private struct MonitorPreferencesView: View {
} }
} }
private struct NotificationsPreferencesView: View { private struct UpdatesPreferencesView: View {
var body: some View { @EnvironmentObject var sparkleUpdater: SparkleUpdater
VStack(alignment: .leading, spacing: 12) {
Text("Notifications")
.font(.headline)
.padding(.bottom)
Text("Configure notification behavior here.") private var automaticallyChecksBinding: Binding<Bool> {
.foregroundColor(.secondary) Binding(
get: { sparkleUpdater.automaticallyChecksForUpdates },
set: { sparkleUpdater.automaticallyChecksForUpdates = $0 }
)
}
private var automaticallyDownloadsBinding: Binding<Bool> {
Binding(
get: { sparkleUpdater.automaticallyDownloadsUpdates },
set: { sparkleUpdater.automaticallyDownloadsUpdates = $0 }
)
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Toggle("Automatically check for updates", isOn: automaticallyChecksBinding)
Toggle("Automatically download updates", isOn: automaticallyDownloadsBinding)
Button(action: sparkleUpdater.checkForUpdates) {
Label("Check for Updates Now", systemImage: "sparkles")
}
Spacer() Spacer()
} }
.toggleStyle(.switch)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct NotificationsPreferencesView: View {
@AppStorage("enableStatusNotifications") private var enableStatusNotifications: Bool = true
@AppStorage("enableAlertNotifications") private var enableAlertNotifications: Bool = true
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Toggle("Status Notifications", isOn: $enableStatusNotifications)
Toggle("Alert Notifications", isOn: $enableAlertNotifications)
Spacer()
}
.toggleStyle(.switch)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding()
} }
} }

View File

@@ -7,16 +7,28 @@
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
@AppStorage("refreshInterval") private var refreshInterval: Int = 60
private var showPlaceholder: Bool { private var showPlaceholder: Bool {
server.info == nil server.info == nil
} }
@State private var progress: Double = 0 @State private var progress: Double = 0
@State private var showRestartSheet = false
@State private var restartFeedback: ServerActionFeedback?
let timer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect() let timer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
@@ -32,7 +44,13 @@ struct ServerDetailView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer().frame(height: 6) Spacer().frame(height: 6)
TabView { TabView {
GeneralView(server: resolvedBinding) GeneralView(
server: resolvedBinding,
canRestart: canRestart,
isRestarting: isRestarting
) {
showRestartSheet = true
}
.tabItem { .tabItem {
Text("General").unredacted() Text("General").unredacted()
} }
@@ -56,13 +74,43 @@ struct ServerDetailView: View {
} }
.padding(0) .padding(0)
} }
.overlay(alignment: .bottomTrailing) {
if let feedback = restartFeedback {
RestartFeedbackBanner(
feedback: feedback,
onDismiss: {
restartFeedback = nil
}
)
.padding()
}
}
.onReceive(timer) { _ in .onReceive(timer) { _ in
guard showIntervalIndicator else { return } guard showIntervalIndicator else { return }
withAnimation(.linear(duration: 1.0 / 60.0)) { withAnimation(.linear(duration: 1.0 / 60.0)) {
progress += 1.0 / (60.0 * 60.0) progress += 1.0 / (Double(refreshInterval) * 60.0)
if progress >= 1 { progress = 0 } if progress >= 1 { progress = 0 }
} }
} }
.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> {
@@ -80,7 +128,8 @@ struct ServerDetailView: View {
#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 +145,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)
}
}

View File

@@ -20,6 +20,8 @@ struct ServerFormView: View {
@State private var hostname: String @State private var hostname: String
@State private var apiKey: String @State private var apiKey: String
@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
@@ -55,6 +57,12 @@ struct ServerFormView: View {
SecureField("API Key", text: $apiKey) SecureField("API Key", text: $apiKey)
.textFieldStyle(RoundedBorderTextFieldStyle()) .textFieldStyle(RoundedBorderTextFieldStyle())
if !connectionError.isEmpty {
Text(connectionError)
.foregroundColor(.red)
.font(.caption)
}
HStack { HStack {
Button("Cancel") { Button("Cancel") {
dismiss() dismiss()
@@ -65,6 +73,8 @@ struct ServerFormView: View {
await testConnection() await testConnection()
} }
} }
.disabled(hostname.isEmpty || apiKey.isEmpty || testingConnection)
Button("Save") { Button("Save") {
saveServer() saveServer()
updateServer() updateServer()
@@ -102,9 +112,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 {

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
@@ -59,10 +62,11 @@ struct GeneralView: View {
var description = os.label.trimmingCharacters(in: .whitespacesAndNewlines) var description = os.label.trimmingCharacters(in: .whitespacesAndNewlines)
if description.isEmpty { if description.isEmpty {
description = distro description = distro
} else if !distro.isEmpty && distro.caseInsensitiveCompare(description) != .orderedSame { } else if !distro.isEmpty && description.range(of: distro, options: [.caseInsensitive]) == nil {
description += "\(distro)" description += "\(distro)"
} }
if !os.architecture.isEmpty { if !os.architecture.isEmpty &&
description.range(of: os.architecture, options: [.caseInsensitive]) == nil {
description += " (\(os.architecture))" description += " (\(os.architecture))"
} }
if !description.isEmpty { if !description.isEmpty {
@@ -105,11 +109,44 @@ struct GeneralView: View {
if interpreters.isEmpty { if interpreters.isEmpty {
return ["None"] return ["None"]
} }
return interpreters.map { $0.versionFull } let versions = interpreters
.map { $0.fullVersion }
.filter { !$0.isEmpty }
if versions.isEmpty {
return ["None"]
}
return [versions.joined(separator: "")]
}(), }(),
monospaced: true monospaced: true
) )
} }
if canRestart, let onRestart {
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")
}
}
.disabled(isRestarting)
Text("Sends a reboot command to the selected host.")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
} }
.padding() .padding()
.frame(minHeight: geometry.size.height, alignment: .top) .frame(minHeight: geometry.size.height, alignment: .top)
@@ -125,7 +162,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)
} }
} }

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
) )
} }
} }

View File

@@ -12,6 +12,8 @@ import AppKit
@main @main
struct iKeyMonApp: App { struct iKeyMonApp: App {
@StateObject private var sparkleUpdater = SparkleUpdater()
init() { init() {
#if os(macOS) #if os(macOS)
if let customIcon = NSImage(named: "AppIcon") { if let customIcon = NSImage(named: "AppIcon") {
@@ -23,6 +25,7 @@ struct iKeyMonApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
MainView() MainView()
.environmentObject(sparkleUpdater)
.onDisappear { .onDisappear {
NSApp.terminate(nil) NSApp.terminate(nil)
} }
@@ -32,6 +35,7 @@ struct iKeyMonApp: App {
Settings { Settings {
PreferencesView() PreferencesView()
.padding() .padding()
.environmentObject(sparkleUpdater)
} }
} }
} }

30
Sparkle/appcast.xml vendored Normal file
View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>iKeyMon</title>
<item>
<title>26.1.7</title>
<pubDate>Sun, 19 Apr 2026 16:54:42 +0200</pubDate>
<sparkle:version>177</sparkle:version>
<sparkle:shortVersionString>26.1.7</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.1.7/iKeyMon-26.1.7.zip" length="3106520" type="application/octet-stream" sparkle:edSignature="xuNlxCsTtVgroriFU7fphcfHxAEC8cpd6tHnHMXknJ2jvKm27ShQMqjSW2jdqNAz0a0kNtPM8HwTL+e6nvUyCQ=="/>
</item>
<item>
<title>26.1.6</title>
<pubDate>Sun, 19 Apr 2026 15:26:19 +0200</pubDate>
<sparkle:version>175</sparkle:version>
<sparkle:shortVersionString>26.1.6</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.1.6/iKeyMon-26.1.6.zip" length="3063130" type="application/octet-stream" sparkle:edSignature="QPy3zm31ZTXE9grlj7Ul6kEG2t0veODEBjJ/qADM8A88lLJ8V9L4WhNnD8wmM7Urh1O6eZKl1qrCLTk0oo3WBA=="/>
</item>
<item>
<title>26.1.5</title>
<pubDate>Sun, 19 Apr 2026 12:09:33 +0200</pubDate>
<sparkle:version>173</sparkle:version>
<sparkle:shortVersionString>26.1.5</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.1.5/iKeyMon-26.1.5.zip" length="3065231" type="application/octet-stream" sparkle:edSignature="HVV7iZ4eyJC1VMh2q4GUoAESZnk4HoFU00QlA9qM4X4dJAT5oBEVB55m4wuF4u9iVFAeohkB0vleLlV39mxrBA=="/>
</item>
</channel>
</rss>

86
hooks/pre-push Executable file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REMOTE_NAME="${1:-origin}"
QUIET_RELEASE="${QUIET_RELEASE:-1}"
RELEASE_LOG="${RELEASE_LOG:-$ROOT_DIR/.git/release.log}"
if [[ -n "${SKIP_RELEASE:-}" ]]; then
echo "release: skipped (SKIP_RELEASE=1)"
exit 0
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
release_local_ref=""
release_remote_ref=""
while read -r local_ref local_sha remote_ref remote_sha; do
[[ -z "${local_ref:-}" ]] && continue
if [[ "$local_ref" == "refs/heads/master" || "$remote_ref" == "refs/heads/master" ]]; then
should_release=true
release_local_ref="$local_ref"
release_remote_ref="${remote_ref:-refs/heads/master}"
fi
done
if [[ "$should_release" != true ]]; then
current_branch="$(git -C "$ROOT_DIR" symbolic-ref --short -q HEAD || true)"
if [[ "$current_branch" == "master" ]]; then
should_release=true
release_local_ref="refs/heads/master"
release_remote_ref="refs/heads/master"
fi
fi
if [[ "$should_release" != true ]]; then
exit 0
fi
if [[ "$QUIET_RELEASE" == "1" ]]; then
mkdir -p "$(dirname "$RELEASE_LOG")"
: >"$RELEASE_LOG"
fi
run_logged() {
if [[ "$QUIET_RELEASE" == "1" ]]; then
"$@" >>"$RELEASE_LOG" 2>&1
else
"$@"
fi
}
if [[ "$QUIET_RELEASE" == "1" ]]; then
NEW_VERSION="$("$ROOT_DIR/scripts/bump_version.sh" 2>>"$RELEASE_LOG" | tee -a "$RELEASE_LOG")"
else
NEW_VERSION="$("$ROOT_DIR/scripts/bump_version.sh")"
fi
run_logged "$ROOT_DIR/scripts/sync_version.sh"
git -C "$ROOT_DIR" add "$ROOT_DIR/version.json" "$ROOT_DIR/iKeyMon.xcodeproj/project.pbxproj"
echo "release: building v${NEW_VERSION}..."
if ! run_logged "$ROOT_DIR/scripts/build_release.sh"; then
echo "release: failed (log: $RELEASE_LOG)"
exit 1
fi
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
echo "release: no changes detected; continuing push"
exit 0
else
run_logged git -C "$ROOT_DIR" commit -m "chore: release ${NEW_VERSION}" || {
echo "release: commit failed (log: $RELEASE_LOG)"
exit 1
}
fi
echo "release: created commit for v${NEW_VERSION}; run 'git push' again to push the new release commit"
exit 1

12
iKeyMon-Info.plist Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SUFeedURL</key>
<string>https://git.24unix.net/tracer/iKeyMon/raw/branch/master/Sparkle/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>EgJgrOGQ79L5me616jA7kDCEOgx+Rg11uYLYLLIyzTI=</string>
<key>SUEnableInstallerLauncherService</key>
<false/>
</dict>
</plist>

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>

View File

@@ -7,18 +7,36 @@
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 */; };
52A9BD112ED377F7004DD4A2 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 52A9BD102ED377F7004DD4A2 /* Sparkle */; };
52A9BECA2ED3874F004DD4A2 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 52A9BEC92ED3874F004DD4A2 /* README.md */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
52A9BD152ED37BD8004DD4A2 /* CopyFiles */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 6;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* 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>"; };
52A9B8BA2ECA35FB004DD4A2 /* NOTES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = NOTES.md; sourceTree = "<group>"; }; 52A9B8BA2ECA35FB004DD4A2 /* NOTES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = NOTES.md; sourceTree = "<group>"; };
52A9B9712ECF751C004DD4A2 /* signing.env.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = signing.env.example; sourceTree = "<group>"; }; 52A9B9712ECF751C004DD4A2 /* signing.env.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = signing.env.example; sourceTree = "<group>"; };
52A9BEC92ED3874F004DD4A2 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
52A9C38F2ED4D753004DD4A2 /* iKeyMon-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "iKeyMon-Info.plist"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -39,6 +57,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
52A9BD112ED377F7004DD4A2 /* Sparkle in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -48,6 +67,7 @@
5203C2442D997D2800576D4A = { 5203C2442D997D2800576D4A = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
52A9C38F2ED4D753004DD4A2 /* iKeyMon-Info.plist */,
52A9B8BE2ECB68B5004DD4A2 /* Sources */, 52A9B8BE2ECB68B5004DD4A2 /* Sources */,
52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */, 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */,
52A9B7882EC8E7EE004DD4A2 /* iKeyMon.entitlements */, 52A9B7882EC8E7EE004DD4A2 /* iKeyMon.entitlements */,
@@ -56,6 +76,9 @@
52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */, 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */,
52A9B8BA2ECA35FB004DD4A2 /* NOTES.md */, 52A9B8BA2ECA35FB004DD4A2 /* NOTES.md */,
52A9B9712ECF751C004DD4A2 /* signing.env.example */, 52A9B9712ECF751C004DD4A2 /* signing.env.example */,
52A9BEC92ED3874F004DD4A2 /* README.md */,
52A9BD122ED37E08004DD4A2 /* Frameworks */,
5221016C2EE5E82700D04952 /* Sparkle */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -67,6 +90,21 @@
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
5221016C2EE5E82700D04952 /* Sparkle */ = {
isa = PBXGroup;
children = (
5221016B2EE5E82700D04952 /* appcast.xml */,
);
path = Sparkle;
sourceTree = "<group>";
};
52A9BD122ED37E08004DD4A2 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -77,6 +115,7 @@
5203C2492D997D2800576D4A /* Sources */, 5203C2492D997D2800576D4A /* Sources */,
5203C24A2D997D2800576D4A /* Frameworks */, 5203C24A2D997D2800576D4A /* Frameworks */,
5203C24B2D997D2800576D4A /* Resources */, 5203C24B2D997D2800576D4A /* Resources */,
52A9BD152ED37BD8004DD4A2 /* CopyFiles */,
); );
buildRules = ( buildRules = (
); );
@@ -88,6 +127,7 @@
); );
name = iKeyMon; name = iKeyMon;
packageProductDependencies = ( packageProductDependencies = (
52A9BD102ED377F7004DD4A2 /* Sparkle */,
); );
productName = iKeyMon; productName = iKeyMon;
productReference = 5203C24D2D997D2800576D4A /* iKeyMon.app */; productReference = 5203C24D2D997D2800576D4A /* iKeyMon.app */;
@@ -117,6 +157,9 @@
); );
mainGroup = 5203C2442D997D2800576D4A; mainGroup = 5203C2442D997D2800576D4A;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
52A9BD0F2ED377F7004DD4A2 /* XCRemoteSwiftPackageReference "Sparkle" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 5203C24E2D997D2800576D4A /* Products */; productRefGroup = 5203C24E2D997D2800576D4A /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -133,6 +176,8 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.md in Resources */, 52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.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 */,
); );
@@ -277,19 +322,22 @@
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 = 1; CURRENT_PROJECT_VERSION = 177;
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
DEVELOPMENT_TEAM = Q5486ZVAFT; DEVELOPMENT_TEAM = Q5486ZVAFT;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iKeyMon-Info.plist";
INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_CFBundleIconName = AppIcon;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_SUFeedURL = "https://git.24unix.net/tracer/iKeyMon/releases/appcast.xml";
INFOPLIST_KEY_SUPublicEDKey = "EgJgrOGQ79L5me616jA7kDCEOgx+Rg11uYLYLLIyzTI=";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 26.1.7;
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;
@@ -305,19 +353,22 @@
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 = 1; CURRENT_PROJECT_VERSION = 177;
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
DEVELOPMENT_TEAM = Q5486ZVAFT; DEVELOPMENT_TEAM = Q5486ZVAFT;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iKeyMon-Info.plist";
INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_CFBundleIconName = AppIcon;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_SUFeedURL = "https://git.24unix.net/tracer/iKeyMon/releases/appcast.xml";
INFOPLIST_KEY_SUPublicEDKey = "EgJgrOGQ79L5me616jA7kDCEOgx+Rg11uYLYLLIyzTI=";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 26.1.7;
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;
@@ -347,6 +398,25 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
52A9BD0F2ED377F7004DD4A2 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.8.1;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
52A9BD102ED377F7004DD4A2 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 52A9BD0F2ED377F7004DD4A2 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
/* End XCSwiftPackageProductDependency section */
}; };
rootObject = 5203C2452D997D2800576D4A /* Project object */; rootObject = 5203C2452D997D2800576D4A /* Project object */;
} }

View File

@@ -0,0 +1,15 @@
{
"originHash" : "e721da7f9826abdffcb6185e886155efa2514bd6234475f1afa893e29eb258d6",
"pins" : [
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "5581748cef2bae787496fe6d61139aebe0a451f6",
"version" : "2.8.1"
}
}
],
"version" : 3
}

View File

@@ -3,34 +3,171 @@ 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"
CREDENTIALS_FILE="$ROOT_DIR/.signing.env" CREDENTIALS_FILE="$ROOT_DIR/.signing.env"
VERSION_FILE="$ROOT_DIR/version.json"
DERIVED_DATA_ROOT="${DERIVED_DATA_ROOT:-$HOME/Library/Developer/Xcode/DerivedData}"
if [[ -f "$CREDENTIALS_FILE" ]]; then find_generate_appcast() {
# shellcheck disable=SC1090 if [[ -n "${SPARKLE_GENERATE_APPCAST:-}" && -x "${SPARKLE_GENERATE_APPCAST}" ]]; then
source "$CREDENTIALS_FILE" echo "$SPARKLE_GENERATE_APPCAST"
return
fi fi
rm -rf "$BUILD_DIR" "$ARTIFACTS_DIR" if [[ -d "$DERIVED_DATA_ROOT" ]]; then
mkdir -p "$ARTIFACTS_DIR" local candidate
candidate="$(find "$DERIVED_DATA_ROOT" -path "*/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_appcast" -type f 2>/dev/null | head -n 1 || true)"
if [[ -n "$candidate" ]]; then
echo "$candidate"
return
fi
fi
}
generate_appcast() {
local generator
generator="$(find_generate_appcast)"
local download_prefix=""
if [[ -n "${SPARKLE_DOWNLOAD_BASE_TEMPLATE:-}" ]]; then
download_prefix="${SPARKLE_DOWNLOAD_BASE_TEMPLATE//\{\{VERSION\}\}/$VERSION}"
elif [[ -n "${SPARKLE_DOWNLOAD_BASE_URL:-}" ]]; then
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
if [[ -z "$generator" || -z "${SPARKLE_EDDSA_KEY_FILE:-}" || -z "$download_prefix" ]]; then
echo " Skipping Sparkle appcast generation (generator/key/download prefix not configured)."
return
fi
local sparkle_cache="$HOME/Library/Caches/Sparkle_generate_appcast"
rm -rf "$sparkle_cache"
local output="$SPARKLE_APPCAST_OUTPUT"
mkdir -p "$(dirname "$output")"
local staging_dir
staging_dir="$(mktemp -d)"
cp "$ARTIFACTS_DIR"/*.zip "$staging_dir"/ 2>/dev/null || true
if ! ls "$staging_dir"/*.zip >/dev/null 2>&1; then
echo " Skipping Sparkle appcast generation (no ZIP archives found)."
rm -rf "$staging_dir"
return
fi
echo "🧾 Generating Sparkle appcast at $output"
if ! "$generator" \
--download-url-prefix "$download_prefix" \
--ed-key-file "$SPARKLE_EDDSA_KEY_FILE" \
-o "$output" \
"$staging_dir"; then
echo "⚠️ Sparkle appcast generation failed."
fi
rm -rf "$staging_dir"
}
sign_update_artifacts() {
local signer
signer="$(find "$DERIVED_DATA_ROOT" -path "*/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update" -type f 2>/dev/null | head -n 1 || true)"
if [[ -z "$signer" || -z "${SPARKLE_EDDSA_KEY_FILE:-}" ]]; then
echo " Skipping Sparkle signing (sign_update or SPARKLE_EDDSA_KEY_FILE missing)."
return
fi
echo "🔑 Signing ${ZIP_NAME} for Sparkle feed"
if ! "$signer" "${ARTIFACTS_DIR}/${ZIP_NAME}" --ed-key-file "${SPARKLE_EDDSA_KEY_FILE}"; then
echo "⚠️ sign_update failed (continuing without signature)"
fi
}
rewrite_appcast_urls() {
: # no-op (old helper removed)
}
submit_for_notarization() {
local target="$1"
local label="$2"
echo "📝 Submitting ${label} for notarization..."
xcrun notarytool submit "$target" \
--apple-id "$NOTARY_APPLE_ID" \
--team-id "$NOTARY_TEAM_ID" \
--password "$NOTARY_PASSWORD" \
--wait
}
notarize_app_bundle() {
local bundle="$1"
local label="$2"
if [[ -z "${NOTARY_APPLE_ID:-}" || -z "${NOTARY_TEAM_ID:-}" || -z "${NOTARY_PASSWORD:-}" ]]; then
echo " Skipping notarization for ${label} (NOTARY_* variables not set)."
return 1
fi
local tmp_dir
tmp_dir="$(mktemp -d)"
local archive="$tmp_dir/$(basename "$bundle").zip"
ditto -c -k --keepParent "$bundle" "$archive"
submit_for_notarization "$archive" "$label"
xcrun stapler staple "$bundle"
rm -rf "$tmp_dir"
}
notarize_artifact() {
local artifact="$1"
local label="$2"
if [[ -z "${NOTARY_APPLE_ID:-}" || -z "${NOTARY_TEAM_ID:-}" || -z "${NOTARY_PASSWORD:-}" ]]; then
echo " Skipping notarization for ${label} (NOTARY_* variables not set)."
return 1
fi
submit_for_notarization "$artifact" "$label"
xcrun stapler staple "$artifact"
}
if [[ -f "$CREDENTIALS_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$CREDENTIALS_FILE"
set +a
fi
: "${SPARKLE_APPCAST_OUTPUT:=$ROOT_DIR/Sparkle/appcast.xml}"
export SPARKLE_APPCAST_OUTPUT
"$ROOT_DIR/scripts/sync_version.sh"
rm -rf "$DERIVED_DATA_DIR" "$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 \
@@ -44,6 +181,8 @@ else
echo "⚠️ Skipping codesign (CODESIGN_IDENTITY not set)." echo "⚠️ Skipping codesign (CODESIGN_IDENTITY not set)."
fi fi
notarize_app_bundle "$APP_PATH" "iKeyMon.app"
STAGING_DIR=$(mktemp -d) STAGING_DIR=$(mktemp -d)
mkdir -p "$STAGING_DIR" mkdir -p "$STAGING_DIR"
cp -R "$APP_PATH" "$STAGING_DIR/" cp -R "$APP_PATH" "$STAGING_DIR/"
@@ -51,35 +190,38 @@ ln -s /Applications "$STAGING_DIR/Applications"
mkdir -p "$STAGING_DIR/.background" mkdir -p "$STAGING_DIR/.background"
cp "$ROOT_DIR/Assets/dmg_background.png" "$STAGING_DIR/.background/background.png" cp "$ROOT_DIR/Assets/dmg_background.png" "$STAGING_DIR/.background/background.png"
VERSION=$(xcodebuild \ VERSION="$(python3 - <<'PY' "$VERSION_FILE"
-project "$ROOT_DIR/$PROJECT" \ import json, sys
-scheme "$SCHEME" \ with open(sys.argv[1], "r", encoding="utf-8") as handle:
-configuration Release \ data = json.load(handle)
-showBuildSettings | awk '/MARKETING_VERSION/ {print $3; exit}') print(data.get("marketing_version", "dev"))
if [[ -z "$VERSION" ]]; then PY
VERSION="dev" )"
fi
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"
sign_update_artifacts
if [[ -n "${NOTARY_APPLE_ID:-}" && -n "${NOTARY_TEAM_ID:-}" && -n "${NOTARY_PASSWORD:-}" ]]; then if [[ -n "${NOTARY_APPLE_ID:-}" && -n "${NOTARY_TEAM_ID:-}" && -n "${NOTARY_PASSWORD:-}" ]]; then
echo "📝 Submitting DMG for notarization..." notarize_artifact "$ARTIFACTS_DIR/$DMG_NAME" "$DMG_NAME"
xcrun notarytool submit "$ARTIFACTS_DIR/$DMG_NAME" \
--apple-id "$NOTARY_APPLE_ID" \
--team-id "$NOTARY_TEAM_ID" \
--password "$NOTARY_PASSWORD" \
--wait
xcrun stapler staple "$ARTIFACTS_DIR/$DMG_NAME"
else else
echo "⚠️ Skipping notarization (NOTARY_* variables not set)." echo "⚠️ Skipping DMG notarization (NOTARY_* variables not set)."
fi fi
rm -rf "$STAGING_DIR" rm -rf "$STAGING_DIR"
generate_appcast
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/update_homebrew_tap.sh" "$VERSION" "$ARTIFACTS_DIR/$DMG_NAME"
else
echo " Skipping Gitea release publishing (GITEA_* variables not fully set)."
echo " Skipping Homebrew tap update because release publishing was skipped."
fi
echo "✅ Build complete. Artifacts:" echo "✅ Build complete. Artifacts:"
echo " - $ARTIFACTS_DIR/$ZIP_NAME" echo " - $ARTIFACTS_DIR/$ZIP_NAME"
echo " - $ARTIFACTS_DIR/$DMG_NAME" echo " - $ARTIFACTS_DIR/$DMG_NAME"

24
scripts/bump_version.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION_FILE="$ROOT_DIR/version.json"
new_version="$(python3 - "$VERSION_FILE" <<'PY'
import json, sys, pathlib
path = pathlib.Path(sys.argv[1])
data = json.loads(path.read_text())
current = data.get("marketing_version")
if not current:
raise SystemExit("marketing_version missing in version.json")
parts = current.split(".")
if len(parts) != 3 or not all(part.isdigit() for part in parts):
raise SystemExit(f"Invalid marketing_version format: {current}")
parts[-1] = str(int(parts[-1]) + 1)
data["marketing_version"] = ".".join(parts)
path.write_text(json.dumps(data, indent=2) + "\n")
print(data["marketing_version"])
PY
)"
echo "$new_version"

127
scripts/publish_release.sh Executable file
View File

@@ -0,0 +1,127 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="$1"
ZIP_PATH="$2"
DMG_PATH="$3"
: "${GITEA_TOKEN:?Set GITEA_TOKEN in .signing.env}"
: "${GITEA_OWNER:?Set GITEA_OWNER in .signing.env}"
: "${GITEA_REPO:?Set GITEA_REPO in .signing.env}"
TARGET_COMMIT="${GITEA_TARGET_COMMIT:-$(git -C "$ROOT_DIR" rev-parse HEAD)}"
API_BASE="${GITEA_API_BASE:-https://git.24unix.net/api/v1}"
API_BASE="${API_BASE%/}"
RELEASE_TAG="v${VERSION}"
API_URL="${API_BASE}/repos/${GITEA_OWNER}/${GITEA_REPO}"
CHANGELOG_FILE="$ROOT_DIR/CHANGELOG.md"
if ! command -v jq >/dev/null 2>&1; then
echo "❌ jq is required to parse Gitea responses." >&2
exit 1
fi
# 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 \
--arg tag "$RELEASE_TAG" \
--arg name "iKeyMon ${VERSION}" \
--arg target "$TARGET_COMMIT" \
--arg body "$CHANGELOG_BODY" \
--argjson prerelease "$PRERELEASE_FLAG" \
'{ tag_name: $tag, name: $name, target_commitish: $target, body: $body, draft: false, prerelease: $prerelease }')"
response_file="$(mktemp)"
http_code=$(curl -sS -w "%{http_code}" -o "$response_file" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-X POST \
-d "$create_payload" \
"${API_URL}/releases")
if [[ "$http_code" == "201" ]]; then
echo "✅ Created release ${RELEASE_TAG}"
elif [[ "$http_code" == "409" ]]; then
echo " Release ${RELEASE_TAG} already exists, fetching existing ID."
else
echo "❌ Failed to create release (HTTP ${http_code}):"
cat "$response_file"
rm -f "$response_file"
exit 1
fi
if [[ "$http_code" == "409" ]]; then
curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API_URL}/releases/tags/${RELEASE_TAG}" >"$response_file"
fi
release_id=$(jq -r '.id' "$response_file")
rm -f "$response_file"
if [[ -z "$release_id" || "$release_id" == "null" ]]; then
echo "❌ Could not determine release ID for ${RELEASE_TAG}"
exit 1
fi
delete_existing_asset() {
local filename="$1"
local asset_id
asset_id="$(curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API_URL}/releases/${release_id}/assets" | jq -r --arg name "$filename" '.[] | select(.name == $name) | .id' | head -n 1)"
if [[ -n "$asset_id" && "$asset_id" != "null" ]]; then
echo "🗑️ Removing existing asset ${filename}"
curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
-X DELETE \
"${API_URL}/releases/${release_id}/assets/${asset_id}" >/dev/null
fi
}
upload_asset() {
local file="$1"
local filename
filename="$(basename "$file")"
delete_existing_asset "$filename"
echo "⬆️ Uploading ${filename}"
curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${file}" \
"${API_URL}/releases/${release_id}/assets" >/dev/null
}
upload_asset "$ZIP_PATH"
upload_asset "$DMG_PATH"
echo "🎉 Release ${RELEASE_TAG} assets uploaded."

56
scripts/sync_version.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION_FILE="$ROOT_DIR/version.json"
PROJECT_FILE="$ROOT_DIR/iKeyMon.xcodeproj/project.pbxproj"
if [[ ! -f "$VERSION_FILE" ]]; then
echo "❌ version.json not found at $VERSION_FILE" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "❌ jq is required but not found in PATH" >&2
exit 1
fi
MARKETING_VERSION="$(jq -r '.marketing_version // empty' "$VERSION_FILE")"
if [[ -z "$MARKETING_VERSION" ]]; then
echo "❌ marketing_version missing in $VERSION_FILE" >&2
exit 1
fi
if [[ ! "$MARKETING_VERSION" =~ ^[0-9]{2}\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ marketing_version '$MARKETING_VERSION' must follow YY.major.minor (e.g. 26.1.2)" >&2
exit 1
fi
BUILD_NUMBER="${BUILD_NUMBER:-$(git -C "$ROOT_DIR" rev-list --count HEAD)}"
if [[ -z "$BUILD_NUMBER" ]]; then
echo "❌ Unable to derive BUILD_NUMBER" >&2
exit 1
fi
update_setting() {
local key="$1"
local value="$2"
local tmp
tmp="$(mktemp)"
LC_ALL=C sed -E "s/(${key}[[:space:]]*=[[:space:]]*)[^;]+;/\\1${value};/g" "$PROJECT_FILE" >"$tmp"
if cmp -s "$tmp" "$PROJECT_FILE"; then
if ! grep -q "${key} = ${value};" "$PROJECT_FILE"; then
rm -f "$tmp"
echo "❌ Failed to update ${key} in $PROJECT_FILE" >&2
exit 1
fi
rm -f "$tmp"
return
fi
mv "$tmp" "$PROJECT_FILE"
}
update_setting "MARKETING_VERSION" "$MARKETING_VERSION"
update_setting "CURRENT_PROJECT_VERSION" "$BUILD_NUMBER"
echo "✅ Synced marketing version $MARKETING_VERSION and build $BUILD_NUMBER into Xcode project."

62
scripts/update_homebrew_tap.sh Executable file
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}"

View File

@@ -2,3 +2,15 @@ CODESIGN_IDENTITY="Developer ID Application: Your Name (TEAMID1234)"
NOTARY_APPLE_ID="appleid@example.com" NOTARY_APPLE_ID="appleid@example.com"
NOTARY_TEAM_ID="TEAMID1234" NOTARY_TEAM_ID="TEAMID1234"
NOTARY_PASSWORD="app-specific-password" NOTARY_PASSWORD="app-specific-password"
GITEA_TOKEN="personal-access-token"
GITEA_OWNER="tracer"
GITEA_REPO="iKeyMon"
# GITEA_API_BASE="https://git.24unix.net/api/v1"
# GITEA_TARGET_COMMIT="master"
# GITEA_PRERELEASE="false"
# Sparkle appcast generation (optional)
# 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" # defaults to this path
# SPARKLE_GENERATE_APPCAST="/path/to/generate_appcast" # auto-detected if unset

3
version.json Normal file
View File

@@ -0,0 +1,3 @@
{
"marketing_version": "26.1.7"
}