Compare commits

...
77 Commits
Author SHA1 Message Date
Twyco 5769d84526 Merge pull request 'Fix/ios unsaved form navigation' (#180) from fix/ios-unsaved-form-navigation into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m47s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m24s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 26s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m9s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 21s
CI/CD / Publish immutable image (push) Successful in 22s
Reviewed-on: #180
2026-10-15 07:55:42 +00:00
Twyco 99264c7bc8 fix: pnpm packages
CI/CD / preconditions (pull_request) Successful in 4s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m31s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 2m55s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 2m16s
2026-10-15 09:50:04 +02:00
Twyco 1b448e363c fix: restore iOS navigation with unsaved forms 2026-10-15 09:46:19 +02:00
Twyco 65e9df6d6d fix: pnpm packages
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m34s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m49s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 2m15s
CI/CD / Publish immutable image (push) Skipped
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Canceled after 31s
2026-10-15 09:22:09 +02:00
Twyco 7d7b723f69 Merge pull request 'fix: restore macOS navigation with unsaved forms' (#179) from fix/macos-unsaved-form-navigation into main
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m18s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m32s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 28s
CI/CD / test_and_check_app (push) Failing after 19s
CI/CD / preconditions (push) Successful in 3s
CI/CD / Publish immutable image (push) Skipped
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m14s
Reviewed-on: #179
2026-10-15 07:06:48 +00:00
Twyco 7a656de7ce fix: restore macOS navigation with unsaved forms 2026-10-15 07:06:48 +00:00
Twyco 811dbf7fa4 Merge pull request 'KAN-73: Explain and validate MCP ticket description format' (#178) from feature/KAN-73-mcp-description-contract into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Failing after 1m26s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #178
2026-10-15 07:06:05 +00:00
Twyco 5567eea06f KAN-73: document and validate MCP ticket description format 2026-10-15 07:06:05 +00:00
Twyco ca1f243234 Merge pull request 'KAN-72: Complete MCP ticket editing and discovery' (#177) from feature/KAN-72-complete-mcp-ticket-editing into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 1m46s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #177
2026-10-15 07:04:52 +00:00
Twyco 9ed8d0c6cc feat(KAN-72): complete MCP ticket editing and discovery
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m12s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-02 13:34:00 +02:00
Twyco ba12e65e81 Merge pull request 'Fix KAN-64 project deletion on MariaDB' (#176) from fix/KAN-64-project-delete-response into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 10s
Reviewed-on: #176
2026-10-02 10:17:19 +00:00
Twyco 85b9409e3c Fix project deletion with linked ticket statuses on MariaDB 2026-10-02 10:17:19 +00:00
Twyco f862a6a226 Merge pull request 'KAN-12: Simplify links to action and ticket' (#175) from feature/KAN-12-simplify-link-actions into main
CI/CD / Publish immutable image (push) Successful in 14s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 8s
Reviewed-on: #175
2026-10-01 20:36:03 +00:00
Twyco c8f494fe7c feat: simplify ticket links to action and ticket (KAN-12)
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m2s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 22:32:02 +02:00
Twyco f7c3873c19 Merge pull request 'KAN-68: Add WYSIWYG ticket description editor' (#172) from feature/KAN-68-wysiwyg-description-editor into main
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m10s
CI/CD / test_and_check_app (push) Successful in 8s
CI/CD / Publish immutable image (push) Successful in 40s
Reviewed-on: #172
2026-10-01 19:22:22 +00:00
Twyco d8e1659f4b feat(KAN-68): add visual Markdown ticket description editor
CI/CD / preconditions (pull_request) Successful in 1s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m11s
CI/CD / test_and_check_app (pull_request) Successful in 5m58s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 19:21:22 +00:00
Twyco 704fe5d241 fix
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 11s
2026-10-01 21:15:43 +02:00
Twyco 73f6ab6918 fix
CI/CD / preconditions (push) Successful in 2s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m1s
CI/CD / test_and_check_app (push) Failing after 55s
CI/CD / Publish immutable image (push) Skipped
2026-10-01 21:03:01 +02:00
Twyco 6d4a823fe5 fix
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m9s
CI/CD / test_and_check_app (push) Failing after 57s
CI/CD / Publish immutable image (push) Skipped
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
2026-10-01 20:45:34 +02:00
Twyco 6054690607 Merge pull request 'KAN-64: Allow project admins to delete projects' (#171) from fix/KAN-64-cant-delete-project into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Failing after 54s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #171
2026-10-01 15:16:03 +00:00
Twyco 723285f820 fix: allow project admins to delete projects (KAN-64) 2026-10-01 15:16:03 +00:00
Twyco 393d074bb7 Merge pull request 'KAN-12: Support both ticket link directions' (#174) from feature/KAN-12-bidirectional-ticket-links into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 11s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #174
2026-10-01 15:15:32 +00:00
Twyco 3ec59ef367 feat: support both ticket link directions (KAN-12)
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Failing after 59s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 08:13:54 +02:00
Twyco 10f88f6029 Merge pull request 'Fix dependency CVEs and provide shared PR base' (#173) from fix/dependency-cves into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m8s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 35s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
Reviewed-on: #173
2026-09-30 10:07:53 +00:00
Twyco 3b110f08ae fix
CI/CD / preconditions (pull_request) Successful in 1s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 51s
CI/CD / test_and_check_app (pull_request) Successful in 5m49s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-30 11:57:19 +02:00
Twyco 1a022c257f fix
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 59s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 4m37s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
2026-09-30 11:53:42 +02:00
Twyco ce36060ba1 fix: patch vulnerable framework and build dependencies
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m7s
CI/CD / test_and_check_app (pull_request) Successful in 6m26s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
2026-09-30 11:19:21 +02:00
Twyco f4434a3203 Merge pull request 'KAN-70: Integrate native workspace and update settings' (#170) from feature/KAN-70-native-app-workspace-settings into main
Native JavaScript checks / Native launcher and release tests (push) Successful in 59s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m30s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m23s
CI/CD / test_and_check_app (push) Successful in 6m57s
CI/CD / preconditions (push) Successful in 1s
CI/CD / Publish immutable image (push) Successful in 13s
Reviewed-on: #170
2026-09-29 15:24:44 +00:00
Twyco 912cfc28c2 feat(KAN-70): integrate native workspace and update settings
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m38s
CI/CD / test_and_check_app (pull_request) Successful in 6m47s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-29 17:11:10 +02:00
Twyco 752ba2cd38 Merge pull request 'KAN-71: Use interactive WebKit gestures on iOS and macOS' (#169) from fix/KAN-71-native-navigation-scroll into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m31s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m2s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m24s
CI/CD / test_and_check_app (push) Successful in 6m43s
CI/CD / Publish immutable image (push) Successful in 1m20s
Reviewed-on: #169
2026-09-29 06:04:38 +00:00
Twyco 1cf7524a7b fix(KAN-71): use interactive WebKit navigation gestures
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m13s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m44s
CI/CD / test_and_check_app (pull_request) Successful in 7m4s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-29 08:02:10 +02:00
Twyco 386b25858b Merge pull request 'KAN-71: Correct iOS and macOS navigation gestures and scroll bounds' (#168) from fix/KAN-71-native-navigation-scroll into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m26s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m2s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m23s
CI/CD / test_and_check_app (push) Successful in 6m37s
CI/CD / Publish immutable image (push) Successful in 1m17s
Reviewed-on: #168
2026-09-29 05:28:53 +00:00
Twyco aa14068c66 chore(KAN-71): bump native version to 0.2.4 and mobile build numbers
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m32s
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m1s
CI/CD / test_and_check_app (pull_request) Successful in 6m39s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-29 07:27:05 +02:00
Twyco 4afaa79834 fix(KAN-71): correct Apple navigation gestures and scroll bounds
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m4s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m31s
CI/CD / test_and_check_app (pull_request) Successful in 6m44s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-29 07:22:45 +02:00
Twyco 0662a6970e Merge pull request 'KAN-69: Fix macOS startup and window closing; release 0.2.3' (#167) from fix/KAN-69-macos-startup-and-close into main
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m1s
CI/CD / Publish immutable image (push) Successful in 1m20s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m8s
CI/CD / preconditions (push) Successful in 1s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m27s
CI/CD / test_and_check_app (push) Successful in 6m28s
Reviewed-on: #167
2026-09-28 20:01:57 +00:00
Twyco 6820e0b25e fix(KAN-69): hide macOS launcher on startup and quit on close
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m1s
CI/CD / test_and_check_app (pull_request) Canceled after 1m34s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m29s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-28 22:00:06 +02:00
Twyco b124a77cd1 update macos to v0.2.0
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m31s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m1s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m26s
CI/CD / test_and_check_app (push) Successful in 6m25s
CI/CD / Publish immutable image (push) Successful in 1m15s
2026-09-28 21:28:07 +02:00
Twyco 2725816273 Merge pull request 'KAN-69: Open saved native workspace directly in dashboard' (#166) from feature/KAN-69-open-app-in-dashboard into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m0s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m32s
CI/CD / test_and_check_app (push) Successful in 6m17s
CI/CD / Publish immutable image (push) Successful in 1m15s
Reviewed-on: #166
2026-09-28 19:24:03 +00:00
Twyco 32a6756419 feat(KAN-69): open saved native workspace directly in dashboard
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m17s
CI/CD / test_and_check_app (pull_request) Successful in 6m24s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m27s
2026-09-28 21:00:46 +02:00
Twyco 2a637affdf Merge pull request 'KAN-66: iOS und iPadOS für TestFlight signieren' (#165) from feature/KAN-66-ios-testflight-signing into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m1s
CI/CD / test_and_check_app (push) Successful in 6m36s
CI/CD / Publish immutable image (push) Successful in 1m22s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m30s
Reviewed-on: #165
2026-09-28 14:29:06 +00:00
Twyco 188aa07059 feat(KAN-66): prepare local iOS TestFlight signing
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m33s
CI/CD / test_and_check_app (pull_request) Successful in 6m26s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-28 13:17:45 +02:00
Twyco fa8a0cc512 Merge pull request 'Add permanent macOS app download' (#164) from feature/macos-download-app into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m36s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m3s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m14s
CI/CD / test_and_check_app (push) Successful in 7m13s
CI/CD / preconditions (push) Successful in 2s
CI/CD / Publish immutable image (push) Successful in 14s
Reviewed-on: #164
2026-09-28 07:22:09 +00:00
Twyco d18fffe665 feat: add stable macOS app download to website
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m3s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m41s
CI/CD / test_and_check_app (pull_request) Successful in 6m53s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-28 09:12:37 +02:00
Twyco aece653970 update macos to v0.2.0
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 59s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m17s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m33s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 3m7s
2026-09-28 08:18:22 +02:00
Twyco 2b1cf6ec13 Merge pull request 'KAN-65: Prepare macOS 0.1.5 build 5' (#163) from feature/KAN-65-release-0-1-5 into main
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m14s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 5s
Reviewed-on: #163
2026-09-28 06:08:54 +00:00
Twyco 38355c9507 chore(KAN-65): prepare macOS 0.1.5 build 5
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m0s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m34s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 54s
2026-09-28 08:06:55 +02:00
Twyco d0c5d65f0e Merge pull request 'KAN-65: Fix update menu and rebuild failed macOS releases' (#162) from feature/KAN-65-remove-release-resume into main
macOS checks / Native Rust tests and unsigned build (push) Canceled after 6s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
Reviewed-on: #162
2026-09-28 06:04:03 +00:00
Twyco 4f9bf2dec4 fix(KAN-65): synchronize update menu and remove dock badge
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m37s
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m4s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 1m38s
2026-09-28 08:01:35 +02:00
Twyco 4ee047311f fix(KAN-65): allow rebuilding unpublished macOS releases
CI/CD / test_and_check_app (pull_request) Successful in 6m53s
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / preconditions (pull_request) Successful in 2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m32s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m5s
2026-09-28 07:51:56 +02:00
Twyco 41b6dc8f10 refactor(KAN-65): remove release resume mode 2026-09-28 07:43:31 +02:00
Twyco 3d5ee223e8 Merge pull request 'KAN-65: Fix Hetzner feed ETags and resume published releases' (#161) from fix/KAN-65-hetzner-feed-etag into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 42s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Resume verified release publication (push) Skipped
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 3m0s
Reviewed-on: #161
2026-09-28 05:27:36 +00:00
Twyco 9dbb491dc9 fix(KAN-65): resume release publication with CI credentials
CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 11s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 0s
2026-09-28 07:27:05 +02:00
Twyco 40b66bf7ce fix(KAN-65): normalize Hetzner conditional write ETags
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m5s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m34s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 5m16s
2026-09-28 07:24:12 +02:00
Twyco dc9f400f34 fix(KAN-65): recover verified S3 writes from empty error responses
CI/CD / test_and_check_app (push) Successful in 6m31s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
CI/CD / Publish immutable image (push) Successful in 1m18s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 4m57s
CI/CD / preconditions (push) Successful in 2s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m35s
Native JavaScript checks / Native launcher and release tests (push) Successful in 58s
2026-09-27 23:05:35 +02:00
Twyco 48f5fc410b Merge pull request 'KAN-65: Show the actual macOS build number in About and updates' (#160) from fix/KAN-65-about-build-number into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 11s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 4m21s
Reviewed-on: #160
2026-09-27 20:49:33 +00:00
Twyco f6725ac025 fix(KAN-65): show the installed macOS build number
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 2m0s
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 58s
CI/CD / test_and_check_app (pull_request) Successful in 6m28s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-27 22:42:01 +02:00
Twyco 8aa7bf6860 Merge pull request 'KAN-65: Fix DMG packaging without Finder automation' (#159) from fix/KAN-65-headless-dmg-build into main
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m13s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m30s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
Reviewed-on: #159
2026-09-27 20:13:19 +00:00
Twyco ba9c4fd2e8 fix(KAN-65): package release DMGs without Finder automation
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m5s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m39s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 5m33s
2026-09-27 22:09:09 +02:00
Twyco 74bcf5d30c Merge pull request 'KAN-65: macOS DMG distribution and signed app updates' (#158) from feature/KAN-65-macos-app-updates into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m0s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m30s
CI/CD / test_and_check_app (push) Successful in 6m30s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
CI/CD / Publish immutable image (push) Successful in 1m21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 3m3s
Reviewed-on: #158
2026-09-27 19:52:50 +00:00
Twyco 20f9314659 feat(KAN-65): add signed macOS updates and DMG distribution
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m0s
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 7m2s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m35s
2026-09-27 21:40:23 +02:00
Twyco 3e5c316b8f Merge pull request 'KAN-58: Retry public S3 downloads and prepare macOS 0.1.1' (#157) from fix/KAN-58-retry-public-downloads into main
macOS checks / Native Rust tests and unsigned build (push) Canceled after 6s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 2m29s
Reviewed-on: #157
2026-09-27 14:40:36 +00:00
Twyco 4dd73ec583 fix(KAN-58): retry transient public downloads for macOS 0.1.1
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m4s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m28s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 2m15s
CI/CD / preconditions (pull_request) Successful in 2s
2026-09-27 16:37:16 +02:00
Twyco d8ca4975e5 Merge pull request 'KAN-58: Handle empty S3 bucket versioning response' (#156) from fix/KAN-58-empty-s3-versioning into main
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 3m24s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 54s
CI/CD / test_and_check_app (push) Successful in 6m26s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m17s
CI/CD / Publish immutable image (push) Successful in 1m23s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 20s
Reviewed-on: #156
2026-09-27 10:06:22 +00:00
Twyco f2e1bd6a0d fix(KAN-58): accept empty unversioned bucket response
CI/CD / test_and_check_app (pull_request) Successful in 6m34s
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 55s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m23s
2026-09-27 12:05:11 +02:00
Twyco b0cc9dad14 Merge pull request 'KAN-58: Fix macOS release runner selection' (#155) from fix/KAN-58-macos-runner-selection into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 6s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 3m11s
Reviewed-on: #155
2026-09-27 09:30:31 +00:00
Twyco 52304c7eb2 fix(KAN-58): select macOS runner from repository variable
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 36s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 42s
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 14s
2026-09-27 11:30:18 +02:00
Twyco b4cd6a5808 Merge pull request 'KAN-58: Fix macOS release token permissions' (#154) from fix/KAN-58-release-token-permissions into main
macOS checks / Native Rust tests and unsigned build (push) Canceled after 1m17s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 44s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Canceled after 0s
Reviewed-on: #154
2026-09-27 09:17:12 +00:00
Twyco f52cffeaeb fix(KAN-58): avoid admin API in macOS release workflow
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 58s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 3m22s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 4m18s
2026-09-27 11:13:13 +02:00
Twyco 1c620654a3 Merge pull request 'KAN-58: macOS Actions release to Hetzner Object Storage' (#153) from feature/KAN-58-macos-release-pipeline into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 58s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m27s
CI/CD / test_and_check_app (push) Successful in 6m34s
CI/CD / Publish immutable image (push) Successful in 1m18s
macOS release to Hetzner / Validate release ref and configuration (push) Failing after 23s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Skipped
Reviewed-on: #153
2026-09-27 08:58:30 +00:00
Twyco fcba7673c2 feat(KAN-58): add macOS actions and Hetzner release pipeline
CI/CD / preconditions (pull_request) Successful in 2s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 57s
CI/CD / test_and_check_app (pull_request) Successful in 6m33s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-27 08:08:57 +00:00
Twyco 2f99b8e65c Merge pull request 'KAN-56: Sign and notarize macOS releases' (#152) from feature/KAN-56-signed-macos-release into main
CI/CD / test_and_check_app (push) Successful in 6m39s
CI/CD / Publish immutable image (push) Successful in 1m21s
CI/CD / preconditions (push) Successful in 1s
Reviewed-on: #152
2026-09-27 08:08:47 +00:00
Twyco 2b85517965 feat(KAN-56): sign and notarize macOS releases
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Successful in 6m43s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-27 00:16:29 +02:00
Twyco 7001776c48 Merge pull request 'KAN-55: App links, external links and file selection' (#151) from feature/KAN-55-app-links-file-selection into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 6m26s
CI/CD / Publish immutable image (push) Successful in 1m18s
Reviewed-on: #151
2026-09-26 19:01:06 +00:00
Twyco 9d472b5091 feat(KAN-55): support app links, file selection and clipboard
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Successful in 6m33s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-26 20:58:29 +02:00
Twyco 8bed02a484 Merge pull request 'KAN-54: Add local iOS and iPadOS Xcode builds' (#150) from feature/KAN-54-ios-ipados-local-builds into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 6m36s
CI/CD / Publish immutable image (push) Successful in 1m21s
Reviewed-on: #150
2026-09-26 18:54:17 +00:00
Twyco d07b17ebdb feat(KAN-54): add local iOS and iPadOS Xcode builds
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m59s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-26 20:52:56 +02:00
Twyco 859229fa21 Merge pull request 'KAN-53: Add Android app and platform integration' (#149) from feature/KAN-53-android-app-platform into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 6m33s
CI/CD / Publish immutable image (push) Successful in 1m24s
Reviewed-on: #149
2026-09-26 18:46:18 +00:00
175 changed files with 15850 additions and 516 deletions
+12 -72
View File
@@ -1,78 +1,18 @@
name: "Lint and Test APP" name: test_and_check_app
description: "Run Pint and the Pest test suite" description: Build the test image and run all application checks
runs: runs:
using: composite using: composite
steps: steps:
- name: Setup PHP - name: Test and check app
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2 shell: bash
with:
php-version: "8.5"
extensions: mbstring, pdo_sqlite
coverage: none
tools: composer:v2
- name: Verify Composer lockfile
run: | run: |
if [ ! -f composer.lock ]; then set -Eeuo pipefail
echo "::error::composer.lock not found"
exit 1
fi
shell: bash
- name: Get Composer cache directory docker buildx build \
id: composer_cache --file Dockerfile \
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" --target test \
shell: bash --tag "local/kanbangit-test:${GITHUB_SHA}" \
--progress plain \
- name: Cache Composer packages --load \
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 .
with:
path: ${{ steps.composer_cache.outputs.dir }}
key: ${{ runner.os }}-php-8.5-${{ hashFiles('composer.lock') }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
with:
package-json-file: package.json
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
- name: Prepare environment file
run: cp .env.example .env
shell: bash
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
shell: bash
- name: Generate application key
run: php artisan key:generate
shell: bash
- name: Generate Wayfinder routes and actions
run: php artisan wayfinder:generate --with-form
shell: bash
- name: Run independent application checks
run: |
pnpm exec concurrently \
--names frontend,backend \
--prefix-colors cyan,magenta \
--kill-others-on-fail \
"composer run ci:frontend" \
"composer run ci:backend"
shell: bash
- name: Run Laravel tests
run: composer run ci:test
shell: bash
+58
View File
@@ -0,0 +1,58 @@
name: macOS checks
on:
push:
branches: [main]
paths: ["native/**", ".github/**", "package.json", "pnpm-lock.yaml", "eslint.config.mjs", "resources/css/app.css"]
pull_request:
paths: ["native/**", ".github/**", "package.json", "pnpm-lock.yaml", "eslint.config.mjs", "resources/css/app.css"]
workflow_dispatch:
permissions:
contents: read
jobs:
macos:
name: Native Rust tests and unsigned build
runs-on: ${{ vars.MACOS_CHECK_RUNNER || vars.MACOS_RUNNER || 'macos-arm64' }}
timeout-minutes: 45
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
with:
package-json-file: package.json
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- name: Setup Rust
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: 1.98.1
components: rustfmt, clippy
- name: Install locked native dependencies
run: pnpm --dir native install --frozen-lockfile
- name: Run native tests
run: pnpm --dir native test
- name: Check Rust formatting and warnings
run: pnpm --dir native check
- name: Build unsigned macOS app without release credentials
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
with:
projectPath: native
tauriScript: pnpm exec tauri
args: --ci --debug --no-sign --bundles app -- --locked
uploadUpdaterJson: false
uploadUpdaterSignatures: false
uploadWorkflowArtifacts: false
+228
View File
@@ -0,0 +1,228 @@
name: macOS release to Hetzner
on:
push:
# Gitea enforces the configured macos-v* protection when tags are pushed.
# Reading /tag_protections requires admin rights unavailable to job tokens.
tags:
- "macos-v*"
workflow_dispatch:
inputs:
tag:
description: "Existing protected macos-vMAJOR.MINOR.PATCH tag on main"
required: true
type: string
permissions:
contents: read
concurrency:
# Partial release files may be replaced until the feed is promoted.
# Keep every publisher in this group to prevent overlapping rebuilds.
group: macos-release
cancel-in-progress: false
env:
APPLE_SIGNING_IDENTITY: "Developer ID Application: Nico Schröder (ZZX2KZGTHU)"
APPLE_TEAM_ID: ZZX2KZGTHU
MACOS_RELEASE_TARGET: ${{ vars.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin' }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_REGION: ${{ vars.S3_REGION }}
S3_PREFIX: ${{ vars.S3_PREFIX || 'releases/macos' }}
S3_PUBLIC_BASE_URL: ${{ vars.S3_PUBLIC_BASE_URL }}
jobs:
prepare:
name: Validate release ref and configuration
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
commit: ${{ steps.ref.outputs.commit }}
tag: ${{ steps.ref.outputs.tag }}
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Select an existing version tag contained in main
id: ref
shell: bash
env:
REQUESTED_TAG: ${{ inputs.tag }}
MACOS_RUNNER: ${{ vars.MACOS_RUNNER }}
run: |
set -Eeuo pipefail
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
[[ "$GITHUB_REF" == refs/heads/main ]] || { echo 'Dispatch this workflow from main.' >&2; exit 1; }
tag="$REQUESTED_TAG"
else
tag="${GITHUB_REF#refs/tags/}"
fi
[[ "$tag" =~ ^macos-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { echo 'Invalid macOS release tag.' >&2; exit 1; }
[[ "$MACOS_RUNNER" =~ ^[a-zA-Z0-9_-]+$ ]] || { echo 'Set MACOS_RUNNER to a registered macOS runner label.' >&2; exit 1; }
git fetch --no-tags origin main:refs/remotes/origin/main
commit="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
git merge-base --is-ancestor "$commit" origin/main || { echo 'Release tag must belong to main.' >&2; exit 1; }
git checkout --detach "$commit"
printf 'commit=%s\ntag=%s\n' "$commit" "$tag" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- name: Validate app versions and Hetzner configuration
env:
RELEASE_TAG: ${{ steps.ref.outputs.tag }}
run: |
node native/macos-ci.mjs metadata
node native/publish-macos.mjs config
release:
name: Sign, notarize, verify and publish macOS
needs: prepare
# Gitea resolves runner labels before outputs from prepare are available.
runs-on: ${{ vars.MACOS_RUNNER }}
timeout-minutes: 120
env:
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
steps:
- name: Check out the validated commit
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.prepare.outputs.commit }}
persist-credentials: false
- name: Validate runner and required secrets
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
run: |
set -Eeuo pipefail
[[ "$(uname -s)" == Darwin ]] || { echo 'A macOS runner is required.' >&2; exit 1; }
case "$MACOS_RELEASE_TARGET:$(uname -m)" in
aarch64-apple-darwin:arm64|x86_64-apple-darwin:x86_64) ;;
*) echo 'Runner architecture must match MACOS_RELEASE_TARGET.' >&2; exit 1 ;;
esac
xcodebuild -version
xcrun --find notarytool
aws --version | grep '^aws-cli/2\.' || { echo 'Install AWS CLI v2 permanently on this runner.' >&2; exit 1; }
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_PASSWORD TAURI_SIGNING_PRIVATE_KEY S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
[[ -n "${!name}" ]] || { echo "Missing repository secret: $name" >&2; exit 1; }
done
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
with:
package-json-file: package.json
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- name: Setup Rust
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: 1.98.1
targets: ${{ env.MACOS_RELEASE_TARGET }}
components: rustfmt, clippy
- name: Install locked dependencies
run: |
pnpm install --frozen-lockfile
pnpm --dir native install --frozen-lockfile
- name: Run native checks before importing signing credentials
run: |
pnpm exec eslint native --max-warnings 0
pnpm --dir native test
pnpm --dir native check
- name: Prepare isolated signing keychain
id: signing
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
run: |
set -Eeuo pipefail
umask 077
signing_dir="$(mktemp -d "$RUNNER_TEMP/kanbangit-signing.XXXXXX")"
printf 'SIGNING_DIRECTORY=%s\n' "$signing_dir" >> "$GITHUB_ENV"
security list-keychains -d user > "$signing_dir/original-keychains.txt"
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$signing_dir/certificate.p12"
password="$(openssl rand -hex 32)"
echo "::add-mask::$password"
printf 'keychain=kanbangit-%s-%s\npassword=%s\ncertificate=%s/certificate.p12\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$password" "$signing_dir" >> "$GITHUB_OUTPUT"
- name: Import Developer ID certificate
uses: Apple-Actions/import-codesign-certs@5142e029c445c10ffc7149d172e540235a065466 # v7.0.0; deletes its keychain in the post step
with:
keychain: ${{ steps.signing.outputs.keychain }}
keychain-password: ${{ steps.signing.outputs.password }}
p12-filepath: ${{ steps.signing.outputs.certificate }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Confirm release signing identity
shell: bash
run: |
set -Eeuo pipefail
security find-identity -v -p codesigning | grep -F "\"$APPLE_SIGNING_IDENTITY\""
- name: Build, sign and notarize with Tauri
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
# The DMG bundler reads these variables separately from --ci.
CI: "true"
TAURI_BUNDLER_DMG_IGNORE_CI: "false"
APPLE_ID: dev@petsch-studios.de
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: native
tauriScript: pnpm exec tauri
args: >-
--ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app,dmg
--config '{"bundle":{"createUpdaterArtifacts":true,"macOS":{"hardenedRuntime":true}}}'
-- --locked
uploadUpdaterJson: false
uploadUpdaterSignatures: false
uploadWorkflowArtifacts: false
- name: Verify signed app and packaged download
id: package
run: node native/macos-ci.mjs package
- name: Upload release files and promote the verified update feed
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
AWS_EC2_METADATA_DISABLED: "true"
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
RELEASE_DIRECTORY: ${{ steps.package.outputs.directory }}
run: node native/publish-macos.mjs upload
- name: Restore keychain search list and remove certificate export
if: ${{ always() }}
shell: bash
run: |
python3 - <<'PY'
import os, pathlib, shlex, shutil, subprocess
value = os.environ.get('SIGNING_DIRECTORY')
if value:
directory = pathlib.Path(value)
original = directory / 'original-keychains.txt'
try:
if original.exists():
subprocess.run(['security', 'list-keychains', '-d', 'user', '-s', *shlex.split(original.read_text())], check=True)
finally:
shutil.rmtree(directory)
PY
+57
View File
@@ -0,0 +1,57 @@
name: Native JavaScript checks
on:
push:
branches:
- main
paths:
- "native/**"
- ".github/**"
- "package.json"
- "pnpm-lock.yaml"
- "eslint.config.mjs"
- "resources/css/app.css"
pull_request:
paths:
- "native/**"
- ".github/**"
- "package.json"
- "pnpm-lock.yaml"
- "eslint.config.mjs"
- "resources/css/app.css"
workflow_dispatch:
permissions:
contents: read
jobs:
native_javascript:
name: Native launcher and release tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
with:
package-json-file: package.json
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install locked development dependencies
run: pnpm install --frozen-lockfile
- name: Lint native JavaScript
run: pnpm exec eslint native --max-warnings 0
- name: Test native launcher and release safeguards without credentials
run: node --test native/tests/*.test.js
+8
View File
@@ -33,3 +33,11 @@ yarn-error.log
/native/src-tauri/target /native/src-tauri/target
/native/src-tauri/gen/* /native/src-tauri/gen/*
!/native/src-tauri/gen/android/ !/native/src-tauri/gen/android/
!/native/src-tauri/gen/apple/
/native/src-tauri/gen/apple/build/
/native/src-tauri/gen/apple/Externals/
/native/src-tauri/gen/apple/assets/
/native/src-tauri/gen/apple/Pods/
/native/src-tauri/gen/apple/ExportOptions.plist
/native/src-tauri/gen/apple/**/*.xcuserstate
/native/src-tauri/gen/apple/**/xcuserdata/
+10
View File
@@ -1,5 +1,15 @@
# Working Agreements # Working Agreements
## Native app versions and build numbers
For every delivered change under `native/`, increment the app version and bundle/build number for the affected platforms in the same change before committing. Do this for fixes and follow-up changes too; do not wait for a separate release or a user reminder.
- macOS changes require a higher macOS app version and `bundle.macOS.bundleVersion`.
- iOS changes require a higher iOS app version and `bundle.iOS.bundleVersion`, including the matching Xcode `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in both `project.yml` and `project.pbxproj`.
- Shared changes require a higher general native app version and higher bundle/build numbers for every affected platform. Android changes also require a higher `bundle.android.versionCode`; this metadata rule does not imply Android build support.
- The app version is currently shared: keep `native/package.json`, `native/src-tauri/tauri.conf.json`, the native package entries in `Cargo.toml` and `Cargo.lock`, and the Xcode marketing versions consistent. Use a patch increment unless the requested release calls for a minor or major increment. Never decrease or reuse an existing build number.
- Update the native release notes and run the existing version consistency test. One coordinated increment per delivered change is sufficient; intermediate edits and test reruns do not need additional increments.
## Missing programs and SDKs ## Missing programs and SDKs
If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project. If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project.
+8 -1
View File
@@ -3,6 +3,7 @@
namespace App\Actions\Projects; namespace App\Actions\Projects;
use App\Models\Project; use App\Models\Project;
use Illuminate\Support\Facades\DB;
class DeleteProject class DeleteProject
{ {
@@ -10,7 +11,13 @@ class DeleteProject
{ {
$projectName = $project->name; $projectName = $project->name;
$project->delete(); DB::transaction(function () use ($project): void {
$baseTemplate = $project->baseTemplate()->first();
$project->ticketStatuses()->delete();
$project->delete();
$baseTemplate?->delete();
});
return $projectName; return $projectName;
} }
+15 -12
View File
@@ -17,18 +17,21 @@ class StoreProjectTicketLink
) {} ) {}
/** /**
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated * @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated
*/ */
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
{ {
$linkedTicketId = Arr::get($validated, 'linked_ticket_id'); $linkedTicketId = Arr::get($validated, 'linked_ticket_id');
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id'); $ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
$linkedTicket = Ticket::query() $linkedTicket = Ticket::query()
->where('project_id', $project->id) ->where('project_id', $project->id)
->findOrFail($linkedTicketId); ->findOrFail($linkedTicketId);
$sourceTicket = $isSourceTicket ? $ticket : $linkedTicket;
$targetTicket = $isSourceTicket ? $linkedTicket : $ticket;
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink { return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink {
Ticket::query() Ticket::query()
->whereKey([$ticket->id, $linkedTicket->id]) ->whereKey([$ticket->id, $linkedTicket->id])
->orderBy('id') ->orderBy('id')
@@ -50,31 +53,31 @@ class StoreProjectTicketLink
if ($ticketLink === null) { if ($ticketLink === null) {
$ticketLink = TicketLink::query()->create([ $ticketLink = TicketLink::query()->create([
'source_ticket_id' => $ticket->id, 'source_ticket_id' => $sourceTicket->id,
'target_ticket_id' => $linkedTicket->id, 'target_ticket_id' => $targetTicket->id,
'ticket_link_type_id' => $ticketLinkTypeId, 'ticket_link_type_id' => $ticketLinkTypeId,
]); ]);
} else { } else {
$ticketLink->update([ $ticketLink->update([
'source_ticket_id' => $ticket->id, 'source_ticket_id' => $sourceTicket->id,
'target_ticket_id' => $linkedTicket->id, 'target_ticket_id' => $targetTicket->id,
'ticket_link_type_id' => $ticketLinkTypeId, 'ticket_link_type_id' => $ticketLinkTypeId,
]); ]);
} }
$ticketLink->load('ticketLinkType'); $ticketLink->load('ticketLinkType');
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true); $ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false); $linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
$this->createTicketWorklogEntry->handle( $this->createTicketWorklogEntry->handle(
$ticket, $ticket,
$user, $user,
TicketWorklogEntryType::LinkUpdated, TicketWorklogEntryType::LinkUpdated,
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.", "Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
[ [
'linked_ticket_key' => $linkedTicket->ticket_key, 'linked_ticket_key' => $linkedTicket->ticket_key,
'ticket_link_type' => $sourceLabel, 'ticket_link_type' => $ticketLabel,
], ],
); );
@@ -82,10 +85,10 @@ class StoreProjectTicketLink
$linkedTicket, $linkedTicket,
$user, $user,
TicketWorklogEntryType::LinkUpdated, TicketWorklogEntryType::LinkUpdated,
"Linked {$ticket->ticket_key} as {$targetLabel}.", "Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
[ [
'linked_ticket_key' => $ticket->ticket_key, 'linked_ticket_key' => $ticket->ticket_key,
'ticket_link_type' => $targetLabel, 'ticket_link_type' => $linkedTicketLabel,
], ],
); );
@@ -44,7 +44,7 @@ class AcceptanceCriteriaChecklistMapper
/** /**
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items * @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
*/ */
public static function syncTicket(Ticket $ticket, array $items): void public static function syncTicket(Ticket $ticket, array $items, bool $reuseUnidentifiedCriteria = true): void
{ {
$normalized = self::normalize($items); $normalized = self::normalize($items);
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id'); $existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
@@ -73,7 +73,8 @@ class AcceptanceCriteriaChecklistMapper
: $existingCriteria->get($criterionId); : $existingCriteria->get($criterionId);
while ( while (
$criterion === null $reuseUnidentifiedCriteria
&& $criterion === null
&& $fallbackIndex < $fallbackCriteria->count() && $fallbackIndex < $fallbackCriteria->count()
) { ) {
/** @var AcceptanceCriterion $candidate */ /** @var AcceptanceCriterion $candidate */
@@ -0,0 +1,78 @@
<?php
namespace App\Actions\Tickets\Support;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock;
use League\CommonMark\Extension\CommonMark\Node\Inline\HtmlInline;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use League\CommonMark\Extension\Table\Table;
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
use League\CommonMark\Parser\MarkdownParser;
use League\Uri\Uri;
class TicketDescriptionFormat
{
/** @return array<string, mixed> */
public static function contract(): array
{
return once(fn (): array => json_decode(file_get_contents(resource_path('js/lib/ticket-description-format.json')), true, flags: JSON_THROW_ON_ERROR));
}
public static function instructions(): string
{
$contract = self::contract();
return implode(' ', array_map(fn (string $key): string => $contract[$key], ['format', 'supported', 'unsupported', 'links', 'criteria']))
." Valid description example (Markdown string):\n".$contract['example'];
}
public static function criteriaInstructions(): string
{
return self::contract()['criteria'];
}
public static function safeLink(string $value): bool
{
$contract = self::contract();
$decoded = rawurldecode($value);
if (! mb_check_encoding($decoded, 'UTF-8') || preg_match('~'.$contract['unsafe_link_characters'].'~u', $decoded)
|| ! preg_match('~'.$contract['link_prefix'].'~i', $value)) {
return false;
}
if (str_starts_with($value, '/') || str_starts_with($value, '#')) {
return true;
}
$url = Uri::tryNew($decoded);
return $url !== null && ($url->getScheme() === 'mailto' || (filled($url->getHost()) && ($url->getPort() ?? 0) <= 65535));
}
/** @return list<string> */
public static function unsupportedContent(string $value): array
{
$environment = new Environment;
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new GithubFlavoredMarkdownExtension);
$document = (new MarkdownParser($environment))->parse($value);
$unsupported = [];
foreach ($document->iterator() as $node) {
$type = match (true) {
$node instanceof HtmlBlock, $node instanceof HtmlInline => 'html',
$node instanceof Table => 'table',
$node instanceof Image => 'image',
$node instanceof TaskListItemMarker => 'task',
$node instanceof Link && ! self::safeLink($node->getUrl()) => 'unsafe_link',
default => null,
};
if ($type !== null) {
$unsupported[] = self::contract()['unsupported_tokens'][$type];
}
}
return array_values(array_unique($unsupported));
}
}
+6 -3
View File
@@ -51,6 +51,7 @@ class UpdateProjectTicket
Ticket $ticket, Ticket $ticket,
User $actor, User $actor,
array $validated, array $validated,
bool $replaceCriteriaWithIds = false,
): bool { ): bool {
$fieldValueAttributes = Arr::get($validated, 'field_values', []); $fieldValueAttributes = Arr::get($validated, 'field_values', []);
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize( $nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
@@ -58,7 +59,7 @@ class UpdateProjectTicket
); );
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated); $ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool { return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $replaceCriteriaWithIds, $ticket, $ticketAttributes): bool {
if (! $ticketAttributes['is_blocked']) { if (! $ticketAttributes['is_blocked']) {
$ticketAttributes['blocked_comment'] = null; $ticketAttributes['blocked_comment'] = null;
} }
@@ -91,7 +92,9 @@ class UpdateProjectTicket
/** @var Collection<int, TicketFieldValue> $existingFieldValues */ /** @var Collection<int, TicketFieldValue> $existingFieldValues */
$existingFieldValues = $ticket->fieldValues $existingFieldValues = $ticket->fieldValues
->keyBy('ticket_field_definition_id'); ->keyBy('ticket_field_definition_id');
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria); $currentAcceptanceCriteria = $replaceCriteriaWithIds
? AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria)
: AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
$changes = []; $changes = [];
$summaryLabels = []; $summaryLabels = [];
@@ -209,7 +212,7 @@ class UpdateProjectTicket
} }
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) { if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria); AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria, ! $replaceCriteriaWithIds);
} }
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle( $fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
@@ -36,11 +36,12 @@ class ProjectTicketLinkController extends ApiController
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated()); $ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
$ticketLink->setAttribute('current_ticket_id', $ticket->id); $ticketLink->setAttribute('current_ticket_id', $ticket->id);
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType'])) return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
->additional([ ->additional([
'meta' => [ 'meta' => [
'message' => "{$ticketLink->ticketLinkType->label} link was saved.", 'message' => "{$label} link was saved.",
], ],
]) ])
->response() ->response()
@@ -34,9 +34,10 @@ class ProjectTicketLinkController extends Controller
); );
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated()); $ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
return to_route('projects.tickets.show', [$project, $ticket]) return to_route('projects.tickets.show', [$project, $ticket])
->with('status', "{$ticketLink->ticketLinkType->label} link was saved."); ->with('status', "{$label} link was saved.");
} }
public function destroy( public function destroy(
@@ -23,6 +23,7 @@ class RequirePasswordChange
if ($request->routeIs([ if ($request->routeIs([
'logout', 'logout',
'native-app.edit',
'password.force-change.edit', 'password.force-change.edit',
'user-password.update', 'user-password.update',
])) { ])) {
+1 -1
View File
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()?->can('access-admin') ?? false; return $this->user()?->can('admin-project', $this->route('project')) ?? false;
} }
/** /**
@@ -42,6 +42,7 @@ class StoreTicketLinkRequest extends FormRequest
fn ($query) => $query->where('project_id', $project->id), fn ($query) => $query->where('project_id', $project->id),
), ),
], ],
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
]; ];
} }
+15 -3
View File
@@ -2,24 +2,32 @@
namespace App\Mcp\Servers; namespace App\Mcp\Servers;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Mcp\Tools\CreateTicket; use App\Mcp\Tools\CreateTicket;
use App\Mcp\Tools\GetProjectConfiguration;
use App\Mcp\Tools\GetTicket; use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\ListMyAssignedTickets; use App\Mcp\Tools\ListMyAssignedTickets;
use App\Mcp\Tools\ListProjects;
use App\Mcp\Tools\ListTicketsByStatus; use App\Mcp\Tools\ListTicketsByStatus;
use App\Mcp\Tools\ListTicketTypes; use App\Mcp\Tools\ListTicketTypes;
use App\Mcp\Tools\MoveTicketStatus; use App\Mcp\Tools\MoveTicketStatus;
use App\Mcp\Tools\SearchTickets;
use App\Mcp\Tools\UpdateTicket;
use App\Mcp\Tools\UpdateTicketAssignee; use App\Mcp\Tools\UpdateTicketAssignee;
use App\Mcp\Tools\UpdateTicketDescription; use App\Mcp\Tools\UpdateTicketDescription;
use Laravel\Mcp\Server; use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version; use Laravel\Mcp\Server\Attributes\Version;
#[Name('KanbanGit MCP')] #[Name('KanbanGit MCP')]
#[Version('0.1.0')] #[Version('0.2.1')]
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
class KanbanGitServer extends Server class KanbanGitServer extends Server
{ {
protected function boot(): void
{
$this->instructions = 'Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_projects and get_project_configuration to discover visible projects and current workflow metadata. Use get_ticket with ticket_id or ticket_key; use search_tickets for paginated discovery. Use list_ticket_types to discover types and create_ticket with parent_ticket_id to create children of parent-enabled tickets. Use update_ticket for partial updates and move_ticket_status separately for workflow transitions. '.TicketDescriptionFormat::instructions();
}
protected array $tools = [ protected array $tools = [
ListMyAssignedTickets::class, ListMyAssignedTickets::class,
ListTicketsByStatus::class, ListTicketsByStatus::class,
@@ -29,6 +37,10 @@ class KanbanGitServer extends Server
MoveTicketStatus::class, MoveTicketStatus::class,
CreateTicket::class, CreateTicket::class,
ListTicketTypes::class, ListTicketTypes::class,
ListProjects::class,
GetProjectConfiguration::class,
SearchTickets::class,
UpdateTicket::class,
]; ];
protected array $resources = [ protected array $resources = [
@@ -0,0 +1,13 @@
<?php
namespace App\Mcp\Support;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
trait DescribesTicketWriting
{
public function description(): string
{
return parent::description().' '.TicketDescriptionFormat::instructions();
}
}
+24
View File
@@ -13,6 +13,7 @@ use App\Models\TicketStatus;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request; use Laravel\Mcp\Request;
@@ -78,6 +79,17 @@ class KanbanGitMcp
return $ticket; return $ticket;
} }
public function ticketForKey(User $user, string $ticketKey): Ticket
{
$ticket = Ticket::query()->with('project')->where('ticket_key', $ticketKey)->first();
if ($ticket === null || $ticket->ticket_key !== $ticketKey) {
throw ValidationException::withMessages(['ticket_key' => 'Ticket was not found.']);
}
$this->ensureCanRead($user, $ticket->project);
return $ticket;
}
/** /**
* @throws AuthorizationException * @throws AuthorizationException
*/ */
@@ -201,6 +213,18 @@ class KanbanGitMcp
)); ));
} }
/** @return array{current_page: int, per_page: int, total: int, last_page: int, has_more_pages: bool} */
public function paginationPayload(LengthAwarePaginator $paginator): array
{
return [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
'has_more_pages' => $paginator->hasMorePages(),
];
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Mcp\Support;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Http\Requests\UpdateProjectTicketRequest;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\User;
use App\Rules\VisualTicketDescription;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
class TicketUpdateInput
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp) {}
/** @return array<string, mixed> */
public function validate(Request $request, Ticket $ticket, User $user): array
{
$form = new UpdateProjectTicketRequest;
$route = new Route('PUT', '/', fn () => null);
$route->bind(\Illuminate\Http\Request::create('/', 'PUT'));
$route->setParameter('project', $ticket->project);
$route->setParameter('ticket', $ticket);
$form->setRouteResolver(fn () => $route);
$form->setUserResolver(fn () => $user);
$rules = $form->rules();
foreach ($rules as $key => &$rule) {
if (! str_contains($key, '.')) {
$rule = ['sometimes', ...$rule];
}
}
unset($rule);
$rules['description'][] = new VisualTicketDescription;
$rules['acceptance_criteria'] = ['sometimes', 'array', 'list'];
$rules['acceptance_criteria.*'] = ['array:id,text,checked,indent'];
$rules['acceptance_criteria.*.id'] = ['sometimes', 'integer', 'min:1', 'distinct', Rule::exists('acceptance_criteria', 'id')->where('ticket_id', $ticket->id)];
$rules['acceptance_criteria.*.checked'] = ['sometimes', 'boolean'];
$rules['acceptance_criteria.*.indent'] = ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL];
$rules['field_values'] = ['sometimes', 'array', 'list'];
$rules['field_values.*'] = ['array:ticket_field_definition_id,value_number,value_user_id'];
$rules['field_values.*.ticket_field_definition_id'][] = 'distinct';
$rules['ticket_status_id'] = ['prohibited'];
$rules['acceptance_criteria.*.text'][] = function (string $attribute, mixed $value, \Closure $fail): void {
if (is_string($value) && trim($value) === '') {
$fail('Criterion text must not be blank.');
}
};
$input = $request->validate($rules);
$previousIndent = -1;
foreach (Arr::get($input, 'acceptance_criteria', []) as $index => $item) {
$indent = Arr::get($item, 'indent', 0);
if ($indent > $previousIndent + 1) {
throw ValidationException::withMessages(["acceptance_criteria.{$index}.indent" => 'Indentation cannot skip a level; the first item must have indent 0.']);
}
$previousIndent = $indent;
}
$merged = array_replace($this->kanbanGitMcp->updatePayload($ticket), $input);
$merged['acceptance_criteria'] = Arr::exists($input, 'acceptance_criteria')
? AcceptanceCriteriaChecklistMapper::normalize($input['acceptance_criteria'])
: AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria()->get());
$validator = Validator::make($merged, $form->rules());
$form->setValidator($validator);
$validator->validate();
foreach ($form->after() as $callback) {
$callback($validator);
}
$people = User::query()->visibleToProject($ticket->project)->pluck('id')->all();
foreach (['assignee_user_id', 'author_user_id'] as $field) {
if (Arr::exists($input, $field) && $input[$field] !== null && ! in_array((int) $input[$field], $people, true)) {
$validator->errors()->add($field, 'The person must be able to view this project.');
}
}
$definitions = $ticket->project->ticketFieldDefinitions()->get()->keyBy('id');
foreach (Arr::get($input, 'field_values', []) as $index => $value) {
$definition = $definitions->get($value['ticket_field_definition_id']);
$incompatible = $definition->field_type === 'user' ? 'value_number' : 'value_user_id';
if (Arr::get($value, $incompatible) !== null) {
$validator->errors()->add("field_values.{$index}.{$incompatible}", 'The value must match the custom field type.');
}
if (Arr::get($value, 'value_user_id') !== null && ! in_array((int) $value['value_user_id'], $people, true)) {
$validator->errors()->add("field_values.{$index}.value_user_id", 'The person must be able to view this project.');
}
}
if ($validator->errors()->isNotEmpty()) {
throw new ValidationException($validator);
}
if (Arr::exists($input, 'field_values')) {
$replacement = $definitions->map(fn (TicketFieldDefinition $definition): array => [
'ticket_field_definition_id' => $definition->id,
'value_number' => null,
'value_user_id' => null,
])->all();
foreach ($input['field_values'] as $value) {
$replacement[$value['ticket_field_definition_id']] = $value;
}
$merged['field_values'] = array_values($replacement);
}
return $merged;
}
}
+8 -3
View File
@@ -5,11 +5,14 @@ namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\CreateProjectTicket; use App\Actions\Tickets\CreateProjectTicket;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Enums\Permission; use App\Enums\Permission;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp; use App\Mcp\Support\KanbanGitMcp;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use App\Rules\VisualTicketDescription;
use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type; use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@@ -28,6 +31,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)] #[IsDestructive(false)]
class CreateTicket extends Tool class CreateTicket extends Tool
{ {
use DescribesTicketWriting;
public function __construct( public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp, private readonly KanbanGitMcp $kanbanGitMcp,
private readonly CreateProjectTicket $createProjectTicket, private readonly CreateProjectTicket $createProjectTicket,
@@ -44,7 +49,7 @@ class CreateTicket extends Tool
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id')); $project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
$validated = $request->validate([ $validated = $request->validate([
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string', new VisualTicketDescription],
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)], 'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)], 'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)], 'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
@@ -140,7 +145,7 @@ class CreateTicket extends Tool
return [ return [
'project_id' => $schema->integer()->description('Project ID.')->required(), 'project_id' => $schema->integer()->description('Project ID.')->required(),
'title' => $schema->string()->max(255)->description('Ticket title.')->required(), 'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
'description' => $schema->string()->nullable()->description('Ticket description.'), 'description' => $schema->string()->nullable()->description('Ticket description. '.TicketDescriptionFormat::instructions()),
'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(), 'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(), 'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(), 'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
@@ -156,7 +161,7 @@ class CreateTicket extends Tool
'checked' => $schema->boolean()->default(false), 'checked' => $schema->boolean()->default(false),
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0), 'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
])->withoutAdditionalProperties()) ])->withoutAdditionalProperties())
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items.'), ->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items. '.TicketDescriptionFormat::criteriaInstructions()),
'field_values' => $schema->array() 'field_values' => $schema->array()
->items($schema->object([ ->items($schema->object([
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(), 'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Mcp\Support\KanbanGitMcp;
use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('get_project_configuration')]
#[Description('Get compact current configuration for a visible project: ordered columns, statuses with IDs/slugs/column assignment and allowed target status IDs, types, priorities, custom field definitions, and permitted people IDs/names. Reads live effective configuration, without template snapshots or full user/project objects. People are paginated (default 25, maximum 100); use people_page for subsequent pages. Author changes require project admin permission, reported in permissions.')]
#[IsReadOnly]
class GetProjectConfiguration extends Tool
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
public function handle(Request $request): Response|ResponseFactory
{
$input = $request->validate([
'project_id' => ['required', 'integer'],
'people_page' => ['sometimes', 'integer', 'min:1'],
'people_per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);
$user = $this->kanbanGitMcp->user($request);
$project = $this->kanbanGitMcp->projectForRead($user, $input['project_id']);
$people = User::query()->visibleToProject($project)->orderBy('id')->paginate($input['people_per_page'] ?? 25, ['id', 'name'], 'people_page', $input['people_page'] ?? 1);
$columns = $project->boardColumns()->orderBy('id')->get(['id', 'name', 'position']);
$columnPositions = $columns->pluck('position', 'id');
$statuses = $project->ticketStatuses()->with('allowedTargetStatuses')->get()->sortBy(
fn (TicketStatus $status): array => [$columnPositions->get($status->board_column_id, 0), $status->position, $status->id],
)->values();
return Response::structured([
'data' => [
'project' => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name],
'permissions' => [
'can_write' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
'can_change_author' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
],
'board_columns' => $columns->toArray(),
'ticket_statuses' => $statuses->map(fn (TicketStatus $status): array => [
'id' => $status->id, 'name' => $status->name, 'slug' => $status->slug,
'position' => $status->position, 'board_column_id' => $status->board_column_id,
'allowed_target_status_ids' => $status->allowedTargetStatuses->pluck('id')->all(),
])->all(),
'ticket_types' => $project->ticketTypes()->orderBy('id')->get(['id', 'name', 'position', 'is_parent'])->toArray(),
'ticket_priorities' => $project->ticketPriorities()->orderBy('id')->get(['id', 'name', 'position'])->toArray(),
'ticket_field_definitions' => $project->ticketFieldDefinitions()->orderBy('id')->get(['id', 'label', 'field_type', 'position'])->toArray(),
'people' => $people->getCollection()->map(fn (User $person): array => ['id' => $person->id, 'name' => $person->name])->all(),
],
'meta' => ['people' => $this->kanbanGitMcp->paginationPayload($people)],
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'project_id' => $schema->integer()->description('Visible project ID from list_projects.')->required(),
'people_page' => $schema->integer()->min(1)->description('People page number, defaults to 1.'),
'people_per_page' => $schema->integer()->min(1)->max(100)->description('People page size, defaults to 25; maximum 100.'),
];
}
}
+8 -5
View File
@@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('get_ticket')] #[Name('get_ticket')]
#[Description('Get a visible ticket with project, status, type, priority, assignee, author, field values, and worklog data.')] #[Description('Get a visible ticket by exactly one of numeric ticket_id or exact full ticket_key (e.g. KAN-67), with project, status, type, priority, assignee, author, field values, and worklog data.')]
#[IsReadOnly] #[IsReadOnly]
class GetTicket extends Tool class GetTicket extends Tool
{ {
@@ -25,11 +25,14 @@ class GetTicket extends Tool
public function handle(Request $request): Response|ResponseFactory public function handle(Request $request): Response|ResponseFactory
{ {
$validated = $request->validate([ $validated = $request->validate([
'ticket_id' => ['required', 'integer'], 'ticket_id' => ['required_without:ticket_key', 'prohibits:ticket_key', 'integer'],
'ticket_key' => ['required_without:ticket_id', 'prohibits:ticket_id', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
]); ]);
$user = $this->kanbanGitMcp->user($request); $user = $this->kanbanGitMcp->user($request);
$ticket = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']); $ticket = isset($validated['ticket_id'])
? $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id'])
: $this->kanbanGitMcp->ticketForKey($user, $validated['ticket_key']);
return Response::structured([ return Response::structured([
'data' => $this->kanbanGitMcp->ticketPayload($ticket), 'data' => $this->kanbanGitMcp->ticketPayload($ticket),
@@ -43,8 +46,8 @@ class GetTicket extends Tool
{ {
return [ return [
'ticket_id' => $schema->integer() 'ticket_id' => $schema->integer()
->description('Ticket ID.') ->description('Ticket ID. Supply exactly one of ticket_id or ticket_key.'),
->required(), 'ticket_key' => $schema->string()->description('Exact full ticket key, e.g. KAN-67. Supply exactly one of ticket_id or ticket_key.'),
]; ];
} }
} }
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService;
use App\Mcp\Support\KanbanGitMcp;
use App\Models\Project;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('list_projects')]
#[Description('List visible projects independently of assigned tickets. Returns only ID, key and name, ordered by ID, with bounded pagination. page starts at 1; per_page defaults to 25 and may not exceed 100.')]
#[IsReadOnly]
class ListProjects extends Tool
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
public function handle(Request $request): Response|ResponseFactory
{
$input = $request->validate(['page' => ['sometimes', 'integer', 'min:1'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100']]);
$user = $this->kanbanGitMcp->user($request);
$projects = Project::query()->whereIn('id', $this->permissionService->viewableProjectIds($user))
->orderBy('id')->paginate($input['per_page'] ?? 25, ['id', 'key', 'name'], 'page', $input['page'] ?? 1);
return Response::structured([
'data' => $projects->getCollection()->map(fn (Project $project): array => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name])->all(),
'meta' => $this->kanbanGitMcp->paginationPayload($projects),
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
];
}
}
+3
View File
@@ -3,6 +3,7 @@
namespace App\Mcp\Tools; namespace App\Mcp\Tools;
use App\Actions\Tickets\MoveProjectTicket; use App\Actions\Tickets\MoveProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp; use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type; use Illuminate\JsonSchema\Types\Type;
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)] #[IsDestructive(false)]
class MoveTicketStatus extends Tool class MoveTicketStatus extends Tool
{ {
use DescribesTicketWriting;
public function __construct( public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp, private readonly KanbanGitMcp $kanbanGitMcp,
private readonly MoveProjectTicket $moveProjectTicket, private readonly MoveProjectTicket $moveProjectTicket,
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService;
use App\Mcp\Support\KanbanGitMcp;
use App\Models\Ticket;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('search_tickets')]
#[Description('Search visible tickets with combined filters: exact full ticket key, literal text in title/description, project, status, type, assignee and parent. Omitted filters do not restrict results; assignee_user_id/parent_ticket_id null selects unassigned/top-level tickets. Returns compact ID, key, title, project ID and status with deterministic ID order and bounded pagination (default 25, maximum 100).')]
#[IsReadOnly]
class SearchTickets extends Tool
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
public function handle(Request $request): Response|ResponseFactory
{
$input = $request->validate([
'ticket_key' => ['sometimes', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
'query' => ['sometimes', 'string', 'max:255'],
'project_id' => ['sometimes', 'integer'],
'ticket_status_id' => ['sometimes', 'integer'],
'ticket_type_id' => ['sometimes', 'integer'],
'assignee_user_id' => ['sometimes', 'nullable', 'integer'],
'parent_ticket_id' => ['sometimes', 'nullable', 'integer'],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);
$user = $this->kanbanGitMcp->user($request);
$this->kanbanGitMcp->projectForRead($user, $input['project_id'] ?? null);
$query = Ticket::query()->whereIn('project_id', $this->permissionService->viewableProjectIds($user));
foreach (['ticket_key', 'project_id', 'ticket_status_id', 'ticket_type_id', 'assignee_user_id', 'parent_ticket_id'] as $filter) {
if (Arr::exists($input, $filter)) {
$query->where($filter, $input[$filter]);
}
}
if (Arr::exists($input, 'query')) {
$pattern = '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $input['query']).'%';
$query->where(fn (Builder $q) => $q->whereRaw("title LIKE ? ESCAPE '!'", [$pattern])->orWhereRaw("description LIKE ? ESCAPE '!'", [$pattern]));
}
$tickets = $query->with('ticketStatus:id,name,slug,board_column_id')->orderBy('id')
->paginate($input['per_page'] ?? 25, ['id', 'ticket_key', 'title', 'project_id', 'ticket_status_id'], 'page', $input['page'] ?? 1);
return Response::structured([
'data' => $tickets->getCollection()->map(fn (Ticket $ticket): array => [
'id' => $ticket->id,
'ticket_key' => $ticket->ticket_key,
'title' => $ticket->title,
'project_id' => $ticket->project_id,
'ticket_status_id' => $ticket->ticket_status_id,
'status' => $ticket->ticketStatus === null ? null : ['id' => $ticket->ticketStatus->id, 'name' => $ticket->ticketStatus->name, 'slug' => $ticket->ticketStatus->slug],
])->all(),
'meta' => $this->kanbanGitMcp->paginationPayload($tickets),
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'ticket_key' => $schema->string()->description('Exact full key, e.g. KAN-67.'),
'query' => $schema->string()->max(255)->description('Literal substring in title or description.'),
'project_id' => $schema->integer()->description('Visible project ID.'),
'ticket_status_id' => $schema->integer()->description('Status ID from get_project_configuration.'),
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
'assignee_user_id' => $schema->integer()->nullable()->description('Assignee ID; null selects unassigned tickets.'),
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent ID; null selects top-level tickets.'),
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
];
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp;
use App\Mcp\Support\TicketUpdateInput;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[Name('update_ticket')]
#[Description('Partially update a ticket by ID using existing project permissions and ticket rules. Omitted fields stay unchanged. Explicit null clears nullable scalar fields only. acceptance_criteria and field_values replace their entire lists; [] clears a list and null is rejected. Checklist items with an existing ID retain their identity; omit id for a new item. Use move_ticket_status separately for workflow transitions. Author changes require project admin permission; people must be visible to the project.')]
#[IsDestructive(false)]
class UpdateTicket extends Tool
{
use DescribesTicketWriting;
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
private readonly TicketUpdateInput $ticketUpdateInput,
private readonly UpdateProjectTicket $updateProjectTicket,
) {}
public function handle(Request $request): Response|ResponseFactory
{
$selector = $request->validate(['ticket_id' => ['required', 'integer']]);
$user = $this->kanbanGitMcp->user($request);
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $selector['ticket_id']);
$validated = $this->ticketUpdateInput->validate($request, $ticket, $user);
$updated = $this->updateProjectTicket->handle($ticket, $user, $validated, true);
return Response::structured([
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
'meta' => ['updated' => $updated],
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'ticket_id' => $schema->integer()->description('Existing ticket ID.')->required(),
'title' => $schema->string()->max(255)->description('Nonblank title; omit to preserve.'),
'description' => $schema->string()->nullable()->description('Description; null clears it. '.TicketDescriptionFormat::instructions()),
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
'ticket_priority_id' => $schema->integer()->description('Priority ID from get_project_configuration.'),
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent-enabled ticket in this project; null removes the parent. Cycles are forbidden.'),
'assignee_user_id' => $schema->integer()->nullable()->description('Allowed person ID; null unassigns.'),
'author_user_id' => $schema->integer()->nullable()->description('Author ID; null clears. Changes require project admin permission.'),
'story_points' => $schema->integer()->min(0)->nullable()->description('Nonnegative estimate; null clears.'),
'is_blocked' => $schema->boolean()->description('Blocked state; true requires a nonblank blocker comment. Omit to preserve.'),
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason; null clears only when the ticket is unblocked.'),
'acceptance_criteria' => $schema->array()->items($schema->object([
'id' => $schema->integer()->min(1)->description('Existing criterion ID belonging to this ticket. Omit for a new item. IDs must be unique.'),
'text' => $schema->string()->description('Nonblank criterion text.')->required(),
'checked' => $schema->boolean()->description('Checked state, defaults to false for supplied items.'),
'indent' => $schema->integer()->min(0)->max(8)->description('Indent level, defaults to 0; cannot skip a level.'),
])->withoutAdditionalProperties())->description('Ordered replacement checklist. Omitted items are removed. [] clears; omit field to preserve; null is invalid. '.TicketDescriptionFormat::criteriaInstructions()),
'field_values' => $schema->array()->items($schema->object([
'ticket_field_definition_id' => $schema->integer()->description('Project field ID, supplied once.')->required(),
'value_number' => $schema->number()->nullable()->description('Value for a numeric field; null clears.'),
'value_user_id' => $schema->integer()->nullable()->description('Allowed person ID for a user field; null clears.'),
])->withoutAdditionalProperties())->description('Replacement custom-field list. Omitted definitions are cleared. [] clears all; omit field to preserve; null is invalid.'),
];
}
}
+3
View File
@@ -3,6 +3,7 @@
namespace App\Mcp\Tools; namespace App\Mcp\Tools;
use App\Actions\Tickets\UpdateProjectTicket; use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp; use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type; use Illuminate\JsonSchema\Types\Type;
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)] #[IsDestructive(false)]
class UpdateTicketAssignee extends Tool class UpdateTicketAssignee extends Tool
{ {
use DescribesTicketWriting;
public function __construct( public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp, private readonly KanbanGitMcp $kanbanGitMcp,
private readonly UpdateProjectTicket $updateProjectTicket, private readonly UpdateProjectTicket $updateProjectTicket,
+9 -3
View File
@@ -2,10 +2,14 @@
namespace App\Mcp\Tools; namespace App\Mcp\Tools;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Actions\Tickets\UpdateProjectTicket; use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp; use App\Mcp\Support\KanbanGitMcp;
use App\Rules\VisualTicketDescription;
use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type; use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr;
use Laravel\Mcp\Request; use Laravel\Mcp\Request;
use Laravel\Mcp\Response; use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\ResponseFactory;
@@ -19,6 +23,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)] #[IsDestructive(false)]
class UpdateTicketDescription extends Tool class UpdateTicketDescription extends Tool
{ {
use DescribesTicketWriting;
public function __construct( public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp, private readonly KanbanGitMcp $kanbanGitMcp,
private readonly UpdateProjectTicket $updateProjectTicket, private readonly UpdateProjectTicket $updateProjectTicket,
@@ -27,7 +33,7 @@ class UpdateTicketDescription extends Tool
public function handle(Request $request): Response|ResponseFactory public function handle(Request $request): Response|ResponseFactory
{ {
$validated = $request->validate([ $validated = $request->validate([
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string', new VisualTicketDescription],
'ticket_id' => ['required', 'integer'], 'ticket_id' => ['required', 'integer'],
]); ]);
@@ -35,7 +41,7 @@ class UpdateTicketDescription extends Tool
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']); $ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
$payload = [ $payload = [
...$this->kanbanGitMcp->updatePayload($ticket), ...$this->kanbanGitMcp->updatePayload($ticket),
'description' => $validated['description'] ?? null, ...(Arr::exists($validated, 'description') ? ['description' => Arr::get($validated, 'description')] : []),
]; ];
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload); $wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
@@ -58,7 +64,7 @@ class UpdateTicketDescription extends Tool
return [ return [
'description' => $schema->string() 'description' => $schema->string()
->nullable() ->nullable()
->description('New ticket description. Pass null to clear it.'), ->description('New ticket description. Omit to preserve; pass null to clear it. '.TicketDescriptionFormat::instructions()),
'ticket_id' => $schema->integer() 'ticket_id' => $schema->integer()
->description('Ticket ID.') ->description('Ticket ID.')
->required(), ->required(),
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Rules;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class VisualTicketDescription implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
return;
}
$unsupported = TicketDescriptionFormat::unsupportedContent($value);
if ($unsupported !== []) {
$fail('The description contains unsupported '.implode(', ', $unsupported).'. Remove these contents or show literal examples inside code. Use ordinary Markdown and safe links; put acceptance criteria only in acceptance_criteria. No ticket fields were changed.');
}
}
}
Generated
+26 -22
View File
@@ -1451,20 +1451,20 @@
}, },
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v13.26.1", "version": "v13.34.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/framework.git", "url": "https://github.com/laravel/framework.git",
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab" "reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab", "url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab", "reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19", "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
"composer-runtime-api": "^2.2", "composer-runtime-api": "^2.2",
"doctrine/inflector": "^2.0.5", "doctrine/inflector": "^2.0.5",
"dragonmantank/cron-expression": "^3.4", "dragonmantank/cron-expression": "^3.4",
@@ -1576,18 +1576,20 @@
"league/flysystem-sftp-v3": "^3.25.1", "league/flysystem-sftp-v3": "^3.25.1",
"mockery/mockery": "^1.6.10", "mockery/mockery": "^1.6.10",
"opis/json-schema": "^2.4.1", "opis/json-schema": "^2.4.1",
"orchestra/testbench-core": "^11.0.0", "orchestra/testbench-core": "^11.5.0",
"pda/pheanstalk": "^7.0.0 || ^8.0.0", "pda/pheanstalk": "^7.0.0 || ^8.0.0",
"php-http/discovery": "^1.15", "php-http/discovery": "^1.15",
"phpstan/phpstan": "^2.0", "phpstan/phpstan": "2.2.15",
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
"predis/predis": "^2.3 || ^3.0", "predis/predis": "^2.3 || ^3.0",
"rector/rector": "^2.3", "rector/rector": "2.6.3",
"resend/resend-php": "^1.0", "resend/resend-php": "^1.0",
"symfony/cache": "^7.4.0 || ^8.0.0", "symfony/cache": "^7.4.0 || ^8.0.0",
"symfony/http-client": "^7.4.0 || ^8.0.0", "symfony/http-client": "^7.4.0 || ^8.0.0",
"symfony/mercure": "^0.8.0",
"symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0",
"symfony/translation": "^7.4.0 || ^8.0.0" "symfony/translation": "^7.4.0 || ^8.0.0",
"web-token/jwt-library": "^4.1"
}, },
"suggest": { "suggest": {
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
@@ -1623,8 +1625,10 @@
"symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).",
"symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).",
"symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).",
"symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).",
"symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).",
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).",
"web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)."
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -1674,7 +1678,7 @@
"issues": "https://github.com/laravel/framework/issues", "issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework" "source": "https://github.com/laravel/framework"
}, },
"time": "2026-08-18T20:31:28+00:00" "time": "2026-09-29T12:46:21+00:00"
}, },
{ {
"name": "laravel/mcp", "name": "laravel/mcp",
@@ -2003,16 +2007,16 @@
}, },
{ {
"name": "league/commonmark", "name": "league/commonmark",
"version": "2.10.0", "version": "2.10.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/commonmark.git", "url": "https://github.com/thephpleague/commonmark.git",
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4" "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4", "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4", "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2106,7 +2110,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-08-11T16:06:25+00:00" "time": "2026-09-21T13:07:34+00:00"
}, },
{ {
"name": "league/config", "name": "league/config",
@@ -2192,16 +2196,16 @@
}, },
{ {
"name": "league/flysystem", "name": "league/flysystem",
"version": "3.35.2", "version": "3.36.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/flysystem.git", "url": "https://github.com/thephpleague/flysystem.git",
"reference": "b277b5dc3d56650b68904117124e79c851e12376" "reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
"reference": "b277b5dc3d56650b68904117124e79c851e12376", "reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2269,9 +2273,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/thephpleague/flysystem/issues", "issues": "https://github.com/thephpleague/flysystem/issues",
"source": "https://github.com/thephpleague/flysystem/tree/3.35.2" "source": "https://github.com/thephpleague/flysystem/tree/3.36.0"
}, },
"time": "2026-07-06T14:42:07+00:00" "time": "2026-09-02T08:00:27+00:00"
}, },
{ {
"name": "league/flysystem-aws-s3-v3", "name": "league/flysystem-aws-s3-v3",
+509
View File
@@ -0,0 +1,509 @@
import { Buffer } from 'node:buffer';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, relative } from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { parseArgs } from 'node:util';
export const appleTeamId = 'ZZX2KZGTHU';
export const bundleIdentifier = 'de.kanbangit.ios';
const plistReader = `import base64, datetime, json, plistlib, sys
def encode(value):
if isinstance(value, bytes): return base64.b64encode(value).decode()
if isinstance(value, datetime.datetime): return value.isoformat() + 'Z'
raise TypeError(type(value).__name__)
print(json.dumps(plistlib.loads(sys.stdin.buffer.read()), default=encode))`;
function execute(command, args, options) {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env,
input: options.input,
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024,
stdio: options.inherit ? 'inherit' : 'pipe',
});
if (result.error || result.status !== 0) {
throw new Error(
`${options.step} failed: ${result.error?.message || result.stderr?.trim() || `exit ${result.status ?? result.signal}`}`,
);
}
return result.stdout?.trim() || '';
}
export function validateProfile(profile, mode, now = new Date()) {
const entitlements = profile.Entitlements ?? {};
if (
profile.TeamIdentifier?.length !== 1 ||
profile.TeamIdentifier[0] !== appleTeamId ||
entitlements['com.apple.developer.team-identifier'] !== appleTeamId ||
entitlements['application-identifier'] !==
`${appleTeamId}.${bundleIdentifier}`
) {
throw new Error(
`The profile must explicitly match ${bundleIdentifier} in team ${appleTeamId}.`,
);
}
if (
!/^[a-f\d]{8}-(?:[a-f\d]{4}-){3}[a-f\d]{12}$/i.test(profile.UUID) ||
!(new Date(profile.ExpirationDate) > now) ||
!profile.DeveloperCertificates?.length ||
!profile.Platform?.includes('iOS')
) {
throw new Error(
'The iOS profile is invalid or expired. Download a current profile from your Apple account.',
);
}
if (mode === 'testflight') {
if (
entitlements['get-task-allow'] !== false ||
entitlements['beta-reports-active'] !== true ||
'ProvisionedDevices' in profile ||
profile.ProvisionsAllDevices
) {
throw new Error(
'TestFlight requires an App Store Connect profile, not a development, Ad Hoc or enterprise profile.',
);
}
} else if (
entitlements['get-task-allow'] !== true ||
!profile.ProvisionedDevices?.length ||
profile.ProvisionsAllDevices
) {
throw new Error(
'Device development requires an iOS App Development profile containing your registered device.',
);
}
return profile;
}
export function selectIdentity(profile, identities, mode) {
const fingerprints = profile.DeveloperCertificates.map((certificate) =>
createHash('sha1')
.update(Buffer.from(certificate, 'base64'))
.digest('hex')
.toUpperCase(),
);
const prefix =
mode === 'testflight' ? 'Apple Distribution: ' : 'Apple Development: ';
const matches = [
...identities.matchAll(/\) ([A-Fa-f\d]{40}) "([^"]+)"/g),
].filter(
([, fingerprint, name]) =>
fingerprints.includes(fingerprint.toUpperCase()) &&
name.startsWith(prefix),
);
if (matches.length !== 1) {
throw new Error(
`The profile must match exactly one usable ${prefix.replace(': ', '')} certificate with its private key in your local keychain. Select a profile containing the intended certificate.`,
);
}
return matches[0][1].toUpperCase();
}
export function buildIos({
mode = 'testflight',
env = process.env,
platform = process.platform,
directory = dirname(fileURLToPath(import.meta.url)),
home = homedir(),
run = execute,
log = console.log,
now = new Date(),
} = {}) {
if (!['testflight', 'development', 'simulator'].includes(mode)) {
throw new Error('Choose --mode testflight, development or simulator.');
}
if (platform !== 'darwin') {
throw new Error(
'iOS builds require a Mac with Xcode, XcodeGen and the matching Rust target installed permanently.',
);
}
if (
env.APPLE_DEVELOPMENT_TEAM &&
env.APPLE_DEVELOPMENT_TEAM !== appleTeamId
) {
throw new Error(`APPLE_DEVELOPMENT_TEAM must be ${appleTeamId}.`);
}
if (env.TAURI_CONFIG) {
throw new Error(
'Remove TAURI_CONFIG overrides before building the configured iOS app.',
);
}
const config = JSON.parse(
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
);
const ios = JSON.parse(
readFileSync(join(directory, 'src-tauri/tauri.ios.conf.json'), 'utf8'),
);
const version = config.version;
const buildNumber = ios.bundle?.iOS?.bundleVersion;
if (
ios.identifier !== bundleIdentifier ||
ios.bundle?.iOS?.developmentTeam !== appleTeamId ||
config.identifier !== 'de.kanbangit.desktop' ||
config.productName !== 'KanbanGit' ||
!/^\d+\.\d+\.\d+$/.test(version) ||
!/^[1-9]\d*$/.test(buildNumber)
) {
throw new Error(
'Check the iOS team, bundle identifier, version and positive integer bundleVersion in the Tauri configuration.',
);
}
const simulator = mode === 'simulator';
const configuration = mode === 'testflight' ? 'release' : 'debug';
const target = simulator ? 'aarch64-apple-ios-sim' : 'aarch64-apple-ios';
const sdk = simulator ? 'iphonesimulator' : 'iphoneos';
const project = join(directory, 'src-tauri/gen/apple');
const targetDirectory = join(directory, 'src-tauri/target');
const outputRoot = join(targetDirectory, `ios-${mode}`);
const destination = join(outputRoot, `${version}-${buildNumber}`);
if (mode === 'testflight' && existsSync(destination)) {
throw new Error(
`Build output already exists: ${destination}. Move it aside, or increment bundle.iOS.bundleVersion for a new TestFlight upload.`,
);
}
const buildEnv = Object.fromEntries(
Object.entries(env).filter(
([key]) =>
!key.startsWith('APPLE_') &&
!key.startsWith('IOS_') &&
!key.startsWith('TAURI_') &&
![
'SDKROOT',
'CARGO_ENCODED_RUSTFLAGS',
'RUSTFLAGS',
'RUSTC_WRAPPER',
'RUSTC_WORKSPACE_WRAPPER',
'XCODE_XCCONFIG_FILE',
].includes(key),
),
);
function step(label, command, args, options = {}) {
log(label);
return run(command, args, {
cwd: directory,
env: buildEnv,
step: label,
...options,
});
}
function parsePlist(xml) {
return JSON.parse(
step(
'Reading local plist metadata',
'python3',
['-c', plistReader],
{ input: xml },
),
);
}
function readProfile(path) {
return validateProfile(
parsePlist(
step('Reading provisioning profile', 'security', [
'cms',
'-D',
'-i',
path,
]),
),
mode,
now,
);
}
step(
'Checking XcodeGen (install permanently with brew install xcodegen if missing)',
'xcodegen',
['--version'],
);
step('Checking Xcode', 'xcodebuild', ['-version']);
const installedTargets = step('Checking installed Rust targets', 'rustup', [
'target',
'list',
'--installed',
]);
if (!installedTargets.split(/\s+/).includes(target)) {
throw new Error(
`Install the missing target permanently with rustup target add ${target}, then retry.`,
);
}
const cargo = step('Selecting the rustup Cargo toolchain', 'rustup', [
'which',
'cargo',
]);
buildEnv.RUSTC = step('Selecting the matching Rust compiler', 'rustup', [
'which',
'rustc',
]);
buildEnv.PATH = `${dirname(cargo)}:${buildEnv.PATH ?? ''}`;
buildEnv.SDKROOT = step('Checking the iOS SDK', 'xcrun', [
'--sdk',
sdk,
'--show-sdk-path',
]);
buildEnv.IPHONEOS_DEPLOYMENT_TARGET = ios.bundle.iOS.minimumSystemVersion;
buildEnv.CARGO_TARGET_DIR = targetDirectory;
let profile;
let profilePath;
let identity;
if (!simulator) {
if (
!env.IOS_PROVISIONING_PROFILE ||
!existsSync(env.IOS_PROVISIONING_PROFILE)
) {
throw new Error(
'Set IOS_PROVISIONING_PROFILE to your downloaded .mobileprovision file outside the repository. Apple account setup is manual; this command never creates or downloads profiles.',
);
}
profilePath = realpathSync(env.IOS_PROVISIONING_PROFILE);
const withinProject = relative(
realpathSync(join(directory, '..')),
profilePath,
);
if (
!withinProject.startsWith(
`..${process.platform === 'win32' ? '\\' : '/'}`,
) &&
!isAbsolute(withinProject)
) {
throw new Error(
'Keep provisioning profiles outside the repository.',
);
}
profile = readProfile(profilePath);
identity = selectIdentity(
profile,
step('Checking local signing identities', 'security', [
'find-identity',
'-v',
'-p',
'codesigning',
]),
mode,
);
}
mkdirSync(outputRoot, { recursive: true });
const working = mkdtempSync(join(outputRoot, '.building-'));
let installedProfile;
try {
if (profile) {
const installedPath = join(
home,
'Library/Developer/Xcode/UserData/Provisioning Profiles',
`${profile.UUID}.mobileprovision`,
);
mkdirSync(dirname(installedPath), { recursive: true });
if (existsSync(installedPath)) {
if (
!readFileSync(installedPath).equals(
readFileSync(profilePath),
)
) {
throw new Error(
'A different provisioning profile already exists with the same UUID. Resolve it in Xcode before retrying.',
);
}
} else {
copyFileSync(profilePath, installedPath);
installedProfile = installedPath;
}
}
step('Generating the Xcode project', 'xcodegen', [
'generate',
'--spec',
join(project, 'project.yml'),
]);
step(
'Building the iOS Rust library',
cargo,
[
'build',
'--manifest-path',
join(directory, 'src-tauri/Cargo.toml'),
'--lib',
'--locked',
'--target',
target,
'--features',
'custom-protocol',
...(configuration === 'release' ? ['--release'] : []),
],
{ inherit: true },
);
const library = join(
targetDirectory,
target,
configuration,
'libkanbangit_native.a',
);
if (!existsSync(library)) {
throw new Error(
`The iOS Rust library was not produced: ${library}`,
);
}
buildEnv.KANBANGIT_IOS_LIBRARY = library;
const archive = join(working, 'KanbanGit.xcarchive');
step(
'Archiving locally without Apple provisioning updates',
'xcodebuild',
[
'-project',
join(project, 'kanbangit-native.xcodeproj'),
'-scheme',
'kanbangit-native_iOS',
'-configuration',
configuration,
'-sdk',
sdk,
'-destination',
`generic/platform=${simulator ? 'iOS Simulator' : 'iOS'}`,
'-archivePath',
archive,
'-derivedDataPath',
join(targetDirectory, `ios-derived-${mode}`),
'archive',
`MARKETING_VERSION=${version}`,
`CURRENT_PROJECT_VERSION=${buildNumber}`,
`DEVELOPMENT_TEAM=${appleTeamId}`,
`PRODUCT_BUNDLE_IDENTIFIER=${bundleIdentifier}`,
'CODE_SIGN_STYLE=Manual',
'ARCHS=arm64',
...(simulator
? ['CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO']
: [
`CODE_SIGN_IDENTITY=${identity}`,
`PROVISIONING_PROFILE_SPECIFIER=${profile.UUID}`,
]),
],
{ inherit: true },
);
function verifyApp(app) {
const info = parsePlist(readFileSync(join(app, 'Info.plist')));
if (
info.CFBundleIdentifier !== bundleIdentifier ||
info.CFBundleShortVersionString !== version ||
info.CFBundleVersion !== buildNumber
) {
throw new Error(
'The built app has an unexpected identifier, version or build number.',
);
}
if (!simulator) {
step('Verifying the Apple signature and team', 'codesign', [
'--verify',
'--deep',
'--strict',
'-R',
`=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[subject.OU] = "${appleTeamId}" and certificate leaf = H"${identity}" and entitlement["application-identifier"] = "${appleTeamId}.${bundleIdentifier}" and entitlement["com.apple.developer.team-identifier"] = "${appleTeamId}" and ${mode === 'testflight' ? '! ' : ''}entitlement["get-task-allow"] exists${mode === 'testflight' ? ' and entitlement["beta-reports-active"] exists' : ''}`,
app,
]);
if (
readProfile(join(app, 'embedded.mobileprovision')).UUID !==
profile.UUID
) {
throw new Error(
'The app was signed with an unexpected provisioning profile.',
);
}
}
}
verifyApp(join(archive, 'Products/Applications/KanbanGit.app'));
if (!simulator) {
const exportOptions = join(working, 'ExportOptions.plist');
writeFileSync(
exportOptions,
JSON.stringify({
method:
mode === 'testflight'
? 'app-store-connect'
: 'debugging',
destination: 'export',
signingStyle: 'manual',
teamID: appleTeamId,
signingCertificate: identity,
provisioningProfiles: { [bundleIdentifier]: profile.UUID },
manageAppVersionAndBuildNumber: false,
}),
);
step('Preparing local export options', 'plutil', [
'-convert',
'xml1',
exportOptions,
]);
step(
'Exporting the signed IPA locally (no upload)',
'xcodebuild',
[
'-exportArchive',
'-archivePath',
archive,
'-exportPath',
join(working, 'export'),
'-exportOptionsPlist',
exportOptions,
],
{ inherit: true },
);
const ipa = join(working, 'export/KanbanGit.ipa');
if (!existsSync(ipa)) {
throw new Error('Xcode did not produce the signed IPA.');
}
const extracted = join(working, 'verify');
step('Verifying the exported IPA contents', 'ditto', [
'-x',
'-k',
ipa,
extracted,
]);
verifyApp(join(extracted, 'Payload/KanbanGit.app'));
rmSync(extracted, { recursive: true, force: true });
writeFileSync(
`${ipa}.sha256`,
`${createHash('sha256').update(readFileSync(ipa)).digest('hex')} KanbanGit.ipa\n`,
);
}
if (mode !== 'testflight') {
rmSync(destination, { recursive: true, force: true });
}
renameSync(working, destination);
log(`Verified local ${mode} build: ${destination}`);
return destination;
} catch (error) {
log(`Build did not complete. Diagnostic output remains in ${working}`);
throw error;
} finally {
if (installedProfile) {
rmSync(installedProfile);
}
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
try {
const { values } = parseArgs({
options: { mode: { type: 'string', default: 'testflight' } },
});
buildIos({ mode: values.mode });
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+267
View File
@@ -0,0 +1,267 @@
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
appendFileSync,
copyFileSync,
readlinkSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import process from 'node:process';
import { updateSource } from './update-manifest.mjs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
appleTeamId,
bundleIdentifier,
verifySignedMacosApp,
} from './release-macos.mjs';
const nativeDirectory = dirname(fileURLToPath(import.meta.url));
export function releaseMetadata(
env = process.env,
directory = nativeDirectory,
) {
const config = JSON.parse(
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
);
const nativePackage = JSON.parse(
readFileSync(join(directory, 'package.json'), 'utf8'),
);
const packageVersion = nativePackage.version;
const cargoVersion = readFileSync(
join(directory, 'src-tauri/Cargo.toml'),
'utf8',
)
.match(/\[package\]([\s\S]*?)(?:\n\[|$)/)?.[1]
.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
if (
!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(config.version) ||
config.version !== packageVersion ||
config.version !== cargoVersion
) {
throw new Error(
'Tauri, package.json and Cargo.toml must have the same stable release version.',
);
}
if (env.RELEASE_TAG !== `macos-v${config.version}`) {
throw new Error(
'Release tag must be macos-vMAJOR.MINOR.PATCH and match the app version.',
);
}
if (
config.identifier !== bundleIdentifier ||
config.productName !== 'KanbanGit'
) {
throw new Error(
'The macOS product name and bundle identifier must stay unchanged.',
);
}
if (config.plugins?.updater?.pubkey !== updateSource.publicKey) {
throw new Error(
'The app updater key must match the permanent release key.',
);
}
if (
!/^\d+(?:\.\d+){0,2}$/.test(config.bundle?.macOS?.bundleVersion || '')
) {
throw new Error(
'Set a numeric macOS bundleVersion for the About build number.',
);
}
if (!nativePackage.releaseNotes?.trim()) {
throw new Error(
'Add releaseNotes to native/package.json before publishing.',
);
}
const target = env.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin';
const arch = {
'aarch64-apple-darwin': 'arm64',
'x86_64-apple-darwin': 'x86_64',
}[target];
if (!arch) {
throw new Error(
'MACOS_RELEASE_TARGET must select Apple Silicon or Intel macOS.',
);
}
const identity = env.APPLE_SIGNING_IDENTITY;
if (
!identity?.startsWith('Developer ID Application: ') ||
!identity.endsWith(` (${appleTeamId})`) ||
/[\r\n"]/.test(identity)
) {
throw new Error(
`A Developer ID Application identity for ${appleTeamId} is required.`,
);
}
return {
version: config.version,
notes: nativePackage.releaseNotes,
target,
arch,
filename: `KanbanGit-${config.version}-${target}.dmg`,
updaterFilename: `KanbanGit-${config.version}-${target}.app.tar.gz`,
};
}
export function execute(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
});
if (result.error || result.status !== 0) {
throw new Error(
`${command} failed (exit ${result.status ?? 'unknown'}). ${result.stderr || ''}`,
);
}
return result.stdout?.trim() || result.stderr?.trim() || '';
}
export function verifyNotarizedApp(app, metadata, run = execute) {
verifySignedMacosApp({
app,
version: metadata.version,
step: (_label, command, args) => run(command, args),
});
const arch = run('lipo', [
'-archs',
join(app, 'Contents/MacOS/kanbangit-native'),
]).trim();
if (arch !== metadata.arch) {
throw new Error(
'The app architecture does not match its download path.',
);
}
run('xcrun', ['stapler', 'validate', app]);
run('spctl', ['--assess', '--type', 'execute', '--verbose=2', app]);
}
export function packageNotarizedApp({
env = process.env,
directory = nativeDirectory,
run = execute,
} = {}) {
const metadata = releaseMetadata(env, directory);
const root = join(directory, 'src-tauri/target');
const app = join(
root,
metadata.target,
'release/bundle/macos/KanbanGit.app',
);
verifyNotarizedApp(app, metadata, run);
const releaseRoot = join(root, 'macos-release');
const destination = join(
releaseRoot,
`${metadata.version}-${metadata.target}`,
);
if (existsSync(destination)) {
throw new Error(
'Release output already exists; start from a clean checkout.',
);
}
mkdirSync(releaseRoot, { recursive: true });
const staging = mkdtempSync(join(releaseRoot, '.pending-'));
try {
const archive = join(staging, metadata.filename);
const bundle = join(root, metadata.target, 'release/bundle');
copyFileSync(
join(
bundle,
'dmg',
`KanbanGit_${metadata.version}_${metadata.arch === 'arm64' ? 'aarch64' : 'x64'}.dmg`,
),
archive,
);
const mounted = join(staging, 'mounted');
mkdirSync(mounted);
run('hdiutil', ['verify', archive]);
run('hdiutil', [
'attach',
archive,
'-readonly',
'-nobrowse',
'-mountpoint',
mounted,
]);
try {
if (
readlinkSync(join(mounted, 'Applications')) !== '/Applications'
) {
throw new Error(
'DMG must contain the Applications installation link.',
);
}
verifyNotarizedApp(join(mounted, 'KanbanGit.app'), metadata, run);
} finally {
run('hdiutil', ['detach', mounted]);
}
rmSync(mounted, { recursive: true, force: true });
const updateArchive = join(staging, metadata.updaterFilename);
copyFileSync(join(bundle, 'macos/KanbanGit.app.tar.gz'), updateArchive);
copyFileSync(
join(bundle, 'macos/KanbanGit.app.tar.gz.sig'),
`${updateArchive}.sig`,
);
if (!readFileSync(`${updateArchive}.sig`, 'utf8').trim()) {
throw new Error('The updater signature is missing.');
}
const extracted = join(staging, 'extracted');
mkdirSync(extracted);
run('tar', ['-xzf', updateArchive, '-C', extracted]);
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
rmSync(extracted, { recursive: true, force: true });
const checksum = createHash('sha256')
.update(readFileSync(archive))
.digest('hex');
for (const name of [metadata.filename, metadata.updaterFilename]) {
const hash = createHash('sha256')
.update(readFileSync(join(staging, name)))
.digest('hex');
writeFileSync(
join(staging, `${name}.sha256`),
`${hash} ${name}\n`,
);
}
renameSync(staging, destination);
return { ...metadata, directory: destination, checksum };
} finally {
rmSync(staging, { recursive: true, force: true });
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
try {
let output;
if (process.argv[2] === 'metadata') {
output = releaseMetadata();
} else if (process.argv[2] === 'package') {
output = packageNotarizedApp();
} else {
throw new Error(
'Use metadata or package. Building and notarizing are handled by tauri-action.',
);
}
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
Object.entries(output)
.filter(([key]) => key !== 'notes')
.map(([key, value]) => `${key}=${value}\n`)
.join(''),
);
}
console.log(JSON.stringify(output));
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+25
View File
@@ -0,0 +1,25 @@
import { mkdirSync, rmSync, symlinkSync } from 'node:fs';
import { dirname, join } from 'node:path';
export function createDiskImage({ app, archive, identity, run }) {
const contents = join(dirname(archive), 'dmg-contents');
mkdirSync(contents);
try {
run('ditto', [app, join(contents, 'KanbanGit.app')]);
symlinkSync('/Applications', join(contents, 'Applications'));
run('hdiutil', [
'create',
'-volname',
'KanbanGit',
'-srcfolder',
contents,
'-format',
'UDZO',
archive,
]);
run('codesign', ['--sign', identity, '--timestamp', archive]);
run('hdiutil', ['verify', archive]);
} finally {
rmSync(contents, { recursive: true, force: true });
}
}
+14 -4
View File
@@ -1,19 +1,29 @@
{ {
"name": "kanbangit-native", "name": "kanbangit-native",
"version": "0.1.0", "version": "0.2.8",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tauri dev", "dev": "tauri dev",
"build": "tauri build", "build": "tauri build",
"build:macos": "tauri build --debug --bundles app", "build:macos": "tauri build --debug --bundles app",
"release:macos": "node release-macos.mjs",
"test": "node --test tests/*.test.js && cargo test --manifest-path src-tauri/Cargo.toml --locked", "test": "node --test tests/*.test.js && cargo test --manifest-path src-tauri/Cargo.toml --locked",
"check": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets --all-features -- -D warnings", "check": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets --all-features -- -D warnings",
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
"dev:android": "tauri android dev", "dev:android": "tauri android dev",
"build:android": "tauri android build --debug --apk --target aarch64" "build:android": "tauri android build --debug --apk --target aarch64",
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
"dev:ios": "tauri ios dev",
"build:ios:simulator": "node build-ios.mjs --mode simulator",
"build:ios:device": "node build-ios.mjs --mode development",
"release:ios": "node build-ios.mjs --mode testflight",
"open:ios": "tauri ios dev --open --no-dev-server",
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test",
"smoke:updater": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test updater_smoke"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "2.11.4" "@tauri-apps/cli": "2.11.4"
} },
"releaseNotes": "Fix blocked navigation after changing project creation options or other forms on iOS by displaying native confirmations for unsaved changes. Pending confirmations close safely when changing workspaces."
} }
+515
View File
@@ -0,0 +1,515 @@
import { Buffer } from 'node:buffer';
import { createHash } from 'node:crypto';
import {
appendFileSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import process from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { pathToFileURL } from 'node:url';
import { execute, releaseMetadata } from './macos-ci.mjs';
import {
compareVersions,
updateManifest,
updateSource,
verifyUpdateSignature,
} from './update-manifest.mjs';
export function storageConfiguration(env = process.env) {
const region = env.S3_REGION;
const bucket = env.S3_BUCKET;
const prefix = env.S3_PREFIX || 'releases/macos';
if (!['nbg1', 'fsn1', 'hel1'].includes(region)) {
throw new Error('S3_REGION must be nbg1, fsn1 or hel1.');
}
if (!/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(bucket || '')) {
throw new Error('Set S3_BUCKET to the Hetzner release bucket name.');
}
if (!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(prefix)) {
throw new Error(
'S3_PREFIX must contain plain path segments without leading or trailing slashes.',
);
}
const endpoint = `https://${region}.your-objectstorage.com`;
const publicBase =
env.S3_PUBLIC_BASE_URL ||
`https://${bucket}.${region}.your-objectstorage.com`;
const url = new URL(publicBase);
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.search ||
url.hash ||
/[\s]/.test(publicBase)
) {
throw new Error(
'S3_PUBLIC_BASE_URL must be a public HTTPS URL without credentials or query parameters.',
);
}
return {
region,
bucket,
prefix,
endpoint,
publicBase: publicBase.replace(/\/$/, ''),
};
}
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
export function conditionalEtag(etag) {
const value =
typeof etag === 'string' && etag.startsWith('"') && etag.endsWith('"')
? etag.slice(1, -1)
: etag;
if (
typeof value !== 'string' ||
!/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value)
) {
throw new Error(
'Cannot atomically replace an object without a valid S3 ETag.',
);
}
// Hetzner returns quoted ETags on GET but compares bare values on conditional PUT.
return value;
}
function requiresExistingObjectCheck(error) {
// AWS CLI can crash while formatting Hetzner's empty error Message.
// This only permits a readback; content checks and conditional writes still apply.
return /\(PreconditionFailed\)|\(412\)|argument of type 'NoneType' is not (?:a container or )?iterable/.test(
error.message,
);
}
export async function publishMacos({
env = process.env,
directory,
metadata,
run = execute,
fetchPublic = fetch,
wait = setTimeout,
publicKey = updateSource.publicKey,
feedBase = updateSource.baseUrl,
} = {}) {
const storage = storageConfiguration(env);
if (`${storage.publicBase}/${storage.prefix}` !== feedBase) {
throw new Error(
'Release storage does not match the update source bundled in the app.',
);
}
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
throw new Error(
'Set the dedicated S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY release secrets.',
);
}
const archive = readFileSync(join(directory, metadata.filename));
const checksum = sha256(archive);
const checksumFile = readFileSync(
join(directory, `${metadata.filename}.sha256`),
);
if (checksumFile.toString() !== `${checksum} ${metadata.filename}\n`) {
throw new Error('Release archive does not match its SHA-256 file.');
}
const updater = readFileSync(join(directory, metadata.updaterFilename));
const signature = readFileSync(
join(directory, `${metadata.updaterFilename}.sig`),
'utf8',
);
verifyUpdateSignature(updater, signature, publicKey);
const updateChecksum = readFileSync(
join(directory, `${metadata.updaterFilename}.sha256`),
);
if (
updateChecksum.toString() !==
`${sha256(updater)} ${metadata.updaterFilename}\n`
) {
throw new Error('Updater archive does not match its SHA-256 file.');
}
const common = [
'--endpoint-url',
storage.endpoint,
'--region',
storage.region,
'--no-cli-pager',
's3api',
];
function s3(operation, args) {
try {
return run('aws', [
...common,
operation,
'--bucket',
storage.bucket,
...args,
]);
} catch (error) {
const keyIndex = args.indexOf('--key');
const key = keyIndex >= 0 ? ` ${args[keyIndex + 1]}` : '';
throw new Error(`S3 ${operation}${key}: ${error.message}`, {
cause: error,
});
}
}
const versioningOutput = s3('get-bucket-versioning', [
'--output',
'json',
]).trim();
let versioning;
try {
// AWS CLI prints nothing for a successful empty VersioningConfiguration.
versioning = JSON.parse(versioningOutput || '{}');
} catch {
throw new Error('Invalid JSON from S3 bucket versioning check.');
}
if (
!versioning ||
typeof versioning !== 'object' ||
Array.isArray(versioning)
) {
throw new Error(
'Invalid S3 bucket versioning response: expected an object.',
);
}
if (versioning.Status) {
throw new Error(
'Use an unversioned release bucket: Hetzner does not support conditional writes on versioned buckets.',
);
}
const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-'));
const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`;
const publicPrefix = `${storage.publicBase}/${keyPrefix}`;
const latestPrefix = `${storage.prefix}/${metadata.target}`;
const latestKey = `${latestPrefix}/latest.json`;
async function verifyPublic(name, contents, prefix = publicPrefix) {
for (let attempt = 1; attempt <= 5; attempt++) {
let response;
try {
response = await fetchPublic(`${prefix}/${name}`, {
redirect: 'error',
cache: 'no-store',
signal: AbortSignal.timeout(60000),
});
} catch (error) {
if (attempt === 5) {
throw new Error(
`Public download request failed for ${name}: ${error.message}`,
);
}
await wait(2000);
continue;
}
if (response.ok) {
if (
sha256(Buffer.from(await response.arrayBuffer())) !==
sha256(contents)
) {
throw new Error(
`Public download verification failed for ${name}: SHA-256 mismatch.`,
);
}
return;
}
await response.body?.cancel();
const retryable = [403, 404, 408, 429, 500, 502, 503, 504].includes(
response.status,
);
if (!retryable || attempt === 5) {
throw new Error(
`Public download verification failed for ${name}: HTTP ${response.status}.`,
);
}
await wait(2000);
}
}
function assertUnpublishedRelease(allowSameVersion = false) {
const file = join(staging, 'published-latest.json');
try {
s3('get-object', ['--key', latestKey, file]);
} catch (error) {
if (/\(NoSuchKey\)/.test(error.message)) {
return;
}
if (requiresExistingObjectCheck(error)) {
// A broken CLI error message cannot distinguish missing from denied.
// Only an authenticated, explicitly empty listing proves absence.
const listing = JSON.parse(
s3('list-objects-v2', [
'--prefix',
latestKey,
'--max-keys',
'1',
'--output',
'json',
]),
);
if (
listing?.KeyCount === 0 &&
listing.IsTruncated === false &&
Array.isArray(listing.Contents ?? []) &&
(listing.Contents ?? []).length === 0
) {
return;
}
}
throw error;
}
const published = JSON.parse(readFileSync(file, 'utf8'));
const comparison = compareVersions(metadata.version, published.version);
if (allowSameVersion && comparison < 0) {
throw new Error('Refusing to downgrade the stable download.');
}
if (comparison < 0 || (!allowSameVersion && comparison === 0)) {
throw new Error(
'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.',
);
}
}
// The macos-release concurrency group serializes the entire publication.
// Never run independent publishers against the same release destination.
function putReleaseFile(name, contents, contentType) {
const file = join(staging, name);
writeFileSync(file, contents);
function put(condition) {
s3('put-object', [
'--key',
`${keyPrefix}/${name}`,
'--body',
file,
...condition,
'--content-type',
contentType,
'--cache-control',
'no-cache, max-age=0, must-revalidate',
'--content-md5',
createHash('md5').update(contents).digest('base64'),
'--metadata',
`sha256=${sha256(contents)}`,
]);
}
try {
put(['--if-none-match', '*']);
} catch (error) {
if (!requiresExistingObjectCheck(error)) {
throw error;
}
const existing = join(staging, `existing-${name}`);
const response = JSON.parse(
s3('get-object', [
'--key',
`${keyPrefix}/${name}`,
'--output',
'json',
existing,
]),
);
if (sha256(readFileSync(existing)) !== sha256(contents)) {
assertUnpublishedRelease();
put(['--if-match', conditionalEtag(response.ETag)]);
}
}
}
try {
const files = [
[metadata.filename, archive, 'application/x-apple-diskimage'],
[metadata.updaterFilename, updater, 'application/gzip'],
[
`${metadata.updaterFilename}.sig`,
Buffer.from(signature),
'text/plain; charset=utf-8',
],
[
`${metadata.updaterFilename}.sha256`,
updateChecksum,
'text/plain; charset=utf-8',
],
[
`${metadata.filename}.sha256`,
checksumFile,
'text/plain; charset=utf-8',
],
];
for (const [name, contents, type] of files) {
putReleaseFile(name, contents, type);
await verifyPublic(name, contents);
}
// Completion metadata is written only after every public artifact matches.
const manifest = Buffer.from(
JSON.stringify(
{
version: metadata.version,
target: metadata.target,
url: `${publicPrefix}/${metadata.filename}`,
sha256: checksum,
checksumUrl: `${publicPrefix}/${metadata.filename}.sha256`,
},
null,
2,
) + '\n',
);
putReleaseFile('release.json', manifest, 'application/json');
await verifyPublic('release.json', manifest);
const latest = Buffer.from(
JSON.stringify(
updateManifest(
metadata,
publicPrefix,
signature,
metadata.notes,
),
null,
2,
) + '\n',
);
putReleaseFile('update.json', latest, 'application/json');
await verifyPublic('update.json', latest);
// An older release must not populate an absent download alias either.
assertUnpublishedRelease(true);
const downloadName = 'KanbanGit.dmg';
const downloadKey = `${latestPrefix}/${downloadName}`;
const downloadFile = join(staging, downloadName);
writeFileSync(downloadFile, archive);
function publishDownload(condition) {
s3('put-object', [
'--key',
downloadKey,
'--body',
downloadFile,
...condition,
'--content-type',
'application/x-apple-diskimage',
'--cache-control',
'no-cache, max-age=0, must-revalidate',
'--content-md5',
createHash('md5').update(archive).digest('base64'),
'--metadata',
`sha256=${checksum}`,
]);
}
try {
publishDownload(['--if-none-match', '*']);
} catch (error) {
if (!requiresExistingObjectCheck(error)) {
throw error;
}
const previousFile = join(staging, 'previous-download.dmg');
const response = JSON.parse(
s3('get-object', [
'--key',
downloadKey,
'--output',
'json',
previousFile,
]),
);
if (sha256(readFileSync(previousFile)) !== checksum) {
assertUnpublishedRelease();
publishDownload(['--if-match', conditionalEtag(response.ETag)]);
}
}
await verifyPublic(
downloadName,
archive,
`${storage.publicBase}/${latestPrefix}`,
);
const latestFile = join(staging, 'latest.json');
writeFileSync(latestFile, latest);
function publishLatest(condition) {
s3('put-object', [
'--key',
latestKey,
'--body',
latestFile,
...condition,
'--content-type',
'application/json',
'--cache-control',
'no-cache, max-age=0, must-revalidate',
'--content-md5',
createHash('md5').update(latest).digest('base64'),
]);
}
try {
publishLatest(['--if-none-match', '*']);
} catch (error) {
if (!requiresExistingObjectCheck(error)) {
throw error;
}
const previousFile = join(staging, 'previous-latest.json');
const response = JSON.parse(
s3('get-object', [
'--key',
latestKey,
'--output',
'json',
previousFile,
]),
);
const previousBytes = readFileSync(previousFile);
const previous = JSON.parse(previousBytes);
const comparison = compareVersions(
metadata.version,
previous.version,
);
if (comparison < 0) {
throw new Error('Refusing to downgrade the update feed.');
}
if (comparison === 0) {
if (!previousBytes.equals(latest)) {
throw new Error(
'Published update metadata differs; bump the app version.',
);
}
} else {
publishLatest(['--if-match', conditionalEtag(response.ETag)]);
}
}
await verifyPublic(
'latest.json',
latest,
`${storage.publicBase}/${latestPrefix}`,
);
return `${publicPrefix}/${metadata.filename}`;
} finally {
rmSync(staging, { recursive: true, force: true });
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
try {
if (process.argv[2] === 'config') {
console.log(JSON.stringify(storageConfiguration()));
} else if (
process.argv[2] === 'upload' &&
process.env.RELEASE_DIRECTORY
) {
const url = await publishMacos({
directory: process.env.RELEASE_DIRECTORY,
metadata: releaseMetadata(),
});
console.log(`Verified public release: ${url}`);
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(
process.env.GITHUB_STEP_SUMMARY,
`Download: [KanbanGit](${url})\n`,
);
}
} else {
throw new Error(
'Use config or upload; upload requires RELEASE_DIRECTORY.',
);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+478
View File
@@ -0,0 +1,478 @@
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { parseArgs } from 'node:util';
import { createDiskImage } from './macos-dmg.mjs';
export const appleTeamId = 'ZZX2KZGTHU';
export const bundleIdentifier = 'de.kanbangit.desktop';
const submissionIdPattern =
/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
function execute(command, args, options) {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env,
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
stdio: options.inherit ? 'inherit' : 'pipe',
});
if (result.error || result.status !== 0) {
throw new Error(
`${options.step} failed${result.error ? `: ${result.error.message}` : ` (exit ${result.status ?? result.signal})`}.`,
);
}
return result.stdout?.trim() || result.stderr?.trim() || '';
}
export function verifySignedMacosApp({ app, version, step }) {
step('Verifying Developer ID signature and team', 'codesign', [
'--verify',
'--deep',
'--strict',
'--verbose=2',
'-R',
`=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[subject.OU] = "${appleTeamId}" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists`,
app,
]);
const signature = step('Checking hardened runtime', 'codesign', [
'--display',
'--verbose=4',
app,
]);
if (!/^CodeDirectory .*flags=.*\bruntime\b/m.test(signature)) {
throw new Error(
'The release signature does not enable Hardened Runtime.',
);
}
const cdhash = signature.match(/^CDHash=([\da-f]+)$/m)?.[1];
if (!cdhash) {
throw new Error(
'The signed app has no CodeDirectory hash to match against Apple notarization.',
);
}
const bundledVersion = step('Checking bundled release version', 'plutil', [
'-extract',
'CFBundleShortVersionString',
'raw',
'-o',
'-',
join(app, 'Contents/Info.plist'),
]);
if (bundledVersion.trim() !== version) {
throw new Error(
'The built app version does not match the release version.',
);
}
return cdhash;
}
export function releaseMacos({
env = process.env,
platform = process.platform,
arch = process.arch,
directory = dirname(fileURLToPath(import.meta.url)),
run = execute,
log = console.log,
resumeSubmissionId = null,
} = {}) {
if (platform !== 'darwin') {
throw new Error('macOS releases require a Mac with Xcode installed.');
}
if (
resumeSubmissionId !== null &&
!submissionIdPattern.test(resumeSubmissionId)
) {
throw new Error('--resume requires the Apple submission UUID.');
}
if (!env.TAURI_SIGNING_PRIVATE_KEY?.trim()) {
throw new Error(
'Set TAURI_SIGNING_PRIVATE_KEY to the permanent updater signing key.',
);
}
const identity = env.APPLE_SIGNING_IDENTITY?.trim();
const profile = env.APPLE_NOTARY_KEYCHAIN_PROFILE?.trim();
if (
!identity?.startsWith('Developer ID Application: ') ||
!identity.endsWith(` (${appleTeamId})`) ||
/[\r\n"]/.test(identity)
) {
throw new Error(
`Set APPLE_SIGNING_IDENTITY to the full Developer ID Application certificate name for team ${appleTeamId}.`,
);
}
if (env.APPLE_TEAM_ID && env.APPLE_TEAM_ID !== appleTeamId) {
throw new Error(`APPLE_TEAM_ID must be ${appleTeamId}.`);
}
if (!profile) {
throw new Error(
'Set APPLE_NOTARY_KEYCHAIN_PROFILE to a notarytool profile created with store-credentials for the new Apple team.',
);
}
const target =
env.MACOS_RELEASE_TARGET ||
{ arm64: 'aarch64-apple-darwin', x64: 'x86_64-apple-darwin' }[arch];
if (!['aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(target)) {
throw new Error(
'MACOS_RELEASE_TARGET must be aarch64-apple-darwin or x86_64-apple-darwin.',
);
}
const config = JSON.parse(
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
);
if (
config.identifier !== bundleIdentifier ||
config.productName !== 'KanbanGit'
) {
throw new Error(
'The macOS release must keep the KanbanGit product name and bundle identifier.',
);
}
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(config.version)) {
throw new Error(
'The macOS release version must be a stable MAJOR.MINOR.PATCH version.',
);
}
const targetDirectory = join(directory, 'src-tauri/target');
const releaseDirectory = join(targetDirectory, 'macos-release');
const destination = join(releaseDirectory, `${config.version}-${target}`);
if (existsSync(destination)) {
throw new Error(
`Release output already exists: ${destination}. Move it aside or use a new version.`,
);
}
// Notarization is handled below with the explicit keychain profile, never ambient Apple credentials.
const buildEnv = Object.fromEntries(
Object.entries(env).filter(
([key]) => !key.startsWith('APPLE_') && key !== 'TAURI_CONFIG',
),
);
buildEnv.APPLE_SIGNING_IDENTITY = identity;
buildEnv.CARGO_TARGET_DIR = targetDirectory;
function step(label, command, args, inherit = false) {
log(label);
return run(command, args, {
cwd: directory,
env: buildEnv,
step: label,
inherit,
});
}
const identities = step('Checking signing certificate', 'security', [
'find-identity',
'-v',
'-p',
'codesigning',
]);
if (
!identities
.split('\n')
.some((line) => line.trim().endsWith(`"${identity}"`))
) {
throw new Error(
`Install the Developer ID Application certificate for ${appleTeamId}, including its private key, in an unlocked keychain.`,
);
}
const notaryCredentials = ['--keychain-profile', profile];
if (env.APPLE_NOTARY_KEYCHAIN?.trim()) {
notaryCredentials.push('--keychain', env.APPLE_NOTARY_KEYCHAIN.trim());
}
step(
'Checking notarization access (check the keychain profile if this fails)',
'xcrun',
[
'notarytool',
'history',
...notaryCredentials,
'--output-format',
'json',
],
);
const app = join(
targetDirectory,
target,
'release/bundle/macos/KanbanGit.app',
);
if (!resumeSubmissionId) {
step(
'Building signed macOS release',
'pnpm',
[
'exec',
'tauri',
'build',
'--ci',
'--target',
target,
'--bundles',
'app',
'--config',
JSON.stringify({
bundle: {
createUpdaterArtifacts: false,
macOS: {
signingIdentity: identity,
hardenedRuntime: true,
},
},
}),
'--',
'--locked',
],
true,
);
}
if (!existsSync(app)) {
throw new Error('The release build did not produce KanbanGit.app.');
}
const cdhash = verifySignedMacosApp({ app, version: config.version, step });
mkdirSync(releaseDirectory, { recursive: true });
const staging = mkdtempSync(join(releaseDirectory, '.pending-'));
try {
const submission = join(staging, 'notarization.zip');
let submissionId = resumeSubmissionId;
if (!submissionId) {
step('Preparing notarization archive', 'ditto', [
'-c',
'-k',
'--sequesterRsrc',
'--keepParent',
app,
submission,
]);
const submitted = JSON.parse(
step('Submitting to Apple', 'xcrun', [
'notarytool',
'submit',
submission,
...notaryCredentials,
'--no-wait',
'--output-format',
'json',
]),
);
submissionId = submitted.id;
if (!submissionIdPattern.test(submissionId ?? '')) {
throw new Error(
'Apple did not return a valid submission UUID. Check notarytool history before retrying.',
);
}
}
const resumeHint = `Resume without rebuilding or uploading: pnpm --dir native release:macos --resume ${submissionId}`;
log(`Apple submission: ${submissionId}. ${resumeHint}`);
writeFileSync(
join(
targetDirectory,
`notarization-${submissionId}.submission.json`,
),
JSON.stringify(
{
submissionId,
version: config.version,
target,
cdhash,
},
null,
2,
) + '\n',
);
let result;
try {
result = JSON.parse(
step('Waiting for Apple notarization', 'xcrun', [
'notarytool',
'wait',
submissionId,
...notaryCredentials,
'--timeout',
'30m',
'--output-format',
'json',
]),
);
} catch (error) {
throw new Error(
`${error.message} Apple may still be processing this submission. ${resumeHint}`,
);
}
if (result.status !== 'Accepted') {
throw new Error(
`Apple did not accept the release (status: ${result.status ?? 'unknown'}, submission: ${submissionId}). Inspect it with notarytool log. ${resumeHint}`,
);
}
const notaryLog = JSON.parse(
step('Checking Apple notarization log', 'xcrun', [
'notarytool',
'log',
submissionId,
...notaryCredentials,
]),
);
writeFileSync(
join(targetDirectory, `notarization-${submissionId}.log.json`),
JSON.stringify(notaryLog, null, 2) + '\n',
);
const expectedArch =
target === 'aarch64-apple-darwin' ? 'arm64' : 'x86_64';
if (
notaryLog.jobId !== submissionId ||
notaryLog.status !== 'Accepted' ||
!notaryLog.ticketContents?.some(
(ticket) =>
ticket.path.endsWith('/KanbanGit.app') &&
ticket.cdhash === cdhash &&
ticket.arch === expectedArch,
)
) {
throw new Error(
'The accepted Apple submission does not match this signed app and architecture. Restore the submitted app or create a new release.',
);
}
if (notaryLog.issues?.length) {
throw new Error(
`Apple reported notarization issues. Review src-tauri/target/notarization-${submissionId}.log.json before distribution.`,
);
}
step('Stapling notarization ticket', 'xcrun', [
'stapler',
'staple',
app,
]);
step('Validating notarization ticket', 'xcrun', [
'stapler',
'validate',
app,
]);
step('Verifying final app signature', 'codesign', [
'--verify',
'--deep',
'--strict',
app,
]);
step('Checking Gatekeeper acceptance', 'spctl', [
'--assess',
'--type',
'execute',
'--verbose=2',
app,
]);
const name = `KanbanGit-${config.version}-${target}.dmg`;
const archive = join(staging, name);
createDiskImage({
app,
archive,
identity,
run: (command, args) =>
step('Packaging notarized download', command, args),
});
const checksum = createHash('sha256')
.update(readFileSync(archive))
.digest('hex');
writeFileSync(`${archive}.sha256`, `${checksum} ${name}\n`);
const updaterName = `KanbanGit-${config.version}-${target}.app.tar.gz`;
const updaterArchive = join(staging, updaterName);
step('Packaging updater archive', 'tar', [
'-czf',
updaterArchive,
'-C',
dirname(app),
'KanbanGit.app',
]);
step('Signing updater archive', 'pnpm', [
'exec',
'tauri',
'signer',
'sign',
updaterArchive,
]);
const updaterChecksum = createHash('sha256')
.update(readFileSync(updaterArchive))
.digest('hex');
writeFileSync(
`${updaterArchive}.sha256`,
`${updaterChecksum} ${updaterName}\n`,
);
rmSync(submission, { force: true });
renameSync(staging, destination);
log(`Verified release: ${join(destination, name)}`);
return {
archive: join(destination, name),
checksum,
version: config.version,
target,
};
} finally {
rmSync(staging, { recursive: true, force: true });
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
try {
const { values } = parseArgs({
options: { resume: { type: 'string' } },
});
releaseMacos({ resumeSubmissionId: values.resume ?? null });
} catch (error) {
console.error(`macOS release failed: ${error.message}`);
process.exitCode = 1;
}
}
+570 -38
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.1.0" version = "0.2.8"
description = "KanbanGit native application" description = "KanbanGit native application"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -13,6 +13,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
[dev-dependencies] [dev-dependencies]
@@ -27,3 +30,11 @@ custom-protocol = ["tauri/custom-protocol"]
name = "macos_smoke" name = "macos_smoke"
harness = false harness = false
required-features = ["smoke-test"] required-features = ["smoke-test"]
[target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-updater = "=2.12.0"
[[test]]
name = "updater_smoke"
harness = false
required-features = ["smoke-test"]
+71
View File
@@ -1,3 +1,74 @@
fn main() { fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
build_navigation();
}
tauri_build::build(); tauri_build::build();
} }
fn build_navigation() {
use std::{env, path::PathBuf, process::Command};
println!("cargo:rerun-if-changed=src/navigation.m");
println!("cargo:rerun-if-changed=src/settings.m");
let output = PathBuf::from(env::var_os("OUT_DIR").unwrap());
let object = output.join("navigation.o");
let settings_object = output.join("settings.o");
let archive = output.join("libkanban_navigation.a");
let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() {
"aarch64" => "arm64",
"x86_64" => "x86_64",
_ => panic!("Unsupported macOS architecture"),
};
assert!(Command::new("xcrun")
.args([
"--sdk",
"macosx",
"clang",
"-fobjc-arc",
"-fblocks",
"-Wall",
"-Werror",
"-mmacosx-version-min=14.0",
"-arch",
arch,
"-c",
"src/navigation.m",
"-o"
])
.arg(&object)
.status()
.expect("Xcode clang is required")
.success());
assert!(Command::new("xcrun")
.args([
"--sdk",
"macosx",
"clang",
"-fobjc-arc",
"-fblocks",
"-Wall",
"-Werror",
"-mmacosx-version-min=14.0",
"-arch",
arch,
"-c",
"src/settings.m",
"-o"
])
.arg(&settings_object)
.status()
.expect("Xcode clang is required")
.success());
assert!(Command::new("xcrun")
.args(["ar", "crs"])
.arg(&archive)
.arg(&object)
.arg(&settings_object)
.status()
.expect("Xcode ar is required")
.success());
println!("cargo:rustc-link-search=native={}", output.display());
println!("cargo:rustc-link-lib=static=kanban_navigation");
println!("cargo:rustc-link-lib=framework=AppKit");
println!("cargo:rustc-link-lib=framework=WebKit");
}
@@ -5,6 +5,7 @@ import android.os.SystemClock
import android.view.InputDevice import android.view.InputDevice
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebView import android.webkit.WebView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -59,6 +60,59 @@ class PlatformTest {
instrumentation.waitForIdleSync() instrumentation.waitForIdleSync()
} }
@Test fun coldStartRestoresWorkspaceWithoutSelectionOrLauncherHistory() {
val mode = InstrumentationRegistry.getArguments().getString("startupConfig", "saved")
val preferences = instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE)
preferences.edit().clear().apply {
if (mode == "saved") putString("server", "https://127.0.0.1:9/app/")
if (mode == "invalid") putString("server", "http://invalid.example/")
}.commit()
val scenario = ActivityScenario.launch(MainActivity::class.java)
val launcher = AtomicReference<WebView>()
fun findLauncher(view: View): WebView? {
if (view is WebView && view.url?.startsWith(NavigationPolicy.LAUNCHER_ORIGIN) == true) return view
if (view is ViewGroup) for (index in 0 until view.childCount) {
findLauncher(view.getChildAt(index))?.let { return it }
}
return null
}
waitFor {
scenario.onActivity { launcher.set(findLauncher(it.window.decorView)) }
launcher.get() != null && evaluate(launcher.get(), "typeof window.updateLauncher === 'function'") == "true"
}
if (mode == "saved") {
waitFor {
val state = evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state")
if (state == "\"connecting\"") {
assertEquals("true", evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden"))
}
state == "\"connectionError\""
}
assertEquals("https://127.0.0.1:9/app/", preferences.getString("server", null))
assertEquals("\"Try again\"", evaluate(launcher.get(), "document.querySelector('.open-workspace').textContent"))
scenario.onActivity {
assertEquals("https://127.0.0.1:9/app/dashboard", it.workspace?.originalUrl)
assertFalse(it.workspace!!.canGoBack())
it.openWorkspace("https://127.0.0.1:9/app/")
}
waitFor { evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state") == "\"connectionError\"" }
} else {
waitFor { evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden") == "false" }
scenario.onActivity { assertNull(it.workspace) }
}
scenario.onActivity { it.openWorkspace("https://kanbangit.de/") }
val remote = AtomicReference<WebView>()
waitFor {
var visible = false
scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE }
visible && remote.get() != null
}
assertEquals("\"/login\"", evaluate(remote.get(), "location.pathname"))
assertEquals("1", evaluate(remote.get(), "history.length"))
assertEquals("https://kanbangit.de/", preferences.getString("server", null))
scenario.moveToState(Lifecycle.State.CREATED)
}
@Test fun workspacePreservesFormsAndIsolatesServers() { @Test fun workspacePreservesFormsAndIsolatesServers() {
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit() instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
val scenario = ActivityScenario.launch(MainActivity::class.java) val scenario = ActivityScenario.launch(MainActivity::class.java)
@@ -1,6 +1,8 @@
package de.kanbangit.desktop package de.kanbangit.desktop
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
@@ -8,10 +10,12 @@ import android.net.http.SslError
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.Message
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.SslErrorHandler import android.webkit.SslErrorHandler
import android.webkit.ValueCallback
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebResourceError import android.webkit.WebResourceError
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
@@ -24,8 +28,10 @@ import android.widget.Button
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@@ -50,6 +56,47 @@ class MainActivity : TauriActivity() {
private var keyboardVisible = false private var keyboardVisible = false
private var initialized = false private var initialized = false
private var fileCallback: ValueCallback<Array<Uri>>? = null
private var fileWorkspace: WebView? = null
private var filePage: String? = null
private var multipleFiles = false
private var filePickerActive = false
private val popups = mutableSetOf<WebView>()
private val filePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
filePickerActive = false
val callback = fileCallback
val owner = fileWorkspace
val page = filePage
fileCallback = null
fileWorkspace = null
filePage = null
val files = WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data)
val valid = owner != null && owner === workspace && owner.url == page &&
NavigationPolicy.allows(server, page.orEmpty()) && files?.isNotEmpty() == true &&
(multipleFiles || files.size == 1) && files.all { uri ->
NavigationPolicy.selectedFile(uri.toString(), packageName) && runCatching {
contentResolver.openFileDescriptor(uri, "r")?.use { true } == true
}.getOrDefault(false)
}
callback?.onReceiveValue(if (valid) files else null)
}
private fun cancelFileSelection() {
fileCallback?.onReceiveValue(null)
fileCallback = null
fileWorkspace = null
filePage = null
}
private fun openExternal(url: String) {
if (!NavigationPolicy.external(url)) return
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE))
} catch (_: ActivityNotFoundException) {
Toast.makeText(this, R.string.external_link_error, Toast.LENGTH_LONG).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -117,9 +164,9 @@ class MainActivity : TauriActivity() {
if (initialized) return if (initialized) return
initialized = true initialized = true
val saved = preferences.getString("server", null) val saved = preferences.getString("server", null)
server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER val restored = saved?.let(NavigationPolicy::installation)
updateLauncher() server = restored ?: NavigationPolicy.DEFAULT_SERVER
if (saved != null) openWorkspace(server) if (restored != null) openWorkspace(restored) else updateLauncher()
} }
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty()) "/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
"/action/resume" -> if (ready) showWorkspace() "/action/resume" -> if (ready) showWorkspace()
@@ -144,6 +191,7 @@ class MainActivity : TauriActivity() {
showWorkspace() showWorkspace()
return return
} }
cancelFileSelection()
val changed = address != preferences.getString("server", null) val changed = address != preferences.getString("server", null)
val clearSession = changed || preferences.getBoolean("clearing", false) val clearSession = changed || preferences.getBoolean("clearing", false)
if (!preferences.edit().putString("server", address).putBoolean("clearing", clearSession).commit()) { if (!preferences.edit().putString("server", address).putBoolean("clearing", clearSession).commit()) {
@@ -187,18 +235,67 @@ class MainActivity : TauriActivity() {
javaScriptEnabled = true javaScriptEnabled = true
domStorageEnabled = true domStorageEnabled = true
allowFileAccess = false allowFileAccess = false
allowContentAccess = false allowContentAccess = true
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
javaScriptCanOpenWindowsAutomatically = false javaScriptCanOpenWindowsAutomatically = false
setSupportMultipleWindows(true) setSupportMultipleWindows(true)
} }
CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false) CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false)
remote.webChromeClient = WebChromeClient() remote.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(view: WebView, callback: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
if (view !== workspace || !NavigationPolicy.allows(server, view.url.orEmpty()) ||
params.mode !in listOf(FileChooserParams.MODE_OPEN, FileChooserParams.MODE_OPEN_MULTIPLE)) return false
// Keep one outstanding picker; stale results must never reach a different page.
if (filePickerActive) return false
fileCallback = callback
fileWorkspace = view
filePage = view.url
multipleFiles = params.mode == FileChooserParams.MODE_OPEN_MULTIPLE
return try {
filePickerActive = true
filePicker.launch(params.createIntent().addCategory(Intent.CATEGORY_OPENABLE))
true
} catch (_: ActivityNotFoundException) {
filePickerActive = false
cancelFileSelection()
Toast.makeText(this@MainActivity, R.string.file_picker_error, Toast.LENGTH_LONG).show()
true
}
}
override fun onCreateWindow(view: WebView, isDialog: Boolean, isUserGesture: Boolean, result: Message): Boolean {
if (view !== workspace || !isUserGesture || !NavigationPolicy.allows(server, view.url.orEmpty())) return false
val popup = WebView(this@MainActivity)
popups.add(popup)
fun dispose() {
if (popups.remove(popup)) popup.destroy()
}
popup.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(child: WebView, request: WebResourceRequest): Boolean {
if (request.isForMainFrame && workspace === view) {
val url = request.url.toString()
if (NavigationPolicy.allows(server, url)) view.loadUrl(url) else openExternal(url)
}
child.post { dispose() }
return true
}
}
(result.obj as WebView.WebViewTransport).webView = popup
result.sendToTarget()
handler.postDelayed({ dispose() }, 10000)
return true
}
}
remote.webViewClient = object : WebViewClient() { remote.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
!NavigationPolicy.allows(server, request.url.toString()) val url = request.url.toString()
if (NavigationPolicy.allows(server, url)) return false
if (workspace === view && request.isForMainFrame && request.hasGesture()) openExternal(url)
return true
}
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
if (workspace === view) cancelFileSelection()
if (!NavigationPolicy.allows(server, url)) { if (!NavigationPolicy.allows(server, url)) {
view.stopLoading() view.stopLoading()
fail(view) fail(view)
@@ -216,8 +313,8 @@ class MainActivity : TauriActivity() {
val firstLoad = !ready val firstLoad = !ready
ready = true ready = true
CookieManager.getInstance().flush() CookieManager.getInstance().flush()
updateLauncher()
if (firstLoad) showWorkspace() if (firstLoad) showWorkspace()
updateLauncher()
} }
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) { override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
@@ -236,7 +333,7 @@ class MainActivity : TauriActivity() {
remote.visibility = View.GONE remote.visibility = View.GONE
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1)) content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
startTimeout(remote) startTimeout(remote)
remote.loadUrl(server) remote.loadUrl(NavigationPolicy.dashboard(server))
} }
private fun startTimeout(view: WebView) { private fun startTimeout(view: WebView) {
@@ -299,6 +396,9 @@ class MainActivity : TauriActivity() {
override fun onDestroy() { override fun onDestroy() {
handler.removeCallbacksAndMessages(null) handler.removeCallbacksAndMessages(null)
cancelFileSelection()
popups.forEach { it.destroy() }
popups.clear()
workspace?.destroy() workspace?.destroy()
workspace = null workspace = null
super.onDestroy() super.onDestroy()
@@ -21,6 +21,8 @@ object NavigationPolicy {
"https://${uri.host.lowercase(Locale.ROOT)}$port$path" "https://${uri.host.lowercase(Locale.ROOT)}$port$path"
}.getOrNull() }.getOrNull()
fun dashboard(server: String): String = URI(requireNotNull(installation(server))).resolve("dashboard").toString()
fun allows(server: String, destination: String): Boolean = runCatching { fun allows(server: String, destination: String): Boolean = runCatching {
val base = URI(server) val base = URI(server)
val target = URI(destination) val target = URI(destination)
@@ -31,6 +33,21 @@ object NavigationPolicy {
target.rawUserInfo == null && !destination.contains('\\') target.rawUserInfo == null && !destination.contains('\\')
}.getOrDefault(false) }.getOrDefault(false)
fun external(destination: String): Boolean = runCatching {
val target = URI(destination)
target.scheme.lowercase(Locale.ROOT) in setOf("https", "http") &&
!target.host.isNullOrBlank() && target.rawUserInfo == null &&
(target.port == -1 || target.port in 1..65535) &&
destination.none { it.isISOControl() || it == '\\' }
}.getOrDefault(false)
fun selectedFile(destination: String, packageName: String): Boolean = runCatching {
val target = URI(destination)
target.scheme == "content" && !target.host.isNullOrBlank() &&
target.rawUserInfo == null && target.port == -1 &&
target.host != packageName && target.host != "$packageName.fileprovider"
}.getOrDefault(false)
fun backAction(keyboard: Boolean, launcher: Boolean, ready: Boolean, history: Boolean): String = when { fun backAction(keyboard: Boolean, launcher: Boolean, ready: Boolean, history: Boolean): String = when {
keyboard -> "keyboard" keyboard -> "keyboard"
launcher && ready -> "resume" launcher && ready -> "resume"
@@ -3,4 +3,6 @@
<string name="main_activity_title">"KanbanGit"</string> <string name="main_activity_title">"KanbanGit"</string>
<string name="server_settings">Server settings</string> <string name="server_settings">Server settings</string>
<string name="update_webview">Update Android System WebView to connect to your workspace.</string> <string name="update_webview">Update Android System WebView to connect to your workspace.</string>
<string name="external_link_error">No browser is available to open this link.</string>
<string name="file_picker_error">No file picker is available. Please try again.</string>
</resources> </resources>
@@ -12,6 +12,11 @@ class NavigationPolicyTest {
} }
} }
@Test fun startupTargetsDashboardWithinTheInstallation() {
assertEquals("https://example.com/dashboard", NavigationPolicy.dashboard("https://example.com/"))
assertEquals("https://example.com:8443/kanban/dashboard", NavigationPolicy.dashboard("https://example.com:8443/kanban/"))
}
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() { @Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
val server = "https://example.com:8443/app/" val server = "https://example.com:8443/app/"
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) { for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
@@ -23,6 +28,24 @@ class NavigationPolicyTest {
assertTrue(NavigationPolicy.allows("https://example.com/", "https://example.com:443/login")) assertTrue(NavigationPolicy.allows("https://example.com/", "https://example.com:443/login"))
} }
@Test fun externalLinksNeverBecomeServerNavigation() {
val server = "https://example.com/"
for (url in listOf("https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other")) {
assertTrue(url, NavigationPolicy.external(url))
assertFalse(url, NavigationPolicy.allows(server, url))
}
for (url in listOf("https://user:password@git.example.com/", "https://example.com:99999/", "javascript:alert(1)", "intent://example.com/", "file:///etc/passwd", "content://provider/file", "data:text/html,test", "mailto:user@example.com", "https://example.com\\evil")) {
assertFalse(url, NavigationPolicy.external(url))
}
}
@Test fun filePickerAcceptsOnlyExternalContentProviders() {
assertTrue(NavigationPolicy.selectedFile("content://com.android.providers.media.documents/document/image%3A1", "de.kanbangit.desktop"))
for (url in listOf("file:///data/data/de.kanbangit.desktop/session", "https://example.com/avatar.png", "content://de.kanbangit.desktop.fileprovider/private/key", "content://de.kanbangit.desktop/private/key", "content://user@provider/path", "content://provider:123/path", "content:/missing-provider")) {
assertFalse(url, NavigationPolicy.selectedFile(url, "de.kanbangit.desktop"))
}
}
@Test fun backClosesKeyboardBeforeNavigatingAndPreservesTheWorkspace() { @Test fun backClosesKeyboardBeforeNavigatingAndPreservesTheWorkspace() {
assertEquals("keyboard", NavigationPolicy.backAction(true, false, true, true)) assertEquals("keyboard", NavigationPolicy.backAction(true, false, true, true))
assertEquals("resume", NavigationPolicy.backAction(false, true, true, true)) assertEquals("resume", NavigationPolicy.backAction(false, true, true, true))
+3
View File
@@ -0,0 +1,3 @@
xcuserdata/
build/
Externals/
Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,116 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "AppIcon-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "AppIcon-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "AppIcon-29x29@2x-1.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "AppIcon-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "AppIcon-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "AppIcon-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "AppIcon-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "AppIcon-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "AppIcon-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "AppIcon-20x20@2x-1.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "AppIcon-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "AppIcon-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "AppIcon-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "AppIcon-40x40@2x-1.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "AppIcon-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "AppIcon-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "AppIcon-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "AppIcon-512@2x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17150" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17122"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
+14
View File
@@ -0,0 +1,14 @@
platform :ios, '17.0'
target 'kanbangit-native_iOS' do
# Pods for kanbangit-native_iOS
end
# Inherit the deployment target from the Podfile.
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET'
end
end
end
@@ -0,0 +1,96 @@
import Foundation
struct WorkspaceServer: Codable, Equatable {
let url: URL
let profile: UUID
static func normalize(_ input: String) -> URL? {
let input = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard input.lowercased().hasPrefix("https://"),
!input.contains("\\"),
input.rangeOfCharacter(from: .controlCharacters) == nil,
var components = URLComponents(string: input),
components.scheme?.lowercased() == "https",
let host = components.host, !host.isEmpty,
components.user == nil, components.password == nil,
components.query == nil, components.fragment == nil,
components.port.map({ (1...65535).contains($0) }) ?? true else { return nil }
components.scheme = "https"
components.host = host.lowercased()
if components.port == 443 { components.port = nil }
if !components.path.hasSuffix("/") { components.path += "/" }
return components.url
}
var dashboardURL: URL { url.appendingPathComponent("dashboard") }
func allows(_ destination: URL) -> Bool {
destination.scheme?.lowercased() == "https"
&& destination.host?.lowercased() == url.host?.lowercased()
&& (destination.port ?? 443) == (url.port ?? 443)
&& destination.user == nil && destination.password == nil
}
static func allowsExternal(_ destination: URL) -> Bool {
["https", "http"].contains(destination.scheme?.lowercased() ?? "")
&& destination.host?.isEmpty == false
&& (destination.port.map({ (1...65535).contains($0) }) ?? true)
&& destination.user == nil && destination.password == nil
}
static func selecting(_ url: URL, current: WorkspaceServer?) -> WorkspaceServer {
current?.url == url ? current! : WorkspaceServer(url: url, profile: UUID())
}
}
struct WorkspaceConnection {
private(set) var revision = 0
private(set) var ready = false
private(set) var connecting = false
mutating func begin() -> Int {
revision += 1
connecting = true
return revision
}
mutating func finish() {
connecting = false
ready = true
}
mutating func fail(_ expectedRevision: Int) -> Bool {
guard expectedRevision == revision, connecting else { return false }
connecting = false
return true
}
}
enum NativeSettingsError: Error { case invalidRequest, incompatibleVersion, invalidServer }
struct NativeSettingsRequest {
let id: Int
let action: String
let payload: [String: Any]
static func parse(_ body: String) throws -> NativeSettingsRequest {
guard body.utf8.count <= 8192,
let value = try JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any],
Set(value.keys) == Set(["version", "id", "action", "payload"]),
let version = value["version"] as? NSNumber, CFGetTypeID(version) != CFBooleanGetTypeID(),
version == 1 else { throw NativeSettingsError.incompatibleVersion }
guard let id = value["id"] as? NSNumber, CFGetTypeID(id) != CFBooleanGetTypeID(),
id.doubleValue >= 0, id.doubleValue <= 9007199254740991, id.doubleValue.rounded() == id.doubleValue,
let action = value["action"] as? String,
let payload = value["payload"] as? [String: Any] else { throw NativeSettingsError.invalidRequest }
switch action {
case "getState", "settingsReady", "openSettings":
guard payload.isEmpty else { throw NativeSettingsError.invalidRequest }
case "setWorkspace":
guard Set(payload.keys) == Set(["server"]), payload["server"] is String else { throw NativeSettingsError.invalidRequest }
default: throw NativeSettingsError.invalidRequest
}
return NativeSettingsRequest(id: id.intValue, action: action, payload: payload)
}
}
@@ -0,0 +1,436 @@
import UIKit
import WebKit
@_cdecl("install_ios_workspace")
func installIOSWorkspace() {
DispatchQueue.main.async {
guard let root = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene }).flatMap(\.windows)
.first(where: { $0.isKeyWindow })?.rootViewController
?? UIApplication.shared.windows.first?.rootViewController else { return }
guard !root.children.contains(where: { $0 is WorkspaceViewController }) else { return }
let controller = WorkspaceViewController()
root.view.subviews.forEach { $0.isHidden = true }
root.addChild(controller)
root.view.addSubview(controller.view)
controller.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
controller.view.topAnchor.constraint(equalTo: root.view.topAnchor),
controller.view.bottomAnchor.constraint(equalTo: root.view.bottomAnchor),
controller.view.leadingAnchor.constraint(equalTo: root.view.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: root.view.trailingAnchor),
])
controller.didMove(toParent: root)
}
}
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate {
private let defaults: UserDefaults
private let serverKey = "ios.activeServer"
private(set) var server: WorkspaceServer?
private(set) var workspace: WKWebView?
private var connection = WorkspaceConnection()
private var pendingNavigation: WKNavigation?
private var timeout: DispatchWorkItem?
private let loading = UIActivityIndicatorView(style: .large)
private let launcher = UIScrollView()
private let stack = UIStackView()
private let address = UITextField()
private let status = UILabel()
private let connectButton = UIButton(type: .system)
private let resumeButton = UIButton(type: .system)
private let toolbar = UIToolbar()
private var settingsAvailable = false
private var toolbarHeight: NSLayoutConstraint!
private var appearance: UISegmentedControl!
private var javaScriptConfirmation: (alert: UIAlertController, completion: (Bool) -> Void)?
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
deinit { javaScriptConfirmation?.completion(false) }
private func text(_ key: String) -> String {
NSLocalizedString(key, comment: "KanbanGit iOS workspace")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1)
loading.accessibilityIdentifier = "workspaceLoading"
loading.accessibilityLabel = text("connecting")
loading.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(loading)
NSLayoutConstraint.activate([
loading.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loading.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
configureLauncher()
configureToolbar()
if let data = defaults.data(forKey: serverKey) {
if let saved = try? JSONDecoder().decode(WorkspaceServer.self, from: data),
WorkspaceServer.normalize(saved.url.absoluteString) == saved.url {
server = saved
} else {
status.text = text("storageError")
}
}
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
if let server { open(server.url) } else { showSettings() }
}
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
let label = UILabel()
label.text = text(key)
label.font = .preferredFont(forTextStyle: style)
label.adjustsFontForContentSizeCategory = true
label.numberOfLines = 0
return label
}
private func configureLauncher() {
launcher.isHidden = true
launcher.accessibilityIdentifier = "serverSettings"
launcher.keyboardDismissMode = .interactive
launcher.bounces = false
view.addSubview(launcher)
launcher.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
launcher.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
launcher.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
launcher.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
launcher.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
])
stack.axis = .vertical
stack.spacing = 18
stack.translatesAutoresizingMaskIntoConstraints = false
launcher.addSubview(stack)
let width = stack.widthAnchor.constraint(equalTo: launcher.frameLayoutGuide.widthAnchor, constant: -40)
width.priority = .defaultHigh
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: launcher.contentLayoutGuide.topAnchor, constant: 32),
stack.bottomAnchor.constraint(equalTo: launcher.contentLayoutGuide.bottomAnchor, constant: -32),
stack.centerXAnchor.constraint(equalTo: launcher.frameLayoutGuide.centerXAnchor),
stack.leadingAnchor.constraint(greaterThanOrEqualTo: launcher.contentLayoutGuide.leadingAnchor, constant: 20),
stack.trailingAnchor.constraint(lessThanOrEqualTo: launcher.contentLayoutGuide.trailingAnchor, constant: -20),
stack.widthAnchor.constraint(lessThanOrEqualToConstant: 480), width,
])
stack.addArrangedSubview(label("appName", style: .headline))
stack.addArrangedSubview(label("title", style: .largeTitle))
stack.addArrangedSubview(label("description"))
stack.addArrangedSubview(label("serverLabel", style: .headline))
address.borderStyle = .roundedRect
address.keyboardType = .URL
address.textContentType = .URL
address.autocorrectionType = .no
address.autocapitalizationType = .none
address.clearButtonMode = .whileEditing
address.returnKeyType = .go
address.delegate = self
address.accessibilityLabel = text("serverLabel")
address.accessibilityIdentifier = "serverAddress"
address.heightAnchor.constraint(greaterThanOrEqualToConstant: 48).isActive = true
stack.addArrangedSubview(address)
stack.addArrangedSubview(label("serverHelp", style: .footnote))
connectButton.configuration = .filled()
connectButton.setTitle(text("openWorkspace"), for: .normal)
connectButton.accessibilityIdentifier = "openWorkspace"
connectButton.addTarget(self, action: #selector(connect), for: .touchUpInside)
stack.addArrangedSubview(connectButton)
status.numberOfLines = 0
status.accessibilityIdentifier = "connectionStatus"
status.font = .preferredFont(forTextStyle: .body)
status.adjustsFontForContentSizeCategory = true
stack.addArrangedSubview(status)
resumeButton.setTitle(text("resume"), for: .normal)
resumeButton.accessibilityIdentifier = "resumeWorkspace"
resumeButton.addTarget(self, action: #selector(showWorkspace), for: .touchUpInside)
stack.addArrangedSubview(resumeButton)
stack.addArrangedSubview(label("appearance", style: .headline))
appearance = UISegmentedControl(items: [text("system"), text("light"), text("dark")])
appearance.selectedSegmentIndex = min(2, max(0, defaults.integer(forKey: "ios.appearance")))
appearance.accessibilityIdentifier = "appearance"
appearance.addTarget(self, action: #selector(changeAppearance), for: .valueChanged)
stack.addArrangedSubview(appearance)
[connectButton, resumeButton, appearance].forEach {
$0.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
}
changeAppearance()
}
private func configureToolbar() {
toolbar.isHidden = true
toolbar.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(toolbar)
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
settings.accessibilityIdentifier = "showServerSettings"
toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings]
toolbarHeight = toolbar.heightAnchor.constraint(equalToConstant: 44)
NSLayoutConstraint.activate([
toolbarHeight,
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
])
}
@objc private func changeAppearance() {
defaults.set(appearance.selectedSegmentIndex, forKey: "ios.appearance")
overrideUserInterfaceStyle = [.unspecified, .light, .dark][appearance.selectedSegmentIndex]
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool { connect(); return true }
@objc private func connect() {
guard let url = WorkspaceServer.normalize(address.text ?? "") else {
status.text = text("invalidServer")
UIAccessibility.post(notification: .announcement, argument: status.text)
return
}
if let server, server.url != url {
let alert = UIAlertController(title: text("confirmSwitch"), message: text("switchWarning"), preferredStyle: .alert)
alert.addAction(UIAlertAction(title: text("cancel"), style: .cancel))
alert.addAction(UIAlertAction(title: text("confirmSwitch"), style: .destructive) { [weak self] _ in self?.open(url) })
present(alert, animated: true)
} else { open(url) }
}
func open(_ url: URL) {
if server?.url == url, connection.ready { showWorkspace(); return }
completeJavaScriptConfirmation(false)
let selected = WorkspaceServer.selecting(url, current: server)
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
defaults.set(data, forKey: serverKey)
timeout?.cancel()
workspace?.stopLoading()
workspace?.navigationDelegate = nil
workspace?.configuration.userContentController.removeScriptMessageHandler(forName: "kanbanSettings", contentWorld: .page)
workspace?.removeFromSuperview()
connection = WorkspaceConnection()
server = selected
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile)
let scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")!
let script = try! String(contentsOf: scriptURL, encoding: .utf8)
configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
let settingsURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-settings", withExtension: "js")!
let settingsScript = try! String(contentsOf: settingsURL, encoding: .utf8)
configuration.userContentController.addUserScript(WKUserScript(source: settingsScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
configuration.userContentController.addScriptMessageHandler(NativeSettingsHandler(controller: self), contentWorld: .page, name: "kanbanSettings")
setSettingsAvailable(false)
let webview = WKWebView(frame: .zero, configuration: configuration)
webview.navigationDelegate = self
webview.uiDelegate = self
webview.allowsBackForwardNavigationGestures = true
webview.scrollView.bounces = false
webview.accessibilityIdentifier = "workspace"
webview.translatesAutoresizingMaskIntoConstraints = false
webview.isHidden = true
view.insertSubview(webview, belowSubview: launcher)
NSLayoutConstraint.activate([
webview.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
webview.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
webview.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
])
workspace = webview
view.endEditing(true)
address.text = url.absoluteString
launcher.isHidden = true
toolbar.isHidden = true
loading.startAnimating()
beginNavigation()
pendingNavigation = webview.load(URLRequest(url: selected.dashboardURL))
}
private func beginNavigation() {
timeout?.cancel()
let revision = connection.begin()
status.text = text("connecting")
connectButton.isEnabled = false
resumeButton.isHidden = !connection.ready
let work = DispatchWorkItem { [weak self] in self?.fail(revision) }
timeout = work
DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: work)
}
private func fail(_ revision: Int) {
guard connection.fail(revision) else { return }
timeout?.cancel()
status.text = text("connectionError")
connectButton.isEnabled = true
connectButton.setTitle(text("retry"), for: .normal)
showSettings()
}
@objc private func showSettings() {
completeJavaScriptConfirmation(false)
loading.stopAnimating()
view.endEditing(true)
launcher.isHidden = false
toolbar.isHidden = true
workspace?.isHidden = true
resumeButton.isHidden = !connection.ready
}
@objc private func showWorkspace() {
guard connection.ready else { return }
loading.stopAnimating()
view.endEditing(true)
launcher.isHidden = true
toolbar.isHidden = settingsAvailable
workspace?.isHidden = false
}
private func setSettingsAvailable(_ available: Bool) {
settingsAvailable = available
toolbarHeight.constant = available ? 0 : 44
toolbar.isHidden = available || !launcher.isHidden || !connection.ready
}
func handleSettings(_ message: WKScriptMessage, reply: @escaping (Any?, String?) -> Void) {
guard message.webView === workspace, message.frameInfo.isMainFrame,
let source = message.frameInfo.request.url, server?.allows(source) == true,
let current = workspace?.url, server?.allows(current) == true,
let body = message.body as? String else { reply(nil, "untrustedFrame"); return }
do {
let request = try NativeSettingsRequest.parse(body)
var result: Any = NSNull()
switch request.action {
case "getState":
result = ["apiVersion": 1, "platform": "ios", "workspace": server!.url.absoluteString,
"version": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown",
"capabilities": ["workspace"]] as [String: Any]
case "settingsReady": setSettingsAvailable(true)
case "openSettings": showSettings()
case "setWorkspace":
guard let address = request.payload["server"] as? String,
let url = WorkspaceServer.normalize(address) else { throw NativeSettingsError.invalidServer }
let owner = workspace
DispatchQueue.main.async { [weak self] in
guard let self, self.workspace === owner else { return }
self.open(url)
}
default: throw NativeSettingsError.invalidRequest
}
let data = try JSONSerialization.data(withJSONObject: ["id": request.id, "result": result])
reply(String(decoding: data, as: UTF8.self), nil)
} catch {
reply(nil, String(describing: error))
}
}
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
guard webView === workspace, viewIfLoaded?.window != nil,
presentedViewController == nil, javaScriptConfirmation == nil else {
completionHandler(false)
return
}
let alert = UIAlertController(title: frame.request.url?.host ?? text("appName"),
message: message, preferredStyle: .alert)
let cancel = UIAlertAction(title: text("cancel"), style: .cancel) { [weak self] _ in
self?.completeJavaScriptConfirmation(false)
}
alert.addAction(cancel)
alert.addAction(UIAlertAction(title: text("ok"), style: .default) { [weak self] _ in
self?.completeJavaScriptConfirmation(true)
})
alert.preferredAction = cancel
javaScriptConfirmation = (alert, completionHandler)
present(alert, animated: true)
}
func completeJavaScriptConfirmation(_ accepted: Bool) {
guard let confirmation = javaScriptConfirmation else { return }
javaScriptConfirmation = nil
if confirmation.alert.presentingViewController == nil {
confirmation.completion(accepted)
} else {
confirmation.alert.dismiss(animated: false) {
confirmation.completion(accepted)
}
}
}
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
guard server?.allows(url) == true else {
decisionHandler(.cancel)
if action.navigationType == .linkActivated,
action.sourceFrame.isMainFrame,
action.targetFrame == nil || action.targetFrame?.isMainFrame == true,
WorkspaceServer.allowsExternal(url) {
UIApplication.shared.open(url)
}
return
}
if action.targetFrame == nil {
decisionHandler(.cancel)
webView.load(action.request)
} else { decisionHandler(.allow) }
}
func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
guard webView === workspace, let url = response.response.url,
server?.allows(url) == true else { decisionHandler(.cancel); return }
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
guard webView === workspace else { return }
setSettingsAvailable(false)
pendingNavigation = navigation
beginNavigation()
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
guard webView === workspace, navigation === pendingNavigation, connection.connecting,
let url = webView.url, server?.allows(url) == true else { return }
let firstLoad = !connection.ready
timeout?.cancel()
connection.finish()
connectButton.isEnabled = true
connectButton.setTitle(text("openWorkspace"), for: .normal)
status.text = text("connected")
resumeButton.isHidden = false
if firstLoad { showWorkspace() }
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
navigationFailed(webView, navigation, error)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
navigationFailed(webView, navigation, error)
}
private func navigationFailed(_ webView: WKWebView, _ navigation: WKNavigation?, _ error: Error) {
guard webView === workspace, navigation === pendingNavigation,
(error as NSError).code != NSURLErrorCancelled else { return }
fail(connection.revision)
}
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
guard webView === workspace else { return }
connection = WorkspaceConnection()
status.text = text("processTerminated")
connectButton.isEnabled = true
showSettings()
}
}
private final class NativeSettingsHandler: NSObject, WKScriptMessageHandlerWithReply {
weak var controller: WorkspaceViewController?
init(controller: WorkspaceViewController) { self.controller = controller }
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage,
replyHandler: @escaping (Any?, String?) -> Void) {
guard let controller else { replyHandler(nil, "bridgeUnavailable"); return }
controller.handleSettings(message, reply: replyHandler)
}
}
@@ -0,0 +1,7 @@
#pragma once
namespace ffi {
extern "C" {
void start_app();
}
}
@@ -0,0 +1,2 @@
"NSCameraUsageDescription" = "Choose a profile picture using your camera.";
"NSPhotoLibraryUsageDescription" = "Choose a profile picture from your photo library.";
@@ -0,0 +1,24 @@
"appName" = "KanbanGit";
"title" = "Choose your workspace.";
"description" = "Connect to your KanbanGit installation over HTTPS.";
"serverLabel" = "Server address";
"serverHelp" = "Use the installation address without login details, a query or a fragment.";
"openWorkspace" = "Open workspace";
"retry" = "Try again";
"resume" = "Return to workspace";
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
"confirmSwitch" = "Switch server";
"cancel" = "Cancel";
"ok" = "OK";
"connecting" = "Connecting to your server…";
"connected" = "Your workspace is open. Use Server settings to change servers.";
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
"invalidServer" = "Enter a valid HTTPS installation address without login details, a query or a fragment.";
"storageError" = "The saved server setting could not be read. Enter your server address again.";
"processTerminated" = "iOS closed the workspace to free memory. Open it again to continue. Unsaved inputs may have been lost.";
"appearance" = "Appearance";
"system" = "System";
"light" = "Light";
"dark" = "Dark";
"back" = "Back";
"serverSettings" = "Server settings";
@@ -0,0 +1,36 @@
#include "bindings/bindings.h"
#import <UIKit/UIKit.h>
#include <cstdlib>
#include <unistd.h>
extern "C" void install_ios_workspace(void);
int main(int argc, char * argv[]) {
@autoreleasepool {
// Xcode mirrors os_log to stderr. Tauri 2.11 redirects stderr back to os_log,
// which can fill its pipe and block UIKit/XCTest. Keep Xcode's original streams.
const bool debuggerLogging = getenv("OS_ACTIVITY_DT_MODE") != nullptr
|| getenv("XCTestConfigurationFilePath") != nullptr;
__block int originalOut = debuggerLogging ? dup(STDOUT_FILENO) : -1;
__block int originalError = debuggerLogging ? dup(STDERR_FILENO) : -1;
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidBecomeActiveNotification
object:nil
queue:NSOperationQueue.mainQueue
usingBlock:^(NSNotification *notification) {
if (originalOut >= 0) {
dup2(originalOut, STDOUT_FILENO);
close(originalOut);
originalOut = -1;
}
if (originalError >= 0) {
dup2(originalError, STDERR_FILENO);
close(originalError);
originalError = -1;
}
install_ios_workspace();
}];
ffi::start_app();
return 0;
}
}
@@ -0,0 +1,410 @@
import XCTest
import WebKit
final class WorkspaceTests: XCTestCase {
func testServerValidationAndOriginBoundary() throws {
let url = try XCTUnwrap(WorkspaceServer.normalize(" HTTPS://Example.com:443/kanban "))
XCTAssertEqual(url.absoluteString, "https://example.com/kanban/")
let server = WorkspaceServer(url: url, profile: UUID())
for value in ["http://example.com/", "https://user:secret@example.com/", "https://example.com/?token=1", "https://example.com/#x", "https://exa\nmple.com", "https://example.com\\evil", "file:///etc/passwd", "javascript:alert(1)", "https://example.com:99999/"] {
XCTAssertNil(WorkspaceServer.normalize(value), value)
}
for value in ["https://example.com/login", "https://example.com/two-factor-challenge", "https://example.com/projects/1?ticket=54#details"] {
XCTAssertTrue(server.allows(try XCTUnwrap(URL(string: value))), value)
}
for value in ["http://example.com/", "https://example.com:8443/", "https://evil.example.com/", "https://user@example.com/", "file:///tmp/index.html", "tauri://localhost/action/open"] {
XCTAssertFalse(server.allows(try XCTUnwrap(URL(string: value))), value)
}
}
func testDashboardAddressPreservesTheInstallationPath() throws {
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.com:8443/kanban/"))
XCTAssertEqual(WorkspaceServer(url: url, profile: UUID()).dashboardURL.absoluteString,
"https://example.com:8443/kanban/dashboard")
}
@MainActor
func testColdStartHidesSelectionUntilFailureAndPreservesSavedWorkspace() throws {
let name = "KAN69-startup-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let saved = WorkspaceServer(url: URL(string: "https://example.invalid/kanban/")!, profile: UUID())
let data = try JSONEncoder().encode(saved)
defaults.set(data, forKey: "ios.activeServer")
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
let loading = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "workspaceLoading" } as? UIActivityIndicatorView)
XCTAssertTrue(launcher.isHidden)
XCTAssertTrue(loading.isAnimating)
XCTAssertEqual(controller.server, saved)
let webview = try XCTUnwrap(controller.workspace)
XCTAssertFalse(webview.canGoBack)
XCTAssertTrue(webview.backForwardList.backList.isEmpty)
webview.stopLoading()
let navigation = try XCTUnwrap(webview.loadSimulatedRequest(URLRequest(url: saved.dashboardURL), responseHTML: "<html></html>"))
controller.webView(webview, didStartProvisionalNavigation: navigation)
XCTAssertTrue(launcher.isHidden)
controller.webView(webview, didFailProvisionalNavigation: navigation,
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
XCTAssertFalse(launcher.isHidden)
XCTAssertFalse(loading.isAnimating)
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: XCTUnwrap(defaults.data(forKey: "ios.activeServer"))), saved)
controller.webView(webview, didFinish: navigation)
XCTAssertFalse(launcher.isHidden, "A late finish must not hide recovery after a failure")
controller.open(saved.url)
XCTAssertTrue(launcher.isHidden)
XCTAssertTrue(loading.isAnimating)
XCTAssertEqual(controller.server?.profile, saved.profile)
}
@MainActor
func testMissingOrInvalidConfigurationShowsSetupAndServerChangesAreRestored() throws {
let name = "KAN69-setup-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
for data in [nil, Data("invalid".utf8), try JSONEncoder().encode(WorkspaceServer(url: URL(string: "http://example.com/")!, profile: UUID()))] {
defaults.set(data, forKey: "ios.activeServer")
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
XCTAssertNil(controller.workspace)
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
XCTAssertFalse(launcher.isHidden)
}
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
controller.open(URL(string: "https://first.invalid/")!)
controller.open(URL(string: "https://second.invalid/app/")!)
let restored = WorkspaceViewController(defaults: defaults)
restored.loadViewIfNeeded()
XCTAssertEqual(restored.server, controller.server)
XCTAssertEqual(restored.server?.dashboardURL.absoluteString, "https://second.invalid/app/dashboard")
XCTAssertTrue(restored.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }?.isHidden == true)
}
func testExternalLinksStayOutsideTheSelectedServer() throws {
let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID())
for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] {
let url = try XCTUnwrap(URL(string: value))
XCTAssertTrue(WorkspaceServer.allowsExternal(url))
XCTAssertFalse(server.allows(url))
}
for value in ["https://user:secret@git.example.com/", "https://example.com:99999/", "javascript:alert(1)", "file:///etc/passwd", "tauri://localhost/action/open", "mailto:user@example.com", "data:text/html,test"] {
XCTAssertFalse(WorkspaceServer.allowsExternal(try XCTUnwrap(URL(string: value))), value)
}
}
@MainActor
func testWebKitOwnsInteractiveGesturesAndKeepsScrollBounds() async throws {
let name = "KAN71-history-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
let url = URL(string: "https://example.invalid/dashboard")!
controller.open(URL(string: "https://example.invalid/")!)
let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading()
webview.frame = controller.view.bounds
webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: """
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0"><p>Workspace</p><input id="draft" value="unsaved"></body></html>
""")
try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'")
XCTAssertFalse(webview.canGoBack)
XCTAssertNil(webview.goBack())
XCTAssertNil(webview.goForward())
try await waitFor(webview, "location.pathname === '/dashboard'")
XCTAssertTrue(webview.allowsBackForwardNavigationGestures)
XCTAssertFalse(webview.scrollView.bounces)
XCTAssertFalse(controller.responds(to: NSSelectorFromString("navigateGesture:")))
let customNavigation = try await webview.evaluateJavaScript("typeof window.__kanbanNavigation")
XCTAssertEqual(customNavigation as? String, "undefined")
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
XCTAssertEqual(draft as? String, "unsaved")
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" })
XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" })
let scroll = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorY")
XCTAssertEqual(scroll as? String, "none")
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
XCTAssertEqual(horizontal as? String, "auto")
controller.open(URL(string: "https://other.invalid/")!)
XCTAssertTrue(try XCTUnwrap(controller.workspace).allowsBackForwardNavigationGestures,
"Changing workspaces must retain native gesture navigation")
}
@MainActor
func testDirtyFormNavigationPresentsNativeConfirmation() async throws {
let animationsEnabled = UIView.areAnimationsEnabled
UIView.setAnimationsEnabled(false)
defer { UIView.setAnimationsEnabled(animationsEnabled) }
let name = "ios-confirmation-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = controller
window.makeKeyAndVisible()
defer { window.isHidden = true; window.rootViewController = nil }
let url = URL(string: "https://example.invalid/")!
controller.open(url)
let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading()
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("projects/create")), responseHTML: """
<html><body><input id="duplicate" type="radio"><input id="draft" value="Unsaved project">
<a id="back" href="#back">Back</a><script>
window.visits = 0;
document.querySelector('#back').onclick = event => {
event.preventDefault();
if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++;
};
</script></body></html>
""")
try await waitFor(webview, "document.readyState === 'complete' && typeof window.visits === 'number'")
_ = try await webview.evaluateJavaScript("document.querySelector('#back').click(); true")
let pristineVisits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(pristineVisits as? Int, 1)
XCTAssertNil(controller.presentedViewController)
_ = try await webview.evaluateJavaScript("document.querySelector('#duplicate').click(); true")
var expectedVisits = 1
for (index, accepted) in [false, true, false, true].enumerated() {
let click = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
let alert = try await waitForConfirmation(controller)
XCTAssertEqual(alert.title, "example.invalid")
XCTAssertEqual(alert.message, "Discard unsaved changes?")
XCTAssertEqual(alert.actions.map(\.style), [.cancel, .default])
XCTAssertTrue(alert.preferredAction === alert.actions.first, "The default action must preserve the draft")
if index == 3 {
await withCheckedContinuation { continuation in
alert.dismiss(animated: false) { continuation.resume() }
}
}
controller.completeJavaScriptConfirmation(accepted)
controller.completeJavaScriptConfirmation(!accepted)
_ = try await click.value
if accepted { expectedVisits += 1 }
let visits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(visits as? Int, expectedVisits, "Only accepted visits may run, exactly once each")
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
XCTAssertEqual(draft as? String, "Unsaved project")
XCTAssertNil(controller.presentedViewController)
}
let otherAlert = UIAlertController(title: "Already open", message: nil, preferredStyle: .alert)
controller.present(otherAlert, animated: false)
let blocked = try await webview.evaluateJavaScript("confirm('Another dialog')")
XCTAssertEqual(blocked as? Bool, false, "A blocked presentation must release JavaScript")
XCTAssertTrue(controller.presentedViewController === otherAlert)
await withCheckedContinuation { continuation in
otherAlert.dismiss(animated: false) { continuation.resume() }
}
let pendingClick = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
_ = try await waitForConfirmation(controller)
controller.open(URL(string: "https://other.invalid/")!)
_ = try await pendingClick.value
XCTAssertNil(controller.presentedViewController, "Replacing a workspace must dismiss its confirmation")
let oldVisits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(oldVisits as? Int, expectedVisits)
let stale = try await webview.evaluateJavaScript("confirm('Previous workspace')")
XCTAssertEqual(stale as? Bool, false, "A previous workspace must not present new confirmations")
XCTAssertNil(controller.presentedViewController)
}
@MainActor
private func waitForConfirmation(_ controller: WorkspaceViewController) async throws -> UIAlertController {
for _ in 0..<100 {
if let alert = controller.presentedViewController as? UIAlertController, !alert.isBeingPresented { return alert }
try await Task.sleep(for: .milliseconds(50))
}
return try XCTUnwrap(controller.presentedViewController as? UIAlertController,
"Dirty forms must offer a visible confirmation instead of silently cancelling navigation")
}
func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws {
for body in [
#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
#"{"version":true,"id":1,"action":"getState","payload":{}}"#,
#"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1"}}"#,
#"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":12}}"#,
#"{"version":1,"id":1,"action":"getState","payload":{"extra":1}}"#,
#"{"version":1,"id":1,"action":"shell","payload":{}}"#,
] { XCTAssertThrowsError(try NativeSettingsRequest.parse(body), body) }
XCTAssertThrowsError(try NativeSettingsRequest.parse(String(repeating: "x", count: 8193)))
XCTAssertEqual(try NativeSettingsRequest.parse(#"{"version":1,"id":2,"action":"getState","payload":{}}"#).id, 2)
}
@MainActor
func testSettingsBridgeHidesToolbarOnlyAfterHandshakeAndRejectsFramesAndOldWorkspaces() async throws {
let name = "KAN70-settings-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
let url = URL(string: "https://example.invalid/")!
controller.open(url)
let originalProfile = controller.server!.profile
let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading()
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("login")), responseHTML: "<html><body>Login<iframe srcdoc='<p>frame</p>'></iframe></body></html>")
try await waitFor(webview, "document.readyState === 'complete' && typeof window.kanbanNative === 'object'")
let state = try await callScript(webview, "return await window.kanbanNative.request('getState')") as? [String: Any]
XCTAssertEqual(state?["platform"] as? String, "ios")
XCTAssertEqual(state?["workspace"] as? String, url.absoluteString)
XCTAssertEqual(state?["capabilities"] as? [String], ["workspace"])
XCTAssertNil(state?["updates"])
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
XCTAssertFalse(toolbar.isHidden, "Older pages must retain native recovery")
let rejected = try await callScript(webview, """
try {
await window.frames[0].webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:2,action:'settingsReady',payload:{}}));
return false;
} catch { return true; }
""")
XCTAssertEqual(rejected as? Bool, true)
XCTAssertFalse(toolbar.isHidden)
_ = try await callScript(webview, "return await window.kanbanNative.request('settingsReady')")
controller.view.layoutIfNeeded()
XCTAssertTrue(toolbar.isHidden)
XCTAssertEqual(toolbar.frame.height, 0)
XCTAssertEqual(webview.frame.minY, controller.view.safeAreaLayoutGuide.layoutFrame.minY, accuracy: 1)
let invalid = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'http://bad.test'}); return false; } catch { return true; }")
XCTAssertEqual(invalid as? Bool, true)
XCTAssertEqual(controller.server?.url, url)
_ = try await callScript(webview, "return await window.kanbanNative.request('openSettings')")
XCTAssertFalse(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }!.isHidden)
_ = try await callScript(webview, "return await window.kanbanNative.request('setWorkspace', {server:'https://other.invalid/app/'})")
for _ in 0..<50 {
if controller.workspace !== webview { break }
try await Task.sleep(for: .milliseconds(20))
}
XCTAssertFalse(controller.workspace === webview)
XCTAssertNotEqual(controller.server?.profile, originalProfile)
let restored = WorkspaceViewController(defaults: defaults)
restored.loadViewIfNeeded()
XCTAssertEqual(restored.server?.url.absoluteString, "https://other.invalid/app/")
let stale = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'https://stale.invalid/'}); return false; } catch { return true; }")
XCTAssertEqual(stale as? Bool, true)
XCTAssertEqual(controller.server?.url.absoluteString, "https://other.invalid/app/")
}
@MainActor
private func callScript(_ webview: WKWebView, _ script: String) async throws -> Any {
try await withCheckedThrowingContinuation { continuation in
webview.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
continuation.resume(with: result)
}
}
}
@MainActor
private func waitFor(_ webview: WKWebView, _ expression: String) async throws {
for _ in 0..<100 {
if let result = try? await webview.evaluateJavaScript(expression), result as? Bool == true { return }
try await Task.sleep(for: .milliseconds(50))
}
XCTFail("WebView condition timed out: \(expression)")
}
func testServerChangesNeverReusePreviousProfile() throws {
let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/"))
let otherURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/other/"))
let first = WorkspaceServer.selecting(firstURL, current: nil)
XCTAssertEqual(WorkspaceServer.selecting(firstURL, current: first), first)
let second = WorkspaceServer.selecting(otherURL, current: first)
XCTAssertNotEqual(second.profile, first.profile)
XCTAssertNotEqual(WorkspaceServer.selecting(firstURL, current: second).profile, first.profile)
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: JSONEncoder().encode(first)), first)
}
func testTimeoutDoesNotDiscardLoadedWorkspaceOrAffectLaterNavigation() {
var state = WorkspaceConnection()
let first = state.begin()
let second = state.begin()
XCTAssertFalse(state.fail(first))
state.finish()
XCTAssertFalse(state.fail(second))
let third = state.begin()
XCTAssertTrue(state.fail(third))
XCTAssertTrue(state.ready)
XCTAssertFalse(state.fail(third))
}
@MainActor
func testWKWebViewPersistsCookiesAcrossViewsAndIsolatesServerChanges() async throws {
let profile = UUID()
let first = WKWebViewConfiguration()
first.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile)
let cookie = try XCTUnwrap(HTTPCookie(properties: [
.domain: "example.com", .path: "/", .name: "laravel_session", .value: "test-session",
.secure: "TRUE", .expires: Date().addingTimeInterval(3600),
]))
await first.websiteDataStore.httpCookieStore.setCookie(cookie)
let restored = WKWebViewConfiguration()
restored.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile)
let restoredCookies = await restored.websiteDataStore.httpCookieStore.allCookies()
XCTAssertTrue(restoredCookies.contains { $0.name == "laravel_session" && $0.value == "test-session" })
let other = WKWebsiteDataStore(forIdentifier: UUID())
let otherCookies = await other.httpCookieStore.allCookies()
XCTAssertTrue(otherCookies.isEmpty)
await restored.websiteDataStore.httpCookieStore.deleteCookie(cookie)
let loggedOut = await first.websiteDataStore.httpCookieStore.allCookies()
XCTAssertFalse(loggedOut.contains { $0.name == "laravel_session" })
}
@MainActor
func testControllerRestoresServerAndCreatesIsolatedUnprivilegedWebView() throws {
let name = "KAN54-tests-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
controller.open(url)
let server = try XCTUnwrap(controller.server)
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 2)
let restored = WorkspaceViewController(defaults: defaults)
restored.loadViewIfNeeded()
XCTAssertEqual(restored.server, server)
let other = try XCTUnwrap(WorkspaceServer.normalize("https://other.invalid/"))
restored.open(other)
XCTAssertNotEqual(restored.server?.profile, server.profile)
}
@MainActor
func testConnectionFailureKeepsLoadedFormAndAllowsResume() async throws {
let name = "KAN54-form-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
controller.open(url)
let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading()
let navigation = try XCTUnwrap(webview.loadHTMLString("<html><body><input id='draft' value='unsaved ticket'></body></html>", baseURL: url))
var loaded = false
for _ in 0..<50 {
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
value as? String == "unsaved ticket" { loaded = true; break }
try await Task.sleep(for: .milliseconds(100))
}
XCTAssertTrue(loaded)
controller.webView(webview, didFinish: navigation)
controller.webView(webview, didStartProvisionalNavigation: navigation)
controller.webView(webview, didFailProvisionalNavigation: navigation,
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
XCTAssertTrue(webview.isHidden)
controller.open(url)
XCTAssertTrue(controller.workspace === webview)
XCTAssertFalse(webview.isHidden)
let value = try await webview.evaluateJavaScript("document.getElementById('draft').value")
XCTAssertEqual(value as? String, "unsaved ticket")
let bridge = try await webview.evaluateJavaScript("typeof window.__TAURI_INTERNALS__")
XCTAssertEqual(bridge as? String, "undefined")
}
}
@@ -0,0 +1,83 @@
import XCTest
final class WorkspaceUITests: XCTestCase {
@MainActor
private func reveal(_ element: XCUIElement, in scrollView: XCUIElement) {
for _ in 0..<8 {
if scrollView.frame.contains(CGPoint(x: element.frame.midX, y: element.frame.midY)), element.isHittable { return }
let start = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
let direction: CGFloat = element.frame.midY > scrollView.frame.midY ? 0.2 : 0.8
let end = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: direction))
start.press(forDuration: 0.05, thenDragTo: end)
}
XCTAssertTrue(element.isHittable)
}
@MainActor
func testSavedWorkspaceColdStartNeverShowsSelectionBeforeRecovery() {
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
defer { XCUIDevice.shared.orientation = .portrait }
let app = XCUIApplication()
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
app.launch()
let settings = app.buttons["showServerSettings"]
if settings.waitForExistence(timeout: 3) { settings.tap() }
let address = app.textFields["serverAddress"]
XCTAssertTrue(address.waitForExistence(timeout: 20))
address.tap()
if address.buttons.firstMatch.exists { address.buttons.firstMatch.tap() }
address.typeText("https://127.0.0.1:9/")
app.buttons["openWorkspace"].tap()
let confirmation = app.alerts.buttons["Switch server"]
if confirmation.waitForExistence(timeout: 1) { confirmation.tap() }
XCTAssertTrue(app.staticTexts["connectionStatus"].waitForExistence(timeout: 20))
app.terminate()
app.launch()
// An unreachable saved server may fail immediately; only recovery may reveal setup.
for _ in 0..<30 {
if address.exists {
XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("server did not finish loading"))
break
}
XCTAssertFalse(app.staticTexts["Choose your workspace."].exists)
Thread.sleep(forTimeInterval: 0.1)
}
XCTAssertTrue(address.waitForExistence(timeout: 20))
XCTAssertEqual(address.value as? String, "https://127.0.0.1:9/")
XCTAssertTrue(app.buttons["openWorkspace"].label.contains("Try again"))
}
@MainActor
func testLauncherKeyboardValidationRotationAndAppearance() {
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
defer { XCUIDevice.shared.orientation = .portrait }
let app = XCUIApplication()
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
app.launch()
let settings = app.buttons["showServerSettings"]
if settings.waitForExistence(timeout: 3) { settings.tap() }
let address = app.textFields["serverAddress"]
XCTAssertTrue(address.waitForExistence(timeout: 15))
address.tap()
let clear = address.buttons.firstMatch
if clear.exists { clear.tap() }
address.typeText("http://invalid.example")
app.buttons["openWorkspace"].tap()
XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("valid HTTPS"))
XCUIDevice.shared.orientation = .landscapeLeft
let launcher = app.scrollViews["serverSettings"]
reveal(address, in: launcher)
let open = app.buttons["openWorkspace"]
reveal(open, in: launcher)
let dark = app.segmentedControls["appearance"].buttons["Dark"]
reveal(dark, in: launcher)
dark.tap()
XCTAssertTrue(dark.isSelected)
app.segmentedControls["appearance"].buttons["System"].tap()
XCUIDevice.shared.orientation = .portrait
}
}
@@ -0,0 +1,773 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */; };
5E764E27CA85A3746845F417 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8243D0C3821DAC731FECF105 /* UIKit.framework */; };
6514B0DB3A5CCCDF7385AC18 /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; };
70EBA05ACBA5402617A3371C /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */ = {isa = PBXBuildFile; fileRef = BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */; };
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; };
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */; };
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */ = {isa = PBXBuildFile; fileRef = BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */; };
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
remoteInfo = "kanbangit-native_iOS";
};
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
remoteInfo = "kanbangit-native_iOS";
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
01E10FB645809173AF1D4C2D /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = "<group>"; };
05A753935B6CB0316DA56D96 /* desktop.rs */ = {isa = PBXFileReference; path = desktop.rs; sourceTree = "<group>"; };
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; };
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
5F196E34D1B9E14FBD7191F4 /* settings.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = settings.m; sourceTree = "<group>"; };
5F71F06AB639140AB9714425 /* metadata.rs */ = {isa = PBXFileReference; path = metadata.rs; sourceTree = "<group>"; };
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<group>"; };
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-navigation.js"; sourceTree = "<group>"; };
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
8C0F963909020429B3D257D5 /* navigation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = navigation.m; sourceTree = "<group>"; };
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KanbanGit.app; sourceTree = BUILT_PRODUCTS_DIR; };
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; };
CA65E67046326EA008175045 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
CAF08AD60D2F29A879CDAEBB /* ios.rs */ = {isa = PBXFileReference; path = ios.rs; sourceTree = "<group>"; };
D13B8D854BEB7757AB95231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
D581BB90601D7A89F8F188E2 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; };
DE3B71153677F722CAC6DCF7 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
E8BA104C81071623E1FF367D /* server.rs */ = {isa = PBXFileReference; path = server.rs; sourceTree = "<group>"; };
ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUITests.swift; sourceTree = "<group>"; };
ED4469441C3E0F37B1123572 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceViewController.swift; sourceTree = "<group>"; };
F46963153D20DB21D97843E9 /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; };
F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
32E70C778E91CB2E8CD86715 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D8772A5E508092D6F831058A /* libapp.a in Frameworks */,
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */,
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */,
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */,
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */,
B939982700E800053A9C7663 /* Security.framework in Frameworks */,
5E764E27CA85A3746845F417 /* UIKit.framework in Frameworks */,
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
140A7DB7E2461E64B1FBFA70 /* Tests */ = {
isa = PBXGroup;
children = (
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */,
);
path = Tests;
sourceTree = "<group>";
};
157AF88201BDE4D99AA2E14C = {
isa = PBXGroup;
children = (
31630170C358EA8657E34854 /* Assets.xcassets */,
F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */,
4058655E6F37D054E25D1F37 /* kanbangit-native_iOS */,
8606F3EE5A736385061692EF /* Sources */,
335B1262A9019C70C7F887A5 /* src */,
140A7DB7E2461E64B1FBFA70 /* Tests */,
709BE6DF191B953724A55555 /* ui */,
3C9F31BB065F210267ECA390 /* UITests */,
3028C3786B437115B6486F1C /* Frameworks */,
59D2DE9FD94E6D2D64CB0117 /* Products */,
);
sourceTree = "<group>";
};
2A11A26F0082A5075CD710C6 /* bindings */ = {
isa = PBXGroup;
children = (
01E10FB645809173AF1D4C2D /* bindings.h */,
);
path = bindings;
sourceTree = "<group>";
};
3028C3786B437115B6486F1C /* Frameworks */ = {
isa = PBXGroup;
children = (
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */,
91991DFDB0C064B06DF854ED /* libapp.a */,
CA65E67046326EA008175045 /* Metal.framework */,
D581BB90601D7A89F8F188E2 /* MetalKit.framework */,
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */,
DE3B71153677F722CAC6DCF7 /* Security.framework */,
8243D0C3821DAC731FECF105 /* UIKit.framework */,
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
335B1262A9019C70C7F887A5 /* src */ = {
isa = PBXGroup;
children = (
9C56BE6890F0452F69D64795 /* android.rs */,
05A753935B6CB0316DA56D96 /* desktop.rs */,
CAF08AD60D2F29A879CDAEBB /* ios.rs */,
2B96972F58CC158AB3DFF94A /* lib.rs */,
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
7440C7F0426E6B3977DD1D94 /* main.rs */,
5F71F06AB639140AB9714425 /* metadata.rs */,
8C0F963909020429B3D257D5 /* navigation.m */,
E8BA104C81071623E1FF367D /* server.rs */,
5F196E34D1B9E14FBD7191F4 /* settings.m */,
1CDE5C303A96423E2E6A194D /* settings.rs */,
6610D376CF901AC1733D394E /* updates.rs */,
);
name = src;
path = ../../src;
sourceTree = "<group>";
};
3C9F31BB065F210267ECA390 /* UITests */ = {
isa = PBXGroup;
children = (
ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */,
);
path = UITests;
sourceTree = "<group>";
};
4058655E6F37D054E25D1F37 /* kanbangit-native_iOS */ = {
isa = PBXGroup;
children = (
D13B8D854BEB7757AB95231B /* Info.plist */,
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */,
);
path = "kanbangit-native_iOS";
sourceTree = "<group>";
};
59D2DE9FD94E6D2D64CB0117 /* Products */ = {
isa = PBXGroup;
children = (
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */,
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
709BE6DF191B953724A55555 /* ui */ = {
isa = PBXGroup;
children = (
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */,
);
name = ui;
path = ../../../ui;
sourceTree = "<group>";
};
8606F3EE5A736385061692EF /* Sources */ = {
isa = PBXGroup;
children = (
89AA023B8C28CB860513C685 /* kanbangit-native */,
);
path = Sources;
sourceTree = "<group>";
};
89AA023B8C28CB860513C685 /* kanbangit-native */ = {
isa = PBXGroup;
children = (
4C76059D31C50B198779A5F7 /* en.lproj */,
F46963153D20DB21D97843E9 /* main.mm */,
554AB059B4228085924F4959 /* WorkspacePolicy.swift */,
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */,
2A11A26F0082A5075CD710C6 /* bindings */,
2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */,
C8C02BC0E757897278EB954A /* Localizable.strings */,
);
path = "kanbangit-native";
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = F0C7EA9610FB69A5EA27B950 /* Build configuration list for PBXNativeTarget "kanbangit-native_iOS" */;
buildPhases = (
C74F06A7ECCC2C6C79C74A2D /* Build Rust Code */,
F39C45598E4345705F3820FA /* Sources */,
4A4C7FC1B57785DDD6B301FB /* Resources */,
32E70C778E91CB2E8CD86715 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = "kanbangit-native_iOS";
packageProductDependencies = (
);
productName = "kanbangit-native_iOS";
productReference = 9E29B2FA90E1D53F604961F6 /* KanbanGit.app */;
productType = "com.apple.product-type.application";
};
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = E6F418AEF58E5F3D739C68F1 /* Build configuration list for PBXNativeTarget "WorkspaceUITests" */;
buildPhases = (
0258B26407CD2D8C61079F84 /* Sources */,
);
buildRules = (
);
dependencies = (
5658F3B2915DC3A46531EDCE /* PBXTargetDependency */,
);
name = WorkspaceUITests;
packageProductDependencies = (
);
productName = WorkspaceUITests;
productReference = 9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
CBC4561F8018D37DD49A0598 /* WorkspaceTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
buildPhases = (
2A0467365B852D44D1A9FD5B /* Sources */,
DC3295DE0893151786FEA37B /* Resources */,
);
buildRules = (
);
dependencies = (
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */,
);
name = WorkspaceTests;
packageProductDependencies = (
);
productName = WorkspaceTests;
productReference = A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
AA6AD2A91A9E86D319D86D59 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
721CF221CEDDC28FF2C5EBD5 = {
DevelopmentTeam = ZZX2KZGTHU;
ProvisioningStyle = Manual;
};
988CFF304CB81C8F3A4D6612 = {
DevelopmentTeam = ZZX2KZGTHU;
ProvisioningStyle = Manual;
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
};
CBC4561F8018D37DD49A0598 = {
DevelopmentTeam = ZZX2KZGTHU;
ProvisioningStyle = Manual;
};
};
};
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 157AF88201BDE4D99AA2E14C;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 59D2DE9FD94E6D2D64CB0117 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */,
CBC4561F8018D37DD49A0598 /* WorkspaceTests */,
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
4A4C7FC1B57785DDD6B301FB /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */,
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */,
99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
DC3295DE0893151786FEA37B /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */,
EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
C74F06A7ECCC2C6C79C74A2D /* Build Rust Code */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Build Rust Code";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a",
"$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -eu\nif [ -n \"${KANBANGIT_IOS_LIBRARY:-}\" ]; then\n mkdir -p \"$SRCROOT/Externals/arm64/$CONFIGURATION\"\n cp \"$KANBANGIT_IOS_LIBRARY\" \"$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a\"\n exit 0\nfi\nexport PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR:-} ${ARCHS:?}\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
0258B26407CD2D8C61079F84 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
2A0467365B852D44D1A9FD5B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */,
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */,
6514B0DB3A5CCCDF7385AC18 /* WorkspaceViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
F39C45598E4345705F3820FA /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
70EBA05ACBA5402617A3371C /* WorkspacePolicy.swift in Sources */,
C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */,
333E7761EA87AE29CE04F1AC /* main.mm in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
5658F3B2915DC3A46531EDCE /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
};
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
targetProxy = 1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
47C531812A31207F97A89C5F /* en */,
);
name = InfoPlist.strings;
sourceTree = "<group>";
};
C8C02BC0E757897278EB954A /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
ED4469441C3E0F37B1123572 /* en */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
0C46162024E99BEFF872CABF /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ARCHS = (
arm64,
);
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\".\"",
);
INFOPLIST_FILE = "kanbangit-native_iOS/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
MARKETING_VERSION = 0.2.8;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
};
name = release;
};
390A27695F28BF3AA9A049C9 /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = debug;
};
5110A47C3B60906E18EE09D2 /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = "kanbangit-native_iOS";
};
name = debug;
};
C81211E40BBB158A79AEBE3D /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = release;
};
D41150B2CAE4512D04C26759 /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ARCHS = (
arm64,
);
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\".\"",
);
INFOPLIST_FILE = "kanbangit-native_iOS/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
MARKETING_VERSION = 0.2.8;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
};
name = debug;
};
EF104E75BD04AFEF735BAA52 /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = "kanbangit-native_iOS";
};
name = release;
};
FB6E824120F9ED3CFE30E713 /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
};
name = debug;
};
FF1553DD6765EEA42FF50CDD /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
};
name = release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
FB6E824120F9ED3CFE30E713 /* debug */,
FF1553DD6765EEA42FF50CDD /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */ = {
isa = XCConfigurationList;
buildConfigurations = (
390A27695F28BF3AA9A049C9 /* debug */,
C81211E40BBB158A79AEBE3D /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
E6F418AEF58E5F3D739C68F1 /* Build configuration list for PBXNativeTarget "WorkspaceUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5110A47C3B60906E18EE09D2 /* debug */,
EF104E75BD04AFEF735BAA52 /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
F0C7EA9610FB69A5EA27B950 /* Build configuration list for PBXNativeTarget "kanbangit-native_iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D41150B2CAE4512D04C26759 /* debug */,
0C46162024E99BEFF872CABF /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
/* End XCConfigurationList section */
};
rootObject = AA6AD2A91A9E86D319D86D59 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,10 @@
<?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>BuildSystemType</key>
<string>Original</string>
<key>DisableBuildSystemDeprecationDiagnostic</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
BuildableName = "KanbanGit.app"
BlueprintName = "kanbangit-native_iOS"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
BuildableName = "KanbanGit.app"
BlueprintName = "kanbangit-native_iOS"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</MacroExpansion>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CBC4561F8018D37DD49A0598"
BuildableName = "WorkspaceTests.xctest"
BlueprintName = "WorkspaceTests"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "988CFF304CB81C8F3A4D6612"
BuildableName = "WorkspaceUITests.xctest"
BlueprintName = "WorkspaceUITests"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
BuildableName = "KanbanGit.app"
BlueprintName = "kanbangit-native_iOS"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "release"
shouldUseLaunchSchemeArgsEnv = "NO"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
BuildableName = "KanbanGit.app"
BlueprintName = "kanbangit-native_iOS"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,48 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Choose a profile picture using your camera.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose a profile picture from your photo library.</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
<string>metal</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,5 @@
<?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/>
</plist>
+149
View File
@@ -0,0 +1,149 @@
name: kanbangit-native
options:
bundleIdPrefix: de.kanbangit.ios
deploymentTarget:
iOS: 17.0
fileGroups: [../../src]
configs:
debug: debug
release: release
settingGroups:
app:
base:
PRODUCT_NAME: KanbanGit
SWIFT_VERSION: "5.0"
PRODUCT_MODULE_NAME: KanbanGitNative
TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_STYLE: Manual
DEVELOPMENT_TEAM: ZZX2KZGTHU
ENABLE_USER_SCRIPT_SANDBOXING: false
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
MARKETING_VERSION: "0.2.8"
CURRENT_PROJECT_VERSION: "7"
configs:
debug:
CODE_SIGN_IDENTITY: Apple Development
release:
CODE_SIGN_IDENTITY: Apple Distribution
targetTemplates:
app:
type: application
sources:
- path: Sources
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
groups: [app]
targets:
kanbangit-native_iOS:
type: application
platform: iOS
productName: KanbanGit
sources:
- path: Sources
- path: ../../../ui/workspace-navigation.js
- path: ../../../ui/workspace-settings.js
- path: Assets.xcassets
- path: kanbangit-native_iOS
- path: LaunchScreen.storyboard
info:
path: kanbangit-native_iOS/Info.plist
properties:
NSCameraUsageDescription: Choose a profile picture using your camera.
NSPhotoLibraryUsageDescription: Choose a profile picture from your photo library.
LSRequiresIPhoneOS: true
UILaunchStoryboardName: LaunchScreen
UIRequiredDeviceCapabilities: [arm64, metal]
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
entitlements:
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
testTargets:
- WorkspaceTests
- WorkspaceUITests
settings:
base:
ENABLE_BITCODE: false
ARCHS: [arm64]
VALID_ARCHS: arm64
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64
groups: [app]
dependencies:
- framework: libapp.a
embed: false
- sdk: CoreGraphics.framework
- sdk: Metal.framework
- sdk: MetalKit.framework
- sdk: QuartzCore.framework
- sdk: Security.framework
- sdk: UIKit.framework
- sdk: WebKit.framework
preBuildScripts:
- script: |
set -eu
if [ -n "${KANBANGIT_IOS_LIBRARY:-}" ]; then
mkdir -p "$SRCROOT/Externals/arm64/$CONFIGURATION"
cp "$KANBANGIT_IOS_LIBRARY" "$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a"
exit 0
fi
export PATH="$(dirname "$(rustup which cargo)"):$PATH"
export RUSTC="$(rustup which rustc)"
cd "$SRCROOT/../../.."
./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR:-} ${ARCHS:?}
name: Build Rust Code
basedOnDependencyAnalysis: false
outputFiles:
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
WorkspaceTests:
type: bundle.unit-test
platform: iOS
dependencies:
- target: kanbangit-native_iOS
sources:
- path: ../../../ui/workspace-navigation.js
- path: ../../../ui/workspace-settings.js
- path: Tests
- path: Sources/kanbangit-native/WorkspacePolicy.swift
- path: Sources/kanbangit-native/WorkspaceViewController.swift
- path: Sources/kanbangit-native/en.lproj
settings:
base:
GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
TEST_HOST: $(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit
DEVELOPMENT_TEAM: ZZX2KZGTHU
CODE_SIGN_STYLE: Manual
SWIFT_VERSION: "5.0"
WorkspaceUITests:
type: bundle.ui-testing
platform: iOS
sources: [UITests]
dependencies:
- target: kanbangit-native_iOS
settings:
base:
GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.uitests
DEVELOPMENT_TEAM: ZZX2KZGTHU
CODE_SIGN_STYLE: Manual
SWIFT_VERSION: "5.0"
TEST_TARGET_NAME: kanbangit-native_iOS
+312 -75
View File
@@ -2,7 +2,7 @@ use crate::locale::en;
use crate::server::{validate_url, Server, DEFAULT_SERVER}; use crate::server::{validate_url, Server, DEFAULT_SERVER};
use std::{path::PathBuf, sync::Mutex, time::Duration}; use std::{path::PathBuf, sync::Mutex, time::Duration};
use tauri::{ use tauri::{
menu::{MenuBuilder, SubmenuBuilder}, menu::{AboutMetadata, MenuBuilder, MenuItem, SubmenuBuilder},
webview::{NewWindowResponse, PageLoadEvent}, webview::{NewWindowResponse, PageLoadEvent},
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent, AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
}; };
@@ -17,6 +17,15 @@ struct Connection {
} }
impl Connection { impl Connection {
#[cfg(target_os = "macos")]
fn allows_settings(&self, label: &str, url: &Url, main: bool) -> bool {
main && self.label.as_deref() == Some(label)
&& self
.server
.as_ref()
.is_some_and(|server| allows_server_navigation(&server.url, url))
}
fn begin_navigation(&mut self) -> u64 { fn begin_navigation(&mut self) -> u64 {
self.revision += 1; self.revision += 1;
self.phase = "connecting"; self.phase = "connecting";
@@ -39,6 +48,26 @@ fn allows_server_navigation(server: &Url, destination: &Url) -> bool {
&& destination.password().is_none() && destination.password().is_none()
} }
fn allows_external_navigation(destination: &Url) -> bool {
matches!(destination.scheme(), "https" | "http")
&& destination.host_str().is_some()
&& destination.username().is_empty()
&& destination.password().is_none()
}
fn open_external(destination: &Url) {
if allows_external_navigation(destination) {
// Arguments are passed directly to Launch Services, never through a shell.
let destination = destination.to_string();
std::thread::spawn(move || {
let _ = std::process::Command::new("/usr/bin/open")
.arg("--")
.arg(destination)
.status();
});
}
}
fn allows_launcher_navigation(destination: &Url) -> bool { fn allows_launcher_navigation(destination: &Url) -> bool {
destination.scheme() == "tauri" destination.scheme() == "tauri"
&& destination.host_str() == Some("localhost") && destination.host_str() == Some("localhost")
@@ -146,74 +175,153 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
let handle = app.clone(); let handle = app.clone();
let navigation_label = label.clone(); let navigation_label = label.clone();
let server_url = server.url.clone(); let server_url = server.url.clone();
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url)) let popup_server = server_url.clone();
.title(en::APP_NAME) let popup_handle = app.clone();
.inner_size(1280.0, 850.0) let popup_label = label.clone();
.min_inner_size(375.0, 600.0) let window =
.center() WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.dashboard_url()))
.visible(false) .title(en::APP_NAME)
.data_store_identifier(server.profile) .inner_size(1280.0, 850.0)
.on_navigation(move |destination| { .min_inner_size(375.0, 600.0)
if !allows_server_navigation(&server_url, destination) { .center()
return false; .visible(false)
} .data_store_identifier(server.profile)
begin_navigation(&handle, &navigation_label); .initialization_script(include_str!("../../ui/workspace-navigation.js"))
true .on_navigation(move |destination| {
}) if !allows_server_navigation(&server_url, destination) {
.on_new_window(|_, _| NewWindowResponse::Deny) open_external(destination);
.on_page_load(|window, payload| { return false;
if payload.event() != PageLoadEvent::Finished { }
return; begin_navigation(&handle, &navigation_label);
} true
let app = window.app_handle(); })
let state = app.state::<Mutex<Connection>>(); .on_new_window(move |destination, _| {
let should_show = { if allows_server_navigation(&popup_server, &destination) {
let mut state = state.lock().unwrap(); if let Some(window) = popup_handle.get_webview_window(&popup_label) {
if state.label.as_deref() != Some(window.label()) { let _ = window.navigate(destination);
}
} else {
open_external(&destination);
}
NewWindowResponse::Deny
})
.on_page_load(|window, payload| {
if payload.event() != PageLoadEvent::Finished {
return; return;
} }
let first_load = !state.ready; let app = window.app_handle();
state.ready = true; let state = app.state::<Mutex<Connection>>();
state.phase = "connected"; let should_show = {
first_load let mut state = state.lock().unwrap();
}; if state.label.as_deref() != Some(window.label())
update_launcher(app); || state.phase != "connecting"
if should_show { || !state.server.as_ref().is_some_and(|server| {
let _ = window.show(); allows_server_navigation(&server.url, payload.url())
let _ = window.set_focus(); })
if let Some(launcher) = app.get_webview_window("launcher") { {
let _ = launcher.hide(); return;
}
let first_load = !state.ready;
state.ready = true;
state.phase = "connected";
first_load
};
if should_show {
let _ = window.show();
let _ = window.set_focus();
if let Some(launcher) = app.get_webview_window("launcher") {
let _ = launcher.hide();
}
} }
update_launcher(app);
})
.build()
.map_err(|_| "connectionError")?;
#[cfg(target_os = "macos")]
window
.with_webview(|webview| unsafe {
unsafe extern "C" {
fn install_workspace_navigation(webview: *mut std::ffi::c_void);
} }
install_workspace_navigation(webview.inner().cast());
}) })
.build()
.map_err(|_| "connectionError")?; .map_err(|_| "connectionError")?;
#[cfg(target_os = "macos")]
crate::settings::install(app, &window).map_err(|_| "connectionError")?;
let handle = app.clone(); let handle = app.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {
if matches!(event, WindowEvent::Destroyed) { if let WindowEvent::CloseRequested { api, .. } = event {
let state = handle.state::<Mutex<Connection>>(); let current = handle
let current = { .state::<Mutex<Connection>>()
let mut state = state.lock().unwrap(); .lock()
if state.label.as_deref() == Some(&label) { .unwrap()
state.label = None; .label
state.ready = false; .as_deref()
state.revision += 1; == Some(&label);
state.phase = "idle";
true
} else {
false
}
};
if current { if current {
show_launcher(&handle); api.prevent_close();
handle.exit(0);
} }
} }
}); });
Ok(()) Ok(())
} }
#[cfg(target_os = "macos")]
pub fn allows_settings(app: &AppHandle, label: &str, url: &Url, main: bool) -> bool {
let state = app.state::<Mutex<Connection>>();
let state = state.lock().unwrap();
state.allows_settings(label, url, main)
}
#[cfg(target_os = "macos")]
pub fn settings_action(
app: &AppHandle,
action: crate::settings::Action,
) -> Result<serde_json::Value, &'static str> {
use crate::settings::Action;
match action {
Action::GetState {} => {
let workspace = app
.state::<Mutex<Connection>>()
.lock()
.unwrap()
.server
.as_ref()
.map(|server| server.url.to_string())
.ok_or("bridgeUnavailable")?;
return Ok(
serde_json::json!({"apiVersion":1, "platform":"macos", "workspace":workspace,
"version":app.package_info().version.to_string(), "capabilities":["workspace", "updates"],
"updates":crate::updates::snapshot(app)}),
);
}
Action::SettingsReady {} => {}
Action::OpenSettings {} => show_launcher(app),
Action::SetWorkspace { server } => {
if let Err(error) = open_workspace(app, &server) {
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
show_launcher(app);
return Err(error);
}
}
Action::CheckForUpdates {} => crate::updates::check_inline(app),
Action::SetUpdatePreferences {
automatic,
interval_hours,
} => crate::updates::save_preferences(app, automatic, interval_hours)?,
Action::InstallUpdate { version } => crate::updates::install(app, Some(&version)),
}
Ok(serde_json::Value::Null)
}
fn handle_launcher_action(app: &AppHandle, destination: &Url) { fn handle_launcher_action(app: &AppHandle, destination: &Url) {
let result = match destination.path() { let result = match destination.path() {
"/action/updates" => {
#[cfg(target_os = "macos")]
crate::updates::show(app);
Ok(())
}
"/action/open" => { "/action/open" => {
let input = destination let input = destination
.query_pairs() .query_pairs()
@@ -247,12 +355,24 @@ pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
tauri::generate_context!() tauri::generate_context!()
} }
fn about_metadata(version: String) -> AboutMetadata<'static> {
AboutMetadata {
name: Some(en::APP_NAME.into()),
version: Some(version),
#[cfg(target_os = "macos")]
short_version: Some(crate::metadata::build_number().into()),
..Default::default()
}
}
pub fn builder() -> tauri::Builder<tauri::Wry> { pub fn builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default().setup(|app| { tauri::Builder::default().setup(|app| {
let (server, phase) = match Server::load(&configuration_path(app.handle())?) { let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
Ok(server) => (server, "idle"), Ok(Some(server)) => (Some(server), "connecting"),
Ok(None) => (None, "idle"),
Err(_) => (None, "storageError"), Err(_) => (None, "storageError"),
}; };
let startup_server = server.clone();
app.manage(Mutex::new(Connection { app.manage(Mutex::new(Connection {
server, server,
phase, phase,
@@ -260,7 +380,20 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
})); }));
let menu = MenuBuilder::new(app) let menu = MenuBuilder::new(app)
.item( .item(
&SubmenuBuilder::new(app, en::APP_NAME) &SubmenuBuilder::with_id(app, "app-menu", en::APP_NAME)
.about_with_text(
en::ABOUT,
Some(about_metadata(app.package_info().version.to_string())),
)
.text("check-updates", en::CHECK_UPDATES)
.item(&MenuItem::with_id(
app,
"install-update",
en::INSTALL_UPDATE,
false,
None::<&str>,
)?)
.separator()
.text("server-settings", en::SERVER_SETTINGS) .text("server-settings", en::SERVER_SETTINGS)
.separator() .separator()
.quit() .quit()
@@ -277,11 +410,45 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
.select_all() .select_all()
.build()?, .build()?,
) )
.item(
&SubmenuBuilder::new(app, en::NAVIGATION)
.text("workspace-back", en::BACK)
.text("workspace-forward", en::FORWARD)
.build()?,
)
.build()?; .build()?;
app.set_menu(menu)?; app.set_menu(menu)?;
#[cfg(target_os = "macos")]
crate::updates::setup(app.handle())?;
app.on_menu_event(|app, event| { app.on_menu_event(|app, event| {
#[cfg(target_os = "macos")]
match event.id().as_ref() {
"check-updates" => {
crate::updates::check(app, true);
return;
}
"install-update" => {
crate::updates::show(app);
return;
}
_ => {}
}
if event.id().as_ref() == "server-settings" { if event.id().as_ref() == "server-settings" {
show_launcher(app); show_launcher(app);
} else if let Some(script) = match event.id().as_ref() {
"workspace-back" => Some("window.history.back()"),
"workspace-forward" => Some("window.history.forward()"),
_ => None,
} {
let label = app
.state::<Mutex<Connection>>()
.lock()
.unwrap()
.label
.clone();
if let Some(window) = label.and_then(|label| app.get_webview_window(&label)) {
let _ = window.eval(script);
}
} }
}); });
let handle = app.handle().clone(); let handle = app.handle().clone();
@@ -290,6 +457,7 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
.title(en::APP_NAME) .title(en::APP_NAME)
.inner_size(560.0, 720.0) .inner_size(560.0, 720.0)
.min_inner_size(375.0, 640.0) .min_inner_size(375.0, 640.0)
.visible(false)
.center() .center()
.on_navigation(move |destination| { .on_navigation(move |destination| {
if !allows_launcher_navigation(destination) { if !allows_launcher_navigation(destination) {
@@ -310,25 +478,17 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
if payload.event() == PageLoadEvent::Finished { if payload.event() == PageLoadEvent::Finished {
let app = window.app_handle().clone(); let app = window.app_handle().clone();
update_launcher(&app); update_launcher(&app);
std::thread::spawn(move || { #[cfg(target_os = "macos")]
let handle = app.clone(); crate::updates::refresh(&app);
let _ = app.run_on_main_thread(move || { let needs_setup = app
let server = handle .state::<Mutex<Connection>>()
.state::<Mutex<Connection>>() .lock()
.lock() .unwrap()
.unwrap() .server
.server .is_none();
.clone(); if needs_setup {
if let Some(server) = server { show_launcher(&app);
if let Err(error) = open_workspace(&handle, server.url.as_str()) }
{
handle.state::<Mutex<Connection>>().lock().unwrap().phase =
error;
show_launcher(&handle);
}
}
});
});
} }
}) })
.build()?; .build()?;
@@ -336,9 +496,25 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
launcher.on_window_event(move |event| { launcher.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event { if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close(); api.prevent_close();
let _ = window.hide(); let has_workspace = window
.app_handle()
.state::<Mutex<Connection>>()
.lock()
.unwrap()
.ready;
if has_workspace {
let _ = window.hide();
} else {
window.app_handle().exit(0);
}
} }
}); });
if let Some(server) = startup_server {
if let Err(error) = open_workspace(app.handle(), server.url.as_str()) {
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
show_launcher(app.handle());
}
}
Ok(()) Ok(())
}) })
} }
@@ -347,6 +523,62 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
mod tests { mod tests {
use super::*; use super::*;
#[cfg(target_os = "macos")]
#[test]
fn settings_only_accept_the_current_workspace_main_frame() {
let mut state = Connection {
server: Some(Server::new(validate_url("https://example.com/app/").unwrap()).unwrap()),
label: Some("workspace-1".into()),
..Default::default()
};
let url = Url::parse("https://example.com/settings/app").unwrap();
assert!(state.allows_settings("workspace-1", &url, true));
assert!(!state.allows_settings("workspace-1", &url, false));
assert!(!state.allows_settings("workspace-0", &url, true));
for address in [
"https://evil.test",
"http://example.com",
"https://example.com:444/",
"file:///tmp/page",
] {
assert!(!state.allows_settings("workspace-1", &Url::parse(address).unwrap(), true));
}
state.label = Some("workspace-2".into());
assert!(!state.allows_settings("workspace-1", &url, true));
}
#[cfg(target_os = "macos")]
#[test]
fn about_shows_app_version_followed_by_the_actual_build_number() {
let metadata = about_metadata("9.8.7".into());
assert_eq!(metadata.version.as_deref(), Some("9.8.7"));
assert_eq!(
metadata.short_version.as_deref(),
Some(crate::metadata::build_number())
);
assert_ne!(metadata.version, metadata.short_version);
}
#[test]
fn external_links_accept_only_web_urls_without_credentials() {
for input in [
"https://git.example.com/org/repo/pulls/1#diff",
"http://git.example.com/repo",
] {
assert!(allows_external_navigation(&Url::parse(input).unwrap()));
}
for input in [
"https://user:secret@git.example.com/",
"file:///etc/passwd",
"javascript:alert(1)",
"tauri://localhost/action/open",
"mailto:user@example.com",
"data:text/html,test",
] {
assert!(!allows_external_navigation(&Url::parse(input).unwrap()));
}
}
#[test] #[test]
fn navigation_is_limited_to_the_selected_https_origin() { fn navigation_is_limited_to_the_selected_https_origin() {
let server = validate_url("https://example.com:8443/app/").unwrap(); let server = validate_url("https://example.com:8443/app/").unwrap();
@@ -409,7 +641,10 @@ mod tests {
#[test] #[test]
fn remote_pages_cannot_invoke_native_commands() { fn remote_pages_cannot_invoke_native_commands() {
let app = tauri::test::mock_builder().build(context()).unwrap(); let builder = tauri::test::mock_builder();
#[cfg(target_os = "macos")]
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
let app = builder.build(context()).unwrap();
let window = WebviewWindowBuilder::new( let window = WebviewWindowBuilder::new(
&app, &app,
"workspace-1", "workspace-1",
@@ -418,6 +653,8 @@ mod tests {
.build() .build()
.unwrap(); .unwrap();
for command in [ for command in [
"plugin:updater|check",
"plugin:updater|download_and_install",
"plugin:window|close", "plugin:window|close",
"plugin:webview|create_webview_window", "plugin:webview|create_webview_window",
"plugin:path|resolve_directory", "plugin:path|resolve_directory",
+14
View File
@@ -0,0 +1,14 @@
use tauri::{WebviewUrl, WebviewWindowBuilder};
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
tauri::generate_context!()
}
pub fn builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default().setup(|app| {
WebviewWindowBuilder::new(app, "host", WebviewUrl::App("index.html".into()))
.on_navigation(|_| false)
.build()?;
Ok(())
})
}
+18 -4
View File
@@ -1,18 +1,32 @@
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
mod android; mod android;
#[cfg(not(target_os = "android"))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
mod desktop; mod desktop;
#[cfg(not(target_os = "android"))] #[cfg(target_os = "ios")]
mod ios;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod locale; mod locale;
#[cfg(not(target_os = "android"))] #[cfg(target_os = "macos")]
mod metadata;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod server; mod server;
#[cfg(target_os = "macos")]
mod updates;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub use android::{builder, context}; pub use android::{builder, context};
#[cfg(not(target_os = "android"))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
pub use desktop::{builder, context}; pub use desktop::{builder, context};
#[cfg(target_os = "ios")]
pub use ios::{builder, context};
#[cfg(all(target_os = "macos", feature = "smoke-test"))]
pub use updates::refresh_menu as refresh_update_menu_for_smoke_test;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
builder().run(context()).expect("Could not start KanbanGit"); builder().run(context()).expect("Could not start KanbanGit");
} }
#[cfg(target_os = "macos")]
mod settings;
+7
View File
@@ -1,5 +1,12 @@
pub mod en { pub mod en {
pub const ABOUT: &str = "About KanbanGit";
pub const CHECK_UPDATES: &str = "Check For Updates…";
pub const INSTALL_UPDATE: &str = "Install Update…";
pub const UPDATES: &str = "KanbanGit Updates";
pub const APP_NAME: &str = "KanbanGit"; pub const APP_NAME: &str = "KanbanGit";
pub const SERVER_SETTINGS: &str = "Server settings…"; pub const SERVER_SETTINGS: &str = "Server settings…";
pub const NAVIGATION: &str = "Navigation";
pub const BACK: &str = "Back";
pub const FORWARD: &str = "Forward";
pub const EDIT: &str = "Edit"; pub const EDIT: &str = "Edit";
} }
+69
View File
@@ -0,0 +1,69 @@
use std::{path::Path, sync::LazyLock};
fn build_from_executable(executable: &Path) -> Option<String> {
let macos = executable.parent()?;
let contents = macos.parent()?;
if macos.file_name()? != "MacOS" || contents.file_name()? != "Contents" {
return None;
}
let output = std::process::Command::new("/usr/bin/plutil")
.args(["-extract", "CFBundleVersion", "raw", "-o", "-"])
.arg(contents.join("Info.plist"))
.output()
.ok()?;
if !output.status.success() {
return Some("unknown".into());
}
let build = String::from_utf8(output.stdout).ok()?;
Some(build.trim().to_owned())
}
pub fn build_number() -> &'static str {
static BUILD: LazyLock<String> = LazyLock::new(|| {
std::env::current_exe()
.ok()
.and_then(|executable| build_from_executable(&executable))
.unwrap_or_else(|| {
// Tauri omits macOS bundle settings from its generated runtime config.
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
config["bundle"]["macOS"]["bundleVersion"]
.as_str()
.unwrap_or("unknown")
.to_owned()
})
});
&BUILD
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn installed_build_comes_from_the_app_plist_instead_of_the_app_version() {
let root = std::env::temp_dir().join(format!("kanbangit-build-{}", std::process::id()));
let contents = root.join("KanbanGit.app/Contents");
std::fs::create_dir_all(contents.join("MacOS")).unwrap();
let executable = contents.join("MacOS/kanbangit-native");
std::fs::write(contents.join("Info.plist"), r#"<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleShortVersionString</key><string>0.1.3</string><key>CFBundleVersion</key><string>42</string></dict></plist>"#).unwrap();
assert_eq!(build_from_executable(&executable).as_deref(), Some("42"));
std::fs::remove_file(contents.join("Info.plist")).unwrap();
assert_eq!(
build_from_executable(&executable).as_deref(),
Some("unknown")
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn unbundled_execution_uses_the_configured_build() {
assert!(build_from_executable(Path::new("/tmp/target/debug/kanbangit-native")).is_none());
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
assert_eq!(
build_number(),
config["bundle"]["macOS"]["bundleVersion"].as_str().unwrap()
);
}
}
+55
View File
@@ -0,0 +1,55 @@
#import <AppKit/AppKit.h>
#import <WebKit/WebKit.h>
#import <objc/runtime.h>
@interface KanbanWorkspaceUIDelegate : NSObject <WKUIDelegate>
@property(nonatomic, strong) id<WKUIDelegate> originalDelegate;
@end
@implementation KanbanWorkspaceUIDelegate
- (BOOL)respondsToSelector:(SEL)selector {
return [super respondsToSelector:selector] || [self.originalDelegate respondsToSelector:selector];
}
- (id)forwardingTargetForSelector:(SEL)selector {
if ([self.originalDelegate respondsToSelector:selector]) {
return self.originalDelegate;
}
return [super forwardingTargetForSelector:selector];
}
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler {
NSWindow *window = webView.window;
if (!window || window.attachedSheet) {
completionHandler(NO);
return;
}
NSAlert *alert = [NSAlert new];
alert.alertStyle = NSAlertStyleWarning;
alert.messageText = frame.request.URL.host ?: @"KanbanGit";
alert.informativeText = message;
[alert addButtonWithTitle:@"OK"];
[alert addButtonWithTitle:@"Cancel"];
[alert beginSheetModalForWindow:window completionHandler:^(NSModalResponse response) {
completionHandler(response == NSAlertFirstButtonReturn);
}];
}
@end
static char workspaceUIDelegateKey;
void install_workspace_navigation(void *pointer) {
WKWebView *webview = (__bridge WKWebView *)pointer;
webview.allowsBackForwardNavigationGestures = YES;
if ([webview.UIDelegate isKindOfClass:KanbanWorkspaceUIDelegate.class]) {
return;
}
KanbanWorkspaceUIDelegate *delegate = [KanbanWorkspaceUIDelegate new];
delegate.originalDelegate = webview.UIDelegate;
// WKWebView keeps a weak UI delegate, so retain the forwarding adapter with its view.
objc_setAssociatedObject(webview, &workspaceUIDelegateKey, delegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
webview.UIDelegate = delegate;
}
+21
View File
@@ -37,6 +37,12 @@ pub fn validate_url(input: &str) -> Result<Url, &'static str> {
} }
impl Server { impl Server {
pub fn dashboard_url(&self) -> Url {
self.url
.join("dashboard")
.expect("Validated installation URL")
}
pub fn new(url: Url) -> io::Result<Self> { pub fn new(url: Url) -> io::Result<Self> {
let mut profile = [0; 16]; let mut profile = [0; 16];
fs::File::open("/dev/urandom")?.read_exact(&mut profile)?; fs::File::open("/dev/urandom")?.read_exact(&mut profile)?;
@@ -111,6 +117,21 @@ mod tests {
} }
} }
#[test]
fn startup_targets_dashboard_within_the_installation() {
for (base, expected) in [
("https://example.com/", "https://example.com/dashboard"),
(
"https://example.com:8443/kanban/",
"https://example.com:8443/kanban/dashboard",
),
] {
let server = Server::new(validate_url(base).unwrap()).unwrap();
assert_eq!(server.dashboard_url().as_str(), expected);
assert_eq!(server.url.as_str(), base);
}
}
#[test] #[test]
fn configuration_round_trip_preserves_only_the_address_and_profile() { fn configuration_round_trip_preserves_only_the_address_and_profile() {
let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap(); let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap();
+47
View File
@@ -0,0 +1,47 @@
#import <WebKit/WebKit.h>
typedef char *(*SettingsCallback)(const char *, const char *, const char *, bool);
typedef void (*SettingsFree)(char *);
@interface KanbanSettingsHandler : NSObject <WKScriptMessageHandlerWithReply>
@property(nonatomic, weak) WKWebView *webview;
@property(nonatomic, copy) NSString *label;
@property(nonatomic) SettingsCallback callback;
@property(nonatomic) SettingsFree releaseReply;
@end
@implementation KanbanSettingsHandler
- (void)userContentController:(WKUserContentController *)controller
didReceiveScriptMessage:(WKScriptMessage *)message
replyHandler:(void (^)(id, NSString *))replyHandler {
if (message.webView != self.webview || !message.frameInfo.isMainFrame ||
![message.body isKindOfClass:NSString.class]) {
replyHandler(nil, @"untrustedFrame");
return;
}
char *reply = self.callback(self.label.UTF8String,
message.frameInfo.request.URL.absoluteString.UTF8String,
[(NSString *)message.body UTF8String], message.frameInfo.isMainFrame);
if (!reply) { replyHandler(nil, @"bridgeUnavailable"); return; }
NSString *value = [NSString stringWithUTF8String:reply];
self.releaseReply(reply);
replyHandler(value, nil);
}
@end
void install_workspace_settings(void *pointer, const char *label, const char *script,
SettingsCallback callback, SettingsFree releaseReply) {
WKWebView *webview = (__bridge WKWebView *)pointer;
KanbanSettingsHandler *handler = [KanbanSettingsHandler new];
handler.webview = webview;
handler.label = [NSString stringWithUTF8String:label];
handler.callback = callback;
handler.releaseReply = releaseReply;
[webview.configuration.userContentController addScriptMessageHandlerWithReply:handler
contentWorld:WKContentWorld.pageWorld name:@"kanbanSettings"];
[webview.configuration.userContentController addUserScript:[[WKUserScript alloc]
initWithSource:[NSString stringWithUTF8String:script]
injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];
// The initial document may already have started loading before the handler is attached.
[webview evaluateJavaScript:[NSString stringWithUTF8String:script] completionHandler:nil];
}
+147
View File
@@ -0,0 +1,147 @@
use serde::Deserialize;
use serde_json::{json, Value};
use std::ffi::{c_char, CStr, CString};
use std::sync::OnceLock;
use tauri::{AppHandle, Url};
static APP: OnceLock<AppHandle> = OnceLock::new();
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub version: u8,
pub id: u64,
pub action: String,
pub payload: Value,
}
#[derive(Debug, Deserialize)]
#[serde(
tag = "action",
content = "payload",
rename_all = "camelCase",
deny_unknown_fields
)]
pub enum Action {
GetState {},
SettingsReady {},
OpenSettings {},
SetWorkspace {
server: String,
},
CheckForUpdates {},
SetUpdatePreferences {
automatic: bool,
#[serde(rename = "intervalHours")]
interval_hours: u64,
},
InstallUpdate {
version: String,
},
}
pub fn parse(value: &str) -> Result<(u64, Action), &'static str> {
if value.len() > 8192 {
return Err("invalidRequest");
}
let request: Request = serde_json::from_str(value).map_err(|_| "invalidRequest")?;
if request.version != 1 {
return Err("incompatibleVersion");
}
let action =
serde_json::from_value(json!({"action": request.action, "payload": request.payload}))
.map_err(|_| "invalidRequest")?;
Ok((request.id, action))
}
pub fn install(app: &AppHandle, window: &tauri::WebviewWindow) -> tauri::Result<()> {
let _ = APP.set(app.clone());
let label = CString::new(window.label()).unwrap();
let script = CString::new(include_str!("../../ui/workspace-settings.js")).unwrap();
window.with_webview(move |view| unsafe {
unsafe extern "C" {
fn install_workspace_settings(
view: *mut std::ffi::c_void,
label: *const c_char,
script: *const c_char,
callback: extern "C" fn(
*const c_char,
*const c_char,
*const c_char,
bool,
) -> *mut c_char,
free: extern "C" fn(*mut c_char),
);
}
install_workspace_settings(
view.inner().cast(),
label.as_ptr(),
script.as_ptr(),
receive,
release,
);
})
}
extern "C" fn release(value: *mut c_char) {
if !value.is_null() {
unsafe {
drop(CString::from_raw(value));
}
}
}
extern "C" fn receive(
label: *const c_char,
url: *const c_char,
body: *const c_char,
main: bool,
) -> *mut c_char {
let result = std::panic::catch_unwind(|| -> Result<Value, &'static str> {
if label.is_null() || url.is_null() || body.is_null() {
return Err("invalidRequest");
}
let label = unsafe { CStr::from_ptr(label) }
.to_str()
.map_err(|_| "invalidRequest")?;
let url = unsafe { CStr::from_ptr(url) }
.to_str()
.map_err(|_| "invalidRequest")?;
let body = unsafe { CStr::from_ptr(body) }
.to_str()
.map_err(|_| "invalidRequest")?;
let app = APP.get().ok_or("bridgeUnavailable")?;
let url = Url::parse(url).map_err(|_| "untrustedFrame")?;
if !crate::desktop::allows_settings(app, label, &url, main) {
return Err("untrustedFrame");
}
let (id, action) = parse(body)?;
let result = crate::desktop::settings_action(app, action)?;
Ok(json!({"id": id, "result": result}))
})
.unwrap_or(Err("bridgeUnavailable"));
let value = result.unwrap_or_else(|error| json!({"error": error}));
CString::new(value.to_string()).unwrap().into_raw()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn protocol_rejects_unknown_versions_actions_and_malformed_payloads() {
for value in [
r#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
r#"{"version":1,"id":1,"action":"shell","payload":{}}"#,
r#"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":3}}"#,
r#"{"version":1,"id":1,"action":"setUpdatePreferences","payload":{"automatic":"true","intervalHours":6}}"#,
r#"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1.2.3","url":"https://evil.test"}}"#,
] {
assert!(parse(value).is_err(), "{value}");
}
assert!(parse(&"x".repeat(8193)).is_err());
assert!(matches!(
parse(r#"{"version":1,"id":1,"action":"getState","payload":{}}"#),
Ok((1, Action::GetState {}))
));
}
}
+833
View File
@@ -0,0 +1,833 @@
use semver::Version;
use serde::{Deserialize, Serialize};
use std::{
fs,
io::Read,
path::Path,
sync::Mutex,
time::{Duration, Instant},
};
use tauri::{
webview::{NewWindowResponse, PageLoadEvent},
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder,
};
use tauri_plugin_updater::{Update, UpdaterExt};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Source {
base_url: String,
public_key: String,
}
fn source() -> &'static Source {
static SOURCE: std::sync::LazyLock<Source> = std::sync::LazyLock::new(|| {
serde_json::from_str(include_str!("../../update-source.json"))
.expect("Bundled update source must be valid")
});
&SOURCE
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct Preferences {
automatic: bool,
interval_hours: u64,
}
impl Default for Preferences {
fn default() -> Self {
Self {
automatic: true,
interval_hours: 6,
}
}
}
impl Preferences {
fn valid(&self) -> bool {
[1, 6, 24].contains(&self.interval_hours)
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct View {
phase: &'static str,
current_version: String,
build: String,
version: Option<String>,
notes: Option<String>,
downloaded: u64,
total: Option<u64>,
error: Option<&'static str>,
preferences: Preferences,
}
struct State {
view: View,
update: Option<Update>,
last_check: Option<Instant>,
}
impl State {
fn busy(&self) -> bool {
matches!(
self.view.phase,
"checking" | "downloading" | "verifying" | "installing" | "restarting"
)
}
fn begin_check(&mut self, manual: bool, now: Instant) -> bool {
if self.busy()
|| (!manual
&& (!self.view.preferences.automatic
|| self.last_check.is_some_and(|last| {
now.duration_since(last)
< Duration::from_secs(self.view.preferences.interval_hours * 3600)
})))
{
return false;
}
self.last_check = Some(now);
self.view.phase = "checking";
self.view.error = None;
self.update = None;
self.view.version = None;
self.view.notes = None;
true
}
}
fn target() -> String {
format!("{}-apple-darwin", std::env::consts::ARCH)
}
fn feed_url() -> Url {
Url::parse(&format!("{}/{}/latest.json", source().base_url, target())).unwrap()
}
fn newer_stable(current: &str, candidate: &str) -> bool {
match (Version::parse(current), Version::parse(candidate)) {
(Ok(current), Ok(candidate)) => {
candidate.pre.is_empty() && candidate.build.is_empty() && candidate > current
}
_ => false,
}
}
fn valid_download(current: &str, version: &str, url: &Url) -> bool {
newer_stable(current, version)
&& url.as_str()
== format!(
"{}/{}/{version}/KanbanGit-{version}-{}.app.tar.gz",
source().base_url,
target(),
target()
)
}
fn trusted_navigation(url: &Url) -> bool {
url.scheme() == "tauri"
&& url.host_str() == Some("localhost")
&& url.port().is_none()
&& url.username().is_empty()
&& url.password().is_none()
}
pub fn refresh_menu(app: &AppHandle, available: bool, busy: bool) {
if let Some(menu) = app
.menu()
.and_then(|menu| menu.get("app-menu"))
.and_then(|item| {
item.as_submenu()
.and_then(|menu| menu.get("install-update"))
})
{
if let Some(item) = menu.as_menuitem() {
let _ = item.set_enabled(available && !busy);
}
}
}
pub fn refresh(app: &AppHandle) {
let (view, available, busy) = {
let state = app.state::<Mutex<State>>();
let state = state.lock().unwrap();
(state.view.clone(), state.update.is_some(), state.busy())
};
refresh_menu(app, available, busy);
if let Some(window) = app.get_webview_window("updates") {
if let Ok(json) = serde_json::to_string(&view) {
let _ = window.eval(format!("window.updateStatus?.({json})"));
}
}
if let Some(window) = app.get_webview_window("launcher") {
let _ = window.eval(format!("window.updateAvailable?.({available})"));
}
}
pub fn show(app: &AppHandle) {
if let Some(window) = app.get_webview_window("updates") {
let _ = window.show();
let _ = window.set_focus();
}
refresh(app);
}
fn fail(app: &AppHandle, message: &'static str) {
{
let state = app.state::<Mutex<State>>();
let mut state = state.lock().unwrap();
state.view.phase = "error";
state.view.error = Some(message);
}
refresh(app);
}
fn update_error(error: &tauri_plugin_updater::Error) -> &'static str {
use tauri_plugin_updater::Error;
match error {
Error::Minisign(_)
| Error::Base64(_)
| Error::SignatureUtf8(_)
| Error::SignedVersionMismatch { .. }
| Error::MissingSignedVersion => "signatureError",
Error::Serialization(_)
| Error::Semver(_)
| Error::TargetsNotFound(_)
| Error::TargetNotFound(_) => "metadataError",
_ => "networkError",
}
}
pub fn check(app: &AppHandle, manual: bool) {
if manual {
show(app);
}
check_with_mode(app, manual);
}
pub fn check_inline(app: &AppHandle) {
check_with_mode(app, true);
}
fn check_with_mode(app: &AppHandle, manual: bool) {
let begin = app
.state::<Mutex<State>>()
.lock()
.unwrap()
.begin_check(manual, Instant::now());
if !begin {
return;
}
refresh(app);
let app = app.clone();
tauri::async_runtime::spawn(async move {
let result = async {
app.updater_builder()
.pubkey(&source().public_key)
.endpoints(vec![feed_url()])?
.timeout(Duration::from_secs(60))
.configure_client(|client| client.https_only(true))
.version_comparator(|current, release| {
newer_stable(&current.to_string(), &release.version.to_string())
})
.build()?
.check()
.await
}
.await;
match result {
Ok(Some(mut update)) => {
if !valid_download(
&update.current_version,
&update.version,
&update.download_url,
) || update.signature.trim().is_empty()
{
fail(&app, "metadataError");
return;
}
update.timeout = Some(Duration::from_secs(600));
let state = app.state::<Mutex<State>>();
let mut state = state.lock().unwrap();
state.view.phase = "available";
state.view.version = Some(update.version.clone());
state.view.notes = update.body.clone();
state.update = Some(update);
}
Ok(None) => app.state::<Mutex<State>>().lock().unwrap().view.phase = "current",
Err(error) => {
fail(&app, update_error(&error));
return;
}
}
refresh(&app);
});
}
fn command(program: &str, args: &[&str]) -> Result<String, &'static str> {
let output = std::process::Command::new(program)
.args(args)
.output()
.map_err(|_| "verificationError")?;
if !output.status.success() {
return Err("verificationError");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
fn matches_architecture(header: &[u8]) -> bool {
let expected_cpu: u32 = if cfg!(target_arch = "aarch64") {
0x0100_000c
} else {
0x0100_0007
};
header.len() >= 8
&& header[..4] == [0xcf, 0xfa, 0xed, 0xfe]
&& header[4..8] == expected_cpu.to_le_bytes()
}
fn verify_bundle(archive: &Path, directory: &Path, version: &str) -> Result<(), &'static str> {
command(
"/usr/bin/tar",
&[
"-xzf",
archive.to_str().ok_or("verificationError")?,
"-C",
directory.to_str().ok_or("verificationError")?,
],
)?;
let app = directory.join("KanbanGit.app");
let plist = app.join("Contents/Info.plist");
let plist = plist.to_str().ok_or("verificationError")?;
for (field, expected) in [
("CFBundleShortVersionString", version),
("CFBundleIdentifier", "de.kanbangit.desktop"),
] {
if command(
"/usr/bin/plutil",
&["-extract", field, "raw", "-o", "-", plist],
)? != expected
{
return Err("verificationError");
}
}
let executable = app.join("Contents/MacOS/kanbangit-native");
let mut header = [0u8; 8];
fs::File::open(executable)
.and_then(|mut file| file.read_exact(&mut header))
.map_err(|_| "verificationError")?;
if !matches_architecture(&header) {
return Err("verificationError");
}
command("/usr/bin/codesign", &["--verify", "--deep", "--strict", "-R", "=anchor apple generic and identifier \"de.kanbangit.desktop\" and certificate leaf[subject.OU] = \"ZZX2KZGTHU\" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists", app.to_str().ok_or("verificationError")?])?;
Ok(())
}
pub fn install(app: &AppHandle, confirmed_version: Option<&str>) {
let update = {
let state = app.state::<Mutex<State>>();
let mut state = state.lock().unwrap();
if state.busy() {
return;
}
let Some(update) = state.update.clone() else {
return;
};
if confirmed_version != Some(update.version.as_str()) {
return;
}
state.view.phase = "downloading";
state.view.error = None;
state.view.downloaded = 0;
state.view.total = None;
update
};
refresh(app);
let app = app.clone();
tauri::async_runtime::spawn(async move {
let bytes = update
.download(
|chunk, total| {
{
let state = app.state::<Mutex<State>>();
let mut state = state.lock().unwrap();
state.view.downloaded += chunk as u64;
state.view.total = total;
}
refresh(&app);
},
|| {
app.state::<Mutex<State>>().lock().unwrap().view.phase = "verifying";
refresh(&app);
},
)
.await;
let bytes = match bytes {
Ok(bytes) => bytes,
Err(error) => {
fail(&app, update_error(&error));
return;
}
};
let handle = app.clone();
let result = tauri::async_runtime::spawn_blocking(move || -> Result<(), &'static str> {
let cache = handle
.path()
.app_cache_dir()
.map_err(|_| "installationError")?
.join("verified-update");
if cache.exists() {
fs::remove_dir_all(&cache).map_err(|_| "installationError")?;
}
fs::create_dir_all(&cache).map_err(|_| "installationError")?;
let archive = cache.join("update.tar.gz");
let result = (|| {
fs::write(&archive, &bytes).map_err(|_| "installationError")?;
verify_bundle(&archive, &cache, &update.version)?;
handle.state::<Mutex<State>>().lock().unwrap().view.phase = "installing";
refresh(&handle);
update.install(&bytes).map_err(|_| "installationError")
})();
let _ = fs::remove_dir_all(cache);
result
})
.await;
match result {
Ok(Ok(())) => {
app.state::<Mutex<State>>().lock().unwrap().view.phase = "restarting";
refresh(&app);
app.restart();
}
Ok(Err(message)) => fail(&app, message),
Err(_) => fail(&app, "installationError"),
}
});
}
fn action(app: &AppHandle, url: &Url) {
match url.path() {
"/updates/check" => check(app, true),
"/updates/install" => install(
app,
url.query_pairs()
.find(|(key, _)| key == "version")
.as_ref()
.map(|(_, value)| value.as_ref()),
),
"/updates/preferences" => {
let params: std::collections::HashMap<_, _> = url.query_pairs().collect();
let preferences = Preferences {
automatic: params.get("automatic").is_some_and(|v| v == "true"),
interval_hours: params
.get("interval")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
};
if !preferences.valid() {
fail(app, "preferencesError");
return;
}
if let Err(error) =
save_preferences(app, preferences.automatic, preferences.interval_hours)
{
fail(app, error);
}
}
_ => {}
}
}
pub fn snapshot(app: &AppHandle) -> serde_json::Value {
serde_json::to_value(&app.state::<Mutex<State>>().lock().unwrap().view).unwrap()
}
pub fn save_preferences(
app: &AppHandle,
automatic: bool,
interval_hours: u64,
) -> Result<(), &'static str> {
let preferences = Preferences {
automatic,
interval_hours,
};
if !preferences.valid() {
return Err("preferencesError");
}
let state = app.state::<Mutex<State>>();
let mut state = state.lock().unwrap();
if state.busy() {
return Err("updateBusy");
}
let saved = (|| -> Result<(), Box<dyn std::error::Error>> {
let directory = app.path().app_config_dir()?;
fs::create_dir_all(&directory)?;
let pending = directory.join("update-preferences.pending");
fs::write(&pending, serde_json::to_vec(&preferences)?)?;
fs::rename(pending, directory.join("update-preferences.json"))?;
Ok(())
})();
saved.map_err(|_| "preferencesError")?;
state.view.preferences = preferences;
drop(state);
refresh(app);
Ok(())
}
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
app.plugin(
tauri_plugin_updater::Builder::new()
.pubkey(&source().public_key)
.build(),
)?;
let path = app.path().app_config_dir()?.join("update-preferences.json");
let (preferences, error) = match fs::read(path) {
Ok(bytes) => match serde_json::from_slice::<Preferences>(&bytes) {
Ok(p) if p.valid() => (p, None),
_ => (
Preferences {
automatic: false,
..Default::default()
},
Some("preferencesError"),
),
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
(Preferences::default(), None)
}
Err(_) => (
Preferences {
automatic: false,
..Default::default()
},
Some("preferencesError"),
),
};
app.manage(Mutex::new(State {
view: View {
phase: if error.is_some() { "error" } else { "idle" },
current_version: app.package_info().version.to_string(),
build: crate::metadata::build_number().into(),
version: None,
notes: None,
downloaded: 0,
total: None,
error,
preferences,
},
update: None,
last_check: None,
}));
let handle = app.clone();
let window = WebviewWindowBuilder::new(app, "updates", WebviewUrl::App("updates.html".into()))
.title(crate::locale::en::UPDATES)
.inner_size(560.0, 690.0)
.min_inner_size(420.0, 580.0)
.center()
.visible(false)
.on_navigation(move |url| {
if !trusted_navigation(url) {
return false;
}
if url.path().starts_with("/updates/") {
action(&handle, url);
return false;
}
url.path() == "/updates.html"
})
.on_new_window(|_, _| NewWindowResponse::Deny)
.on_page_load(|window, payload| {
if payload.event() == PageLoadEvent::Finished {
refresh(window.app_handle());
}
})
.build()?;
let hide = window.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = hide.hide();
}
});
let handle = app.clone();
std::thread::spawn(move || loop {
check(&handle, false);
std::thread::sleep(Duration::from_secs(30));
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn state() -> State {
State {
view: View {
phase: "idle",
current_version: "1.0.0".into(),
build: "1".into(),
version: None,
notes: None,
downloaded: 0,
total: None,
error: None,
preferences: Preferences::default(),
},
update: None,
last_check: None,
}
}
#[test]
fn accepts_only_newer_stable_versions_and_exact_release_paths() {
assert!(newer_stable("1.9.9", "1.10.0"));
for version in ["0.9.0", "1.0.0", "1.2.0-beta", "1.2.0+build", "invalid"] {
assert!(!newer_stable("1.0.0", version));
}
let good = format!(
"{}/{}/1.1.0/KanbanGit-1.1.0-{}.app.tar.gz",
source().base_url,
target(),
target()
);
assert!(valid_download(
"1.0.0",
"1.1.0",
&Url::parse(&good).unwrap()
));
for input in [
good.replace("https:", "http:"),
good.replace(".app.tar.gz", ".dmg"),
good.replace("1.1.0/", "0.9.0/"),
good.replace(&target(), "foreign-apple-darwin"),
format!("{good}?token=anything"),
format!("{good}#fragment"),
good.replace("https://", "https://user@"),
"https://untrusted.example/update.app.tar.gz".into(),
] {
assert!(!valid_download(
"1.0.0",
"1.1.0",
&Url::parse(&input).unwrap()
));
}
}
#[test]
fn automatic_checks_start_once_then_respect_interval_and_opt_out() {
let mut state = state();
let now = Instant::now();
assert!(state.begin_check(false, now));
assert!(!state.begin_check(false, now));
state.view.phase = "current";
assert!(!state.begin_check(false, now + Duration::from_secs(6 * 3600 - 1)));
assert!(state.begin_check(false, now + Duration::from_secs(6 * 3600)));
state.view.phase = "current";
state.view.preferences.automatic = false;
assert!(!state.begin_check(false, now + Duration::from_secs(24 * 3600)));
assert!(state.begin_check(true, now + Duration::from_secs(24 * 3600)));
}
#[test]
fn manual_checks_retry_errors_but_never_overlap_downloads_or_installation() {
let mut state = state();
state.view.phase = "error";
state.view.error = Some("networkError");
assert!(state.begin_check(true, Instant::now()));
assert!(state.view.error.is_none());
for phase in [
"checking",
"downloading",
"verifying",
"installing",
"restarting",
] {
state.view.phase = phase;
assert!(!state.begin_check(true, Instant::now()));
}
}
#[test]
fn preferences_validate_and_round_trip_without_server_settings() {
let p = Preferences {
automatic: false,
interval_hours: 24,
};
let json = serde_json::to_string(&p).unwrap();
let loaded: Preferences = serde_json::from_str(&json).unwrap();
assert!(!loaded.automatic);
assert_eq!(loaded.interval_hours, 24);
assert!(loaded.valid());
for interval in [0, 2, 100, u64::MAX] {
assert!(!Preferences {
automatic: true,
interval_hours: interval
}
.valid());
}
}
#[test]
fn update_actions_are_confined_to_the_local_trusted_window() {
assert!(trusted_navigation(
&Url::parse("tauri://localhost/updates/install").unwrap()
));
for address in [
"https://kanbangit.de/updates/install",
"https://localhost/updates/install",
"tauri://user@localhost/updates/install",
"tauri://localhost:443/updates/install",
"tauri://other/updates/install",
] {
assert!(!trusted_navigation(&Url::parse(address).unwrap()));
}
}
#[test]
fn rejects_foreign_or_invalid_mach_o_headers_without_developer_tools() {
let cpu: u32 = if cfg!(target_arch = "aarch64") {
0x0100_000c
} else {
0x0100_0007
};
let mut header = vec![0xcf, 0xfa, 0xed, 0xfe];
header.extend(cpu.to_le_bytes());
assert!(matches_architecture(&header));
header[4] ^= 1;
assert!(!matches_architecture(&header));
assert!(!matches_architecture(b"MZ"));
assert!(!matches_architecture(&[0; 8]));
}
#[test]
fn error_categories_offer_specific_retryable_messages() {
use tauri_plugin_updater::Error;
assert_eq!(
update_error(&Error::Network("offline".into())),
"networkError"
);
assert_eq!(
update_error(&Error::TargetNotFound("darwin-other".into())),
"metadataError"
);
assert_eq!(
update_error(&Error::SignatureUtf8("invalid".into())),
"signatureError"
);
}
}
#[cfg(test)]
mod signed_upgrade_test {
use super::*;
use std::{io::Write, net::TcpListener};
#[test]
#[ignore = "Requires KANBANGIT_UPGRADE_BASE and KANBANGIT_UPGRADE_ARCHIVE pointing to two Developer-ID-signed macOS versions"]
fn installs_a_real_signed_upgrade_and_rejects_tampering() {
let base = std::env::var("KANBANGIT_UPGRADE_BASE").expect("Set the signed base app path");
let archive = std::env::var("KANBANGIT_UPGRADE_ARCHIVE")
.expect("Set the signed updater archive path");
let base_version = env!("CARGO_PKG_VERSION");
let mut next_version = Version::parse(base_version).unwrap();
next_version.patch += 1;
let candidate_version =
std::env::var("KANBANGIT_UPGRADE_VERSION").unwrap_or_else(|_| next_version.to_string());
assert!(newer_stable(base_version, &candidate_version));
let root = std::env::temp_dir().join(format!("kanbangit-upgrade-{}", std::process::id()));
fs::create_dir(&root).unwrap();
struct Cleanup(std::path::PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
let _cleanup = Cleanup(root.clone());
let installed = root.join("KanbanGit.app");
command("/usr/bin/ditto", &[&base, installed.to_str().unwrap()]).unwrap();
let data = root.join("user-data");
fs::create_dir(&data).unwrap();
fs::write(data.join("active-server"), "https://workspace.example/").unwrap();
fs::write(data.join("session"), "existing-session").unwrap();
let bytes = fs::read(&archive).unwrap();
let signature = fs::read_to_string(format!("{archive}.sig")).unwrap();
let verify_dir = root.join("verify");
fs::create_dir(&verify_dir).unwrap();
verify_bundle(Path::new(&archive), &verify_dir, &candidate_version).unwrap();
assert!(verify_bundle(Path::new(&archive), &verify_dir, base_version).is_err());
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let download = bytes.clone();
let announced_version = candidate_version.clone();
let server = std::thread::spawn(move || {
for index in 0..3 {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let mut request = [0; 4096];
let _ = stream.read(&mut request).unwrap();
let body = if index == 0 {
serde_json::to_vec(&serde_json::json!({"version":announced_version, "platforms":{(format!("darwin-{}", std::env::consts::ARCH)): {"url":format!("http://{address}/update.tar.gz"), "signature":signature.trim()}}})).unwrap()
} else if index == 1 {
b"tampered update".to_vec()
} else {
download.clone()
};
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.unwrap();
stream.write_all(&body).unwrap();
}
});
let mut context = crate::desktop::context();
context.config_mut().plugins.0.insert("updater".into(), serde_json::json!({"pubkey":source().public_key, "dangerousInsecureTransportProtocol":true}));
let app = tauri::test::mock_builder()
.plugin(tauri_plugin_updater::Builder::new().build())
.build(context)
.unwrap();
let update = tauri::async_runtime::block_on(async {
app.updater_builder()
.executable_path(installed.join("Contents/MacOS/kanbangit-native"))
.endpoints(vec![
Url::parse(&format!("http://{address}/latest.json")).unwrap()
])
.unwrap()
.timeout(Duration::from_secs(20))
.build()
.unwrap()
.check()
.await
.unwrap()
.unwrap()
});
assert_eq!(update.current_version, base_version);
let rejected =
tauri::async_runtime::block_on(update.download(|_, _| {}, || {})).unwrap_err();
assert_eq!(update_error(&rejected), "signatureError");
let plist = installed.join("Contents/Info.plist");
assert_eq!(
command(
"/usr/bin/plutil",
&[
"-extract",
"CFBundleShortVersionString",
"raw",
"-o",
"-",
plist.to_str().unwrap()
]
)
.unwrap(),
base_version
);
let verified = tauri::async_runtime::block_on(update.download(|_, _| {}, || {})).unwrap();
assert_eq!(verified, bytes);
update.install(verified).unwrap();
assert_eq!(
command(
"/usr/bin/plutil",
&[
"-extract",
"CFBundleShortVersionString",
"raw",
"-o",
"-",
plist.to_str().unwrap()
]
)
.unwrap(),
candidate_version
);
command(
"/usr/bin/codesign",
&[
"--verify",
"--deep",
"--strict",
installed.to_str().unwrap(),
],
)
.unwrap();
assert_eq!(
fs::read_to_string(data.join("active-server")).unwrap(),
"https://workspace.example/"
);
assert_eq!(
fs::read_to_string(data.join("session")).unwrap(),
"existing-session"
);
server.join().unwrap();
}
}
+32 -4
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit", "productName": "KanbanGit",
"version": "0.1.0", "version": "0.2.8",
"identifier": "de.kanbangit.desktop", "identifier": "de.kanbangit.desktop",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
@@ -16,10 +16,38 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["app"], "targets": [
"icon": ["icons/icon.png", "icons/icon.icns"], "app"
],
"icon": [
"icons/icon.png",
"icons/icon.icns"
],
"macOS": { "macOS": {
"minimumSystemVersion": "14.0" "minimumSystemVersion": "14.0",
"bundleVersion": "12",
"dmg": {
"windowSize": {
"width": 660,
"height": 400
},
"appPosition": {
"x": 180,
"y": 170
},
"applicationFolderPosition": {
"x": 480,
"y": 170
}
}
},
"android": {
"versionCode": 2004
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhBQzA0QjhENTI3OEE1QzAKUldUQXBYaFNqVXZBaXJVWmErWUZTNVp6QnllT25NWFMyemxqcFBnYnJHT3JBSWxrSkVYU1VVMFEK"
} }
} }
} }
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://schema.tauri.app/config/2",
"identifier": "de.kanbangit.ios",
"bundle": {
"iOS": {
"minimumSystemVersion": "17.0",
"developmentTeam": "ZZX2KZGTHU",
"bundleVersion": "7"
}
}
}
+145 -15
View File
@@ -6,18 +6,65 @@ fn main() {
}; };
use tauri::{webview::PageLoadEvent, Manager}; use tauri::{webview::PageLoadEvent, Manager};
let startup = std::env::var("KANBANGIT_SMOKE_STARTUP").unwrap_or_default();
let online = startup == "online";
let needs_setup = matches!(startup.as_str(), "missing" | "invalid");
let passed = Arc::new(AtomicBool::new(false)); let passed = Arc::new(AtomicBool::new(false));
let stage = Arc::new(AtomicUsize::new(0)); let stage = Arc::new(AtomicUsize::new(0));
let page_stage = stage.clone(); let page_stage = stage.clone();
let result_passed = passed.clone(); let result_passed = passed.clone();
let setup_passed = passed.clone();
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke") let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/__native_smoke_result?passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);") .setup(move |app, _| {
let directory = app.path().app_config_dir()?;
std::fs::create_dir_all(&directory)?;
std::fs::write(directory.join("update-preferences.json"), r#"{"automatic":false,"intervalHours":6}"#)?;
if needs_setup {
if startup == "invalid" {
std::fs::write(directory.join("active-server"), "invalid configuration")?;
}
return Ok(());
}
let server = if online { "https://kanbangit.de/" } else { "https://127.0.0.1:9/" };
std::fs::write(directory.join("active-server"), format!("{server}\n0123456789abcdef0123456789abcdef"))?;
Ok(())
})
.js_init_script(r#"
if (location.protocol === 'tauri:') {
window.__selectionFlashed = false;
new MutationObserver(() => {
const selection = document.querySelector('#workspace-settings');
const phase = document.querySelector('#connection-status')?.dataset.state;
if (selection && !selection.hidden && phase !== 'connectionError') window.__selectionFlashed = true;
}).observe(document, { subtree: true, attributes: true, childList: true });
}
"#)
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/?smoke=result&passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
.on_page_load(move |webview, payload| { .on_page_load(move |webview, payload| {
eprintln!("Smoke load: {} {:?} {}", webview.label(), payload.event(), payload.url());
if payload.event() != PageLoadEvent::Finished { if payload.event() != PageLoadEvent::Finished {
return; return;
} }
if webview.label() == "launcher" { if webview.label() == "launcher" {
if needs_setup {
let app = webview.app_handle().clone();
let passed = setup_passed.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(200));
let handle = app.clone();
app.run_on_main_thread(move || {
let launcher = handle.get_webview_window("launcher").unwrap();
assert!(launcher.is_visible().unwrap(), "First launch and invalid configuration must show setup");
assert!(!handle.webview_windows().keys().any(|label| label.starts_with("workspace-")));
passed.store(true, Ordering::SeqCst);
launcher.close().unwrap();
}).unwrap();
});
return;
}
assert!(!webview.app_handle().get_webview_window("launcher").unwrap().is_visible().unwrap(), "Saved startup must not show the native launcher window");
if online { return; }
webview.eval(r#" webview.eval(r#"
(async () => { (async () => {
const wait = async predicate => { const wait = async predicate => {
@@ -28,7 +75,9 @@ fn main() {
throw new Error('Timed out waiting for local state'); throw new Error('Timed out waiting for local state');
}; };
try { try {
await wait(() => document.querySelector('.open-workspace')?.textContent === 'Open workspace'); await wait(() => document.querySelector('#connection-status')?.dataset.state === 'connectionError');
if (window.__selectionFlashed) throw new Error('Workspace selection flashed during saved startup');
if (document.querySelector('#server-url').value !== 'https://127.0.0.1:9/') throw new Error('Saved workspace was discarded');
const input = document.querySelector('#server-url'); const input = document.querySelector('#server-url');
const form = document.querySelector('#server-form'); const form = document.querySelector('#server-form');
const status = document.querySelector('#connection-status'); const status = document.querySelector('#connection-status');
@@ -43,6 +92,7 @@ fn main() {
} }
input.value = 'https://127.0.0.1:9/'; input.value = 'https://127.0.0.1:9/';
form.requestSubmit(); form.requestSubmit();
await wait(() => status.dataset.state === 'connecting');
await wait(() => status.dataset.state === 'connectionError'); await wait(() => status.dataset.state === 'connectionError');
if (input.value !== 'https://127.0.0.1:9/' || document.querySelector('.open-workspace').disabled) throw new Error('Offline recovery failed'); if (input.value !== 'https://127.0.0.1:9/' || document.querySelector('.open-workspace').disabled) throw new Error('Offline recovery failed');
input.value = 'https://kanbangit.de/'; input.value = 'https://kanbangit.de/';
@@ -53,7 +103,7 @@ fn main() {
form.requestSubmit(); form.requestSubmit();
document.querySelector('#confirm-switch').click(); document.querySelector('#confirm-switch').click();
} catch (error) { } catch (error) {
location.href = 'tauri://localhost/__native_smoke_result?passed=false&reason=' + encodeURIComponent(error.message); location.href = 'tauri://localhost/?smoke=result&passed=false&reason=' + encodeURIComponent(error.message);
} }
})(); })();
"#).unwrap(); "#).unwrap();
@@ -69,16 +119,34 @@ fn main() {
const rendered = document.querySelector('#app')?.innerText.trim().length > 100; const rendered = document.querySelector('#app')?.innerText.trim().length > 100;
let denied = true; let denied = true;
for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) { for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) {
try { await window.__TAURI_INTERNALS__.invoke(cmd); denied = false; } try { await Promise.race([window.__TAURI_INTERNALS__.invoke(cmd), new Promise((_, reject) => setTimeout(() => reject(new Error('IPC timeout')), 3000))]); denied = false; }
catch (error) { denied = denied && String(error).includes('not allowed'); } catch (error) { denied = denied && String(error).includes('not allowed'); }
} }
const api = window.kanbanNative;
if (api?.version !== 1) throw new Error('Versioned settings bridge missing');
const initial = await api.request('getState');
if (initial.platform !== 'macos' || initial.workspace !== 'https://kanbangit.de/' || !initial.capabilities.includes('updates')) throw new Error('Incorrect native settings');
await api.request('setUpdatePreferences', {automatic:false, intervalHours:24});
const changed = await api.request('getState');
if (changed.updates.preferences.automatic || changed.updates.preferences.intervalHours !== 24) throw new Error('Update preferences did not persist');
const invalid = await Promise.race([window.webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:5,action:'shell',payload:{}})), new Promise((_, reject) => setTimeout(() => reject(new Error('Raw bridge timeout')), 3000))]);
if (!JSON.parse(invalid).error) throw new Error('Unknown native action accepted');
const frame = document.createElement('iframe');
document.body.append(frame);
if (!frame.contentWindow.webkit?.messageHandlers.kanbanSettings) throw new Error('Frame bridge fixture missing');
let frameDenied = false;
try { await Promise.race([frame.contentWindow.webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:6,action:'getState',payload:{}})), new Promise((_, reject) => setTimeout(() => reject(new Error('Frame bridge timeout')), 3000))]); }
catch (error) { frameDenied = String(error).includes('untrustedFrame'); }
if (!frameDenied) throw new Error('Subframe settings access accepted');
const input = document.createElement('input'); const input = document.createElement('input');
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input); input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax'; document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
localStorage.setItem('kan52_fixture', 'isolated'); localStorage.setItem('kan52_fixture', 'isolated');
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined'; if (typeof window.__kanbanNavigation !== 'undefined') throw new Error('Custom gesture bridge must be removed');
if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok; location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
})(); })().catch(error => { location.href = '/__native_smoke_result?passed=false&reason=' + encodeURIComponent(String(error)); });
"#).unwrap(); "#).unwrap();
} else { } else {
webview.eval(r#" webview.eval(r#"
@@ -90,7 +158,8 @@ fn main() {
}) })
.on_navigation(move |webview, url| { .on_navigation(move |webview, url| {
if url.path() != "/__native_smoke_result" { eprintln!("Smoke navigation: {} {}", webview.label(), url);
if url.path() != "/__native_smoke_result" && !url.query_pairs().any(|(key, value)| key == "smoke" && value == "result") {
return true; return true;
} }
let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true"); let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true");
@@ -109,17 +178,25 @@ fn main() {
location.href = '/__native_smoke_result?passed=false&stage=preservation'; location.href = '/__native_smoke_result?passed=false&stage=preservation';
} }
"#).unwrap(); "#).unwrap();
let launcher = app.get_webview_window("launcher").unwrap(); window.eval(r#"
launcher.show().unwrap(); window.kanbanNative.request('setWorkspace', {server:'https://kanbangit.de/login/'}).catch(error => {
launcher.eval(r#" location.href = '/__native_smoke_result?passed=false&stage=settings-switch&reason=' + encodeURIComponent(error.message);
document.querySelector('#server-url').value = 'https://kanbangit.de/login/'; });
document.querySelector('#server-form').requestSubmit();
document.querySelector('#confirm-switch').click();
"#).unwrap(); "#).unwrap();
}); });
} else { } else {
result_passed.store(success, Ordering::SeqCst); result_passed.store(success, Ordering::SeqCst);
webview.app_handle().exit(if success { 0 } else { 1 }); if success {
let app = webview.app_handle().clone();
let label = webview.label().to_string();
let handle = app.clone();
app.run_on_main_thread(move || {
assert!(!handle.get_webview_window("launcher").unwrap().is_visible().unwrap());
handle.get_webview_window(&label).unwrap().close().unwrap();
}).unwrap();
} else {
webview.app_handle().exit(1);
}
} }
false false
}) })
@@ -130,6 +207,25 @@ fn main() {
.plugin(plugin) .plugin(plugin)
.build(context) .build(context)
.expect("The macOS application must start"); .expect("The macOS application must start");
let visibility_handle = app.handle().clone();
let visibility_stage = stage.clone();
let visibility_check = std::thread::spawn(move || {
if needs_setup {
return;
}
for _ in 0..30 {
if visibility_stage.load(Ordering::SeqCst) > 0 {
break;
}
if let Some(launcher) = visibility_handle.get_webview_window("launcher") {
assert!(
!launcher.is_visible().unwrap(),
"Launcher flashed while the workspace was loading"
);
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
});
let config_dir = app.path().app_config_dir().unwrap(); let config_dir = app.path().app_config_dir().unwrap();
let handle = app.handle().clone(); let handle = app.handle().clone();
std::thread::spawn(move || { std::thread::spawn(move || {
@@ -137,7 +233,41 @@ fn main() {
eprintln!("macOS smoke test timed out"); eprintln!("macOS smoke test timed out");
handle.exit(1); handle.exit(1);
}); });
app.run_return(|_, _| {}); let closed = Arc::new(AtomicBool::new(false));
let closed_result = closed.clone();
let exit_code = app.run_return(move |app, event| {
if let tauri::RunEvent::ExitRequested { code: Some(0), .. } = event {
closed_result.store(true, Ordering::SeqCst);
assert!(
needs_setup
|| !app
.get_webview_window("launcher")
.unwrap()
.is_visible()
.unwrap(),
"Closing the workspace must not reopen settings"
);
}
});
visibility_check.join().unwrap();
assert_eq!(exit_code, 0);
assert!(
closed.load(Ordering::SeqCst),
"The close button must quit the application"
);
if online {
assert!(std::fs::read_to_string(config_dir.join("active-server"))
.unwrap()
.starts_with("https://kanbangit.de/login/\n"));
let preferences: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(config_dir.join("update-preferences.json")).unwrap(),
)
.unwrap();
assert_eq!(
preferences,
serde_json::json!({"automatic": false, "intervalHours": 24})
);
}
let _ = std::fs::remove_dir_all(config_dir); let _ = std::fs::remove_dir_all(config_dir);
assert!( assert!(
passed.load(Ordering::SeqCst), passed.load(Ordering::SeqCst),
+110
View File
@@ -0,0 +1,110 @@
#[cfg(target_os = "macos")]
#[tauri::command]
fn updater_smoke_menu(app: tauri::AppHandle) -> Result<(), String> {
let submenu = app.menu().unwrap().get("app-menu").unwrap();
let item = submenu.as_submenu().unwrap().get("install-update").unwrap();
let item = item.as_menuitem().unwrap();
for (available, busy, expected) in [
(false, false, false), // No update.
(true, false, true), // Available update, also after a retryable failure.
(true, true, false), // Download or installation in progress.
(true, false, true), // Retry after a failed download.
(false, true, false), // A new check clears the previous update.
(false, false, false), // Up to date.
] {
kanbangit_native::refresh_update_menu_for_smoke_test(&app, available, busy);
if item.is_enabled().map_err(|error| error.to_string())? != expected {
return Err(format!(
"Native install menu has wrong state: available={available}, busy={busy}"
));
}
}
Ok(())
}
#[cfg(target_os = "macos")]
#[tauri::command]
fn updater_smoke_report(app: tauri::AppHandle, passed: bool, reason: String) {
use tauri::Manager;
println!("Updater WebView test: passed={passed}, {reason}");
app.state::<std::sync::Arc<std::sync::atomic::AtomicBool>>()
.store(passed, std::sync::atomic::Ordering::SeqCst);
app.exit(if passed { 0 } else { 1 });
}
#[cfg(target_os = "macos")]
fn main() {
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use tauri::webview::PageLoadEvent;
let passed = Arc::new(AtomicBool::new(false));
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
let expected_installed = format!(
"Installed version {} (build {})",
config["version"].as_str().unwrap(),
config["bundle"]["macOS"]["bundleVersion"].as_str().unwrap()
);
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("updater-smoke")
.js_init_script(format!("window.expectedInstalled = {};", serde_json::to_string(&expected_installed).unwrap()))
.on_page_load(|window, payload| {
if window.label() != "updates" || payload.event() != PageLoadEvent::Finished { return; }
window.eval(r#"
(async () => {
try {
for (let i = 0; i < 100 && !window.updateStatus; i++) await new Promise(r => setTimeout(r, 50));
if (!window.updateStatus) throw new Error('Update UI did not load');
const $ = id => document.getElementById(id);
for (let i = 0; i < 100 && !$('installed').textContent; i++) await new Promise(r => setTimeout(r, 50));
if ($('installed').textContent !== window.expectedInstalled) throw new Error('Actual installed version/build failed: ' + $('installed').textContent);
await window.__TAURI_INTERNALS__.invoke('updater_smoke_menu');
const state = { phase: 'current', currentVersion: '0.1.2', build: '2', version: null, notes: null, downloaded: 0, total: null, error: null, preferences: { automatic: true, intervalHours: 6 } };
window.updateStatus(state);
if (!$('installed').textContent.includes('0.1.2 (build 2)') || !$('install').hidden || $('check').disabled) throw new Error('Current version state failed');
Object.assign(state, { phase: 'available', version: '0.1.3', notes: '<script>unsafe()</script>\nRelease notes' });
window.updateStatus(state);
if ($('install').hidden || $('release-notes').querySelector('script') || !$('release-notes').textContent.includes('<script>')) throw new Error('Available state or text escaping failed');
$('install').click();
if ($('install-confirmation').hidden || !$('install-confirmation').textContent.includes('Unsaved inputs')) throw new Error('Missing restart confirmation');
$('cancel-install').click();
if (!$('install-confirmation').hidden) throw new Error('Cancel failed');
Object.assign(state, { phase: 'downloading', downloaded: 500, total: 1000 }); window.updateStatus(state);
if (!$('check').disabled || !$('install').hidden || $('download-progress').value !== 500) throw new Error('Progress state failed');
Object.assign(state, { phase: 'error', error: 'signatureError' }); window.updateStatus(state);
if ($('install').hidden || !$('update-error').textContent.includes('Nothing was installed')) throw new Error('Retry failed');
state.preferences.automatic = false; window.updateStatus(state);
if ($('automatic').checked || !$('interval').disabled) throw new Error('Opt out failed');
await window.__TAURI_INTERNALS__.invoke('updater_smoke_report', { passed: true, reason: '' });
} catch (error) {
await window.__TAURI_INTERNALS__.invoke('updater_smoke_report', { passed: false, reason: error.message });
}
})();
"#).unwrap();
})
.build();
let mut context = kanbangit_native::context();
context.config_mut().identifier = format!("de.kanbangit.smoke.updates.{}", std::process::id());
let app = kanbangit_native::builder()
.manage(passed.clone())
.invoke_handler(tauri::generate_handler![
updater_smoke_report,
updater_smoke_menu
])
.plugin(plugin)
.build(context)
.expect("App must start");
let handle = app.handle().clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(30));
handle.exit(1);
});
app.run_return(|_, _| {});
assert!(passed.load(Ordering::SeqCst), "Updater UI test must pass");
}
#[cfg(not(target_os = "macos"))]
fn main() {
panic!("Requires macOS");
}
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test';
import { conditionalEtag } from '../publish-macos.mjs';
test(
'Hetzner conditional writes accept a bare ETag and reject a stale ETag',
{
skip: process.env.KANBANGIT_S3_INTEGRATION !== '1',
},
() => {
const bucket = process.env.AWS_BUCKET;
assert(bucket, 'Set AWS_BUCKET to a test bucket');
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-s3-etag-'));
const key = `kanbangit-integration-tests/${randomUUID()}.txt`;
const file = join(directory, 'object.txt');
const downloaded = join(directory, 'downloaded.txt');
const run = (operation, args) => {
const result = spawnSync(
'aws',
[
'--debug',
'--endpoint-url',
process.env.AWS_ENDPOINT,
'--region',
process.env.AWS_DEFAULT_REGION,
'--no-cli-pager',
's3api',
operation,
'--bucket',
bucket,
'--key',
key,
...args,
],
{ encoding: 'utf8', timeout: 30000 },
);
// Never expose debug headers: they contain the signed Authorization header.
return {
status: result.status,
stdout: result.stdout,
codes: [
...(result.stderr || '').matchAll(/<Code>([^<]*)<\/Code>/g),
].map((match) => match[1]),
};
};
let created = false;
try {
writeFileSync(file, 'first');
const initial = run('put-object', [
'--body',
file,
'--if-none-match',
'*',
]);
assert.equal(initial.status, 0, `Create failed: ${initial.codes}`);
created = true;
const read = run('get-object', ['--output', 'json', downloaded]);
assert.equal(read.status, 0, `Read failed: ${read.codes}`);
const etag = JSON.parse(read.stdout).ETag;
const quoted = run('put-object', [
'--body',
file,
'--if-match',
etag,
]);
console.log(
`Quoted ETag result: exit ${quoted.status}, S3 ${quoted.codes.join(', ')}`,
);
const bare = conditionalEtag(etag);
writeFileSync(file, 'second');
const updated = run('put-object', [
'--body',
file,
'--if-match',
bare,
]);
assert.equal(
updated.status,
0,
`Bare ETag replacement failed: ${updated.codes}`,
);
writeFileSync(file, 'stale writer');
const stale = run('put-object', [
'--body',
file,
'--if-match',
bare,
]);
assert.notEqual(stale.status, 0);
assert(
stale.codes.includes('PreconditionFailed'),
`Unexpected stale error: ${stale.codes}`,
);
assert.equal(run('get-object', [downloaded]).status, 0);
assert.equal(readFileSync(downloaded, 'utf8'), 'second');
} finally {
try {
if (created) {
const cleanup = run('delete-object', []);
assert.equal(
cleanup.status,
0,
`Cleanup failed: ${cleanup.codes}`,
);
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
},
);
+534
View File
@@ -0,0 +1,534 @@
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
import { createHash } from 'node:crypto';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import {
appleTeamId,
buildIos,
bundleIdentifier,
selectIdentity,
validateProfile,
} from '../build-ios.mjs';
const certificate = Buffer.from('synthetic test certificate');
const fingerprint = createHash('sha1')
.update(certificate)
.digest('hex')
.toUpperCase();
function profileFor(mode = 'testflight') {
return {
UUID: '00000000-1111-2222-3333-444444444444',
TeamIdentifier: [appleTeamId],
Platform: ['iOS'],
ExpirationDate: '2099-01-01T00:00:00Z',
DeveloperCertificates: [certificate.toString('base64')],
...(mode === 'development'
? { ProvisionedDevices: ['test-device'] }
: {}),
Entitlements: {
'application-identifier': `${appleTeamId}.${bundleIdentifier}`,
'com.apple.developer.team-identifier': appleTeamId,
'get-task-allow': mode === 'development',
'beta-reports-active': mode === 'testflight',
},
};
}
function fixture(t, mode = 'testflight', overrides = {}) {
const root = mkdtempSync(join(tmpdir(), 'kanbangit-ios-test-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const directory = join(root, 'repo/native');
const profilePath = join(root, 'profile.mobileprovision');
const config = {
productName: 'KanbanGit',
identifier: 'de.kanbangit.desktop',
version: '1.2.3',
};
const ios = {
identifier: bundleIdentifier,
bundle: {
iOS: {
developmentTeam: appleTeamId,
bundleVersion: '7',
minimumSystemVersion: '17.0',
},
},
};
mkdirSync(join(directory, 'src-tauri'), { recursive: true });
writeFileSync(
join(directory, 'src-tauri/tauri.conf.json'),
JSON.stringify(config),
);
writeFileSync(
join(directory, 'src-tauri/tauri.ios.conf.json'),
JSON.stringify(ios),
);
writeFileSync(profilePath, 'external provisioning profile');
const profile = profileFor(mode);
const calls = [];
const destination = join(directory, `src-tauri/target/ios-${mode}/1.2.3-7`);
const home = join(root, 'home');
const installedProfile = join(
home,
`Library/Developer/Xcode/UserData/Provisioning Profiles/${profile.UUID}.mobileprovision`,
);
const info = {
CFBundleIdentifier: bundleIdentifier,
CFBundleShortVersionString: '1.2.3',
CFBundleVersion: '7',
};
function app(path) {
mkdirSync(path, { recursive: true });
writeFileSync(
join(path, 'Info.plist'),
JSON.stringify({ ...info, ...overrides.info }),
);
}
const options = {
directory,
home,
mode,
platform: 'darwin',
log() {},
env: { PATH: '/test/bin', IOS_PROVISIONING_PROFILE: profilePath },
run(command, args, settings) {
calls.push({ command, args, settings });
if (overrides.failStep === settings.step) {
throw new Error('Simulated build failure');
}
if (command === 'python3') {
return settings.input.toString();
}
if (command === 'security') {
if (args[0] === 'cms') {
const embedded = args
.at(-1)
.endsWith('embedded.mobileprovision');
return JSON.stringify(
embedded && overrides.embeddedProfile
? overrides.embeddedProfile
: profile,
);
}
return `1) ${fingerprint} "Apple ${mode === 'development' ? 'Development' : 'Distribution'}: Example (${appleTeamId})"`;
}
if (command === 'rustup') {
if (args[0] === 'target') {
return (
overrides.targets ??
'aarch64-apple-ios\naarch64-apple-ios-sim'
);
}
return `/permanent/toolchain/bin/${args[1]}`;
}
if (command === '/permanent/toolchain/bin/cargo') {
const library = join(
directory,
'src-tauri/target',
args[args.indexOf('--target') + 1],
args.includes('--release') ? 'release' : 'debug',
'libkanbangit_native.a',
);
mkdirSync(dirname(library), { recursive: true });
writeFileSync(library, 'compiled library');
}
if (command === 'xcodebuild' && args.includes('archive')) {
assert.ok(mode === 'simulator' || existsSync(installedProfile));
app(
join(
args[args.indexOf('-archivePath') + 1],
'Products/Applications/KanbanGit.app',
),
);
}
if (command === 'xcodebuild' && args.includes('-exportArchive')) {
const output = args[args.indexOf('-exportPath') + 1];
mkdirSync(output, { recursive: true });
if (!overrides.missingIpa) {
writeFileSync(
join(output, 'KanbanGit.ipa'),
'signed ipa bytes',
);
}
}
if (command === 'ditto') {
app(join(args.at(-1), 'Payload/KanbanGit.app'));
}
if (command === 'xcrun') {
return '/permanent/Xcode/SDK';
}
return '';
},
};
return {
options,
calls,
profile,
profilePath,
destination,
installedProfile,
ios,
};
}
test('TestFlight produces a verified local IPA with manual signing and no Apple mutation or upload flags', (t) => {
const f = fixture(t);
f.options.env.APPLE_API_KEY = 'must-not-reach-build';
f.options.env.IOS_CERTIFICATE = 'must-not-reach-build';
assert.equal(buildIos(f.options), f.destination);
const options = JSON.parse(
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
);
assert.equal(options.method, 'app-store-connect');
assert.equal(options.destination, 'export');
assert.equal(options.signingStyle, 'manual');
assert.equal(options.manageAppVersionAndBuildNumber, false);
assert.equal(options.signingCertificate, fingerprint);
assert.deepEqual(options.provisioningProfiles, {
[bundleIdentifier]: f.profile.UUID,
});
assert.equal(
readFileSync(
join(f.destination, 'export/KanbanGit.ipa.sha256'),
'utf8',
),
`${createHash('sha256').update('signed ipa bytes').digest('hex')} KanbanGit.ipa\n`,
);
assert.equal(
f.calls.filter(
(call) =>
call.command === 'codesign' && call.args.includes('--verify'),
).length,
2,
);
assert.equal(existsSync(f.installedProfile), false);
const verification = f.calls.find((call) => call.command === 'codesign');
const requirement = verification.args[verification.args.indexOf('-R') + 1];
assert.ok(requirement.includes('! entitlement["get-task-allow"] exists'));
assert.ok(
requirement.includes('entitlement["beta-reports-active"] exists'),
);
assert.ok(requirement.includes(`certificate leaf = H"${fingerprint}"`));
for (const call of f.calls) {
assert.equal(call.settings.env.APPLE_API_KEY, undefined);
assert.equal(call.settings.env.IOS_CERTIFICATE, undefined);
assert.ok(
!call.args.some((arg) =>
/allowProvisioning|authenticationKey|upload|downloadPlatform/.test(
arg,
),
),
);
}
const archive = f.calls.find((call) => call.args.includes('archive'));
assert.ok(archive.args.includes('CODE_SIGN_STYLE=Manual'));
assert.ok(
archive.args.includes(
`PROVISIONING_PROFILE_SPECIFIER=${f.profile.UUID}`,
),
);
assert.ok(
archive.settings.env.KANBANGIT_IOS_LIBRARY.endsWith(
'/aarch64-apple-ios/release/libkanbangit_native.a',
),
);
});
test('the simulator needs no certificate or profile and disables signing explicitly', (t) => {
const f = fixture(t, 'simulator');
delete f.options.env.IOS_PROVISIONING_PROFILE;
buildIos(f.options);
assert.ok(
f.calls.some((call) => call.args.includes('CODE_SIGNING_ALLOWED=NO')),
);
assert.ok(
f.calls.some((call) => call.args.includes('aarch64-apple-ios-sim')),
);
assert.ok(
!f.calls.some(
(call) =>
['security', 'codesign'].includes(call.command) ||
call.args.includes('-exportArchive'),
),
);
});
test('local device development uses a registered-device development profile', (t) => {
const f = fixture(t, 'development');
buildIos(f.options);
const options = JSON.parse(
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
);
assert.equal(options.method, 'debugging');
assert.equal(options.destination, 'export');
});
for (const [name, change, message] of [
[
'different team',
(p) => {
p.TeamIdentifier = ['OLDTEAM'];
},
/explicitly match/,
],
[
'wildcard identifier',
(p) => {
p.Entitlements['application-identifier'] = `${appleTeamId}.*`;
},
/explicitly match/,
],
[
'macOS identifier',
(p) => {
p.Entitlements['application-identifier'] =
`${appleTeamId}.de.kanbangit.desktop`;
},
/explicitly match/,
],
[
'expired profile',
(p) => {
p.ExpirationDate = '2000-01-01';
},
/expired/,
],
[
'invalid expiry',
(p) => {
p.ExpirationDate = 'invalid';
},
/expired/,
],
[
'invalid UUID',
(p) => {
p.UUID = '../unexpected';
},
/invalid/,
],
[
'missing certificate',
(p) => {
p.DeveloperCertificates = [];
},
/invalid/,
],
[
'wrong platform',
(p) => {
p.Platform = ['OSX'];
},
/iOS profile/,
],
[
'Ad Hoc profile',
(p) => {
p.ProvisionedDevices = ['device'];
},
/App Store Connect/,
],
[
'enterprise profile',
(p) => {
p.ProvisionsAllDevices = true;
},
/App Store Connect/,
],
[
'development entitlement',
(p) => {
p.Entitlements['get-task-allow'] = true;
},
/App Store Connect/,
],
[
'missing beta entitlement',
(p) => {
delete p.Entitlements['beta-reports-active'];
},
/App Store Connect/,
],
]) {
test(`rejects ${name} before signing`, () => {
const profile = profileFor();
change(profile);
assert.throws(() => validateProfile(profile, 'testflight'), message);
});
}
test('a distribution profile cannot be used for device development', () => {
assert.throws(
() => validateProfile(profileFor(), 'development'),
/App Development/,
);
});
test('identity selection requires the profile certificate and a usable private key of the correct kind', () => {
const profile = profileFor();
for (const identities of [
'0 valid identities found',
`1) ${'A'.repeat(40)} "Apple Distribution: Other"`,
`1) ${fingerprint} "Developer ID Application: Example"`,
`1) ${fingerprint} "Apple Development: Example"`,
]) {
assert.throws(
() => selectIdentity(profile, identities, 'testflight'),
/private key/,
);
}
});
for (const step of [
'Generating the Xcode project',
'Building the iOS Rust library',
'Archiving locally without Apple provisioning updates',
'Verifying the Apple signature and team',
'Exporting the signed IPA locally (no upload)',
]) {
test(`failure at ${step} does not publish a successful build and cleans its profile`, (t) => {
const f = fixture(t, 'testflight', { failStep: step });
assert.throws(() => buildIos(f.options), /Simulated build failure/);
assert.equal(existsSync(f.destination), false);
assert.equal(existsSync(f.installedProfile), false);
});
}
test('an existing identical local profile is preserved after the build', (t) => {
const f = fixture(t);
mkdirSync(dirname(f.installedProfile), { recursive: true });
writeFileSync(f.installedProfile, readFileSync(f.profilePath));
buildIos(f.options);
assert.equal(existsSync(f.installedProfile), true);
});
test('a conflicting local profile is never overwritten or deleted', (t) => {
const f = fixture(t);
mkdirSync(dirname(f.installedProfile), { recursive: true });
writeFileSync(f.installedProfile, 'user profile');
assert.throws(() => buildIos(f.options), /same UUID/);
assert.equal(readFileSync(f.installedProfile, 'utf8'), 'user profile');
});
test('missing toolchain targets fail before compilation without installing anything', (t) => {
const f = fixture(t, 'testflight', { targets: 'aarch64-apple-darwin' });
assert.throws(
() => buildIos(f.options),
/rustup target add aarch64-apple-ios/,
);
assert.ok(
!f.calls.some(
(call) => call.args.includes('build') || call.args.includes('add'),
),
);
});
test('missing profile and ambient team overrides fail clearly', (t) => {
const f = fixture(t);
delete f.options.env.IOS_PROVISIONING_PROFILE;
assert.throws(() => buildIos(f.options), /IOS_PROVISIONING_PROFILE/);
f.options.env.APPLE_DEVELOPMENT_TEAM = 'OLDTEAM';
assert.throws(() => buildIos(f.options), /must be ZZX2KZGTHU/);
delete f.options.env.APPLE_DEVELOPMENT_TEAM;
f.options.env.TAURI_CONFIG = '{}';
assert.throws(() => buildIos(f.options), /Remove TAURI_CONFIG/);
});
for (const [name, overrides, message] of [
[
'wrong app version',
{ info: { CFBundleVersion: '8' } },
/unexpected identifier, version/,
],
[
'changed embedded profile',
{
embeddedProfile: {
...profileFor(),
UUID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
},
},
/unexpected provisioning profile/,
],
['missing IPA', { missingIpa: true }, /did not produce/],
]) {
test(`rejects ${name} in the build output`, (t) => {
const f = fixture(t, 'testflight', overrides);
assert.throws(() => buildIos(f.options), message);
assert.equal(existsSync(f.destination), false);
});
}
test('completed artifacts are not silently overwritten', (t) => {
const f = fixture(t);
buildIos(f.options);
assert.throws(() => buildIos(f.options), /already exists/);
});
for (const mode of ['simulator', 'development']) {
test(`${mode} can rebuild the same version and preserves the previous build on failure`, (t) => {
const overrides = {};
const f = fixture(t, mode, overrides);
buildIos(f.options);
const previousBuild = join(f.destination, 'previous-build');
writeFileSync(previousBuild, 'verified artifact');
overrides.failStep = 'Building the iOS Rust library';
assert.throws(() => buildIos(f.options), /Simulated build failure/);
assert.equal(readFileSync(previousBuild, 'utf8'), 'verified artifact');
delete overrides.failStep;
buildIos(f.options);
assert.equal(existsSync(previousBuild), false);
assert.ok(existsSync(join(f.destination, 'KanbanGit.xcarchive')));
assert.equal(existsSync(f.installedProfile), false);
});
}
test('every iOS icon has its declared dimensions and contains no alpha channel or transparency chunk', () => {
const assets = fileURLToPath(
new URL(
'../src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/',
import.meta.url,
),
);
const catalog = JSON.parse(
readFileSync(join(assets, 'Contents.json'), 'utf8'),
);
assert.ok(
catalog.images.some(
(icon) =>
icon.idiom === 'ios-marketing' && icon.size === '1024x1024',
),
);
for (const icon of catalog.images) {
const png = readFileSync(join(assets, icon.filename));
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
const [width, height] = icon.size.split('x').map(Number);
const scale = Number.parseInt(icon.scale, 10);
assert.equal(png.readUInt32BE(16), width * scale, icon.filename);
assert.equal(png.readUInt32BE(20), height * scale, icon.filename);
assert.equal(png[25], 2, `${icon.filename} must use RGB without alpha`);
for (
let offset = 8;
offset < png.length;
offset += png.readUInt32BE(offset) + 12
) {
assert.notEqual(
png.subarray(offset + 4, offset + 8).toString(),
'tRNS',
icon.filename,
);
}
}
});
+7 -6
View File
@@ -62,6 +62,12 @@ const latestAction = async (name) => {
}; };
try { try {
await page.goto(`http://127.0.0.1:${server.address().port}/`); await page.goto(`http://127.0.0.1:${server.address().port}/`);
assert.equal(await page.locator('#workspace-settings').isVisible(), false);
await state('connecting');
await page.waitForTimeout(500);
assert.equal(await page.locator('#workspace-settings').isVisible(), false);
assert.equal(await page.locator('#startup-loading').isVisible(), true);
await page.reload();
await state('idle'); await state('idle');
for (const width of [320, 390, 1440]) { for (const width of [320, 390, 1440]) {
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
@@ -98,12 +104,7 @@ try {
'https://first.example.com/', 'https://first.example.com/',
); );
await state('connecting', false, 'https://first.example.com/'); await state('connecting', false, 'https://first.example.com/');
assert.equal( assert.equal(await page.locator('.open-workspace').isDisabled(), true);
await page
.getByRole('button', { name: 'Open workspace', exact: true })
.isDisabled(),
true,
);
await state('connectionError', false, 'https://first.example.com/'); await state('connectionError', false, 'https://first.example.com/');
assert.equal( assert.equal(
await page.getByLabel('Server address').inputValue(), await page.getByLabel('Server address').inputValue(),

Some files were not shown because too many files have changed in this diff Show More