palmier 0.8.1 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CLAUDE.md +13 -0
  2. package/README.md +16 -14
  3. package/dist/agents/agent.d.ts +0 -4
  4. package/dist/agents/claude.js +1 -1
  5. package/dist/agents/codex.js +2 -2
  6. package/dist/agents/cursor.js +1 -1
  7. package/dist/agents/deepagents.js +1 -1
  8. package/dist/agents/gemini.js +3 -2
  9. package/dist/agents/goose.js +1 -1
  10. package/dist/agents/hermes.js +1 -1
  11. package/dist/agents/kiro.js +1 -1
  12. package/dist/agents/opencode.js +1 -1
  13. package/dist/agents/qoder.js +1 -1
  14. package/dist/agents/shared-prompt.d.ts +0 -3
  15. package/dist/agents/shared-prompt.js +0 -3
  16. package/dist/commands/info.d.ts +0 -3
  17. package/dist/commands/info.js +0 -5
  18. package/dist/commands/init.d.ts +0 -3
  19. package/dist/commands/init.js +2 -11
  20. package/dist/commands/pair.d.ts +1 -4
  21. package/dist/commands/pair.js +3 -12
  22. package/dist/commands/restart.d.ts +0 -3
  23. package/dist/commands/restart.js +0 -3
  24. package/dist/commands/run.d.ts +1 -14
  25. package/dist/commands/run.js +18 -61
  26. package/dist/commands/serve.d.ts +0 -3
  27. package/dist/commands/serve.js +29 -27
  28. package/dist/config.d.ts +0 -8
  29. package/dist/config.js +0 -8
  30. package/dist/device-capabilities.d.ts +1 -1
  31. package/dist/event-queues.d.ts +6 -21
  32. package/dist/event-queues.js +6 -21
  33. package/dist/events.d.ts +0 -6
  34. package/dist/events.js +1 -9
  35. package/dist/index.js +0 -1
  36. package/dist/mcp-handler.js +1 -2
  37. package/dist/mcp-tools.d.ts +0 -3
  38. package/dist/mcp-tools.js +12 -16
  39. package/dist/nats-client.d.ts +0 -3
  40. package/dist/nats-client.js +1 -4
  41. package/dist/pending-requests.d.ts +4 -18
  42. package/dist/pending-requests.js +4 -18
  43. package/dist/platform/index.d.ts +1 -4
  44. package/dist/platform/index.js +8 -7
  45. package/dist/platform/linux.d.ts +3 -9
  46. package/dist/platform/linux.js +9 -20
  47. package/dist/platform/macos.d.ts +32 -0
  48. package/dist/platform/macos.js +287 -0
  49. package/dist/platform/platform.d.ts +1 -4
  50. package/dist/platform/windows.d.ts +2 -5
  51. package/dist/platform/windows.js +19 -39
  52. package/dist/pwa/assets/index-499vYQvR.js +120 -0
  53. package/dist/pwa/assets/{index-CQxcuDhM.css → index-UaZFu6XL.css} +1 -1
  54. package/dist/pwa/assets/{web-DOyOiwsW.js → web-Bp48ONY3.js} +1 -1
  55. package/dist/pwa/assets/{web-D7Kq3Nvk.js → web-CyJutAy4.js} +1 -1
  56. package/dist/pwa/index.html +2 -2
  57. package/dist/pwa/service-worker.js +1 -1
  58. package/dist/rpc-handler.d.ts +0 -6
  59. package/dist/rpc-handler.js +14 -47
  60. package/dist/spawn-command.d.ts +10 -25
  61. package/dist/spawn-command.js +7 -15
  62. package/dist/task.d.ts +6 -64
  63. package/dist/task.js +7 -70
  64. package/dist/transports/http-transport.d.ts +0 -4
  65. package/dist/transports/http-transport.js +7 -28
  66. package/dist/transports/nats-transport.d.ts +0 -4
  67. package/dist/transports/nats-transport.js +3 -9
  68. package/dist/types.d.ts +3 -7
  69. package/dist/update-checker.d.ts +1 -4
  70. package/dist/update-checker.js +2 -5
  71. package/package.json +1 -1
  72. package/palmier-server/pwa/src/App.css +325 -22
  73. package/palmier-server/pwa/src/App.tsx +2 -0
  74. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +288 -0
  75. package/palmier-server/pwa/src/components/HostMenu.tsx +20 -207
  76. package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
  77. package/palmier-server/pwa/src/components/SessionComposer.tsx +11 -2
  78. package/palmier-server/pwa/src/components/SessionsView.tsx +60 -32
  79. package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
  80. package/palmier-server/pwa/src/components/TaskCard.tsx +1 -1
  81. package/palmier-server/pwa/src/components/TaskForm.tsx +207 -5
  82. package/palmier-server/pwa/src/components/TasksView.tsx +3 -1
  83. package/palmier-server/pwa/src/constants.ts +1 -1
  84. package/palmier-server/pwa/src/native/Device.ts +18 -2
  85. package/palmier-server/pwa/src/pages/Dashboard.tsx +13 -6
  86. package/palmier-server/pwa/src/pages/PairHost.tsx +3 -1
  87. package/palmier-server/pwa/src/pages/PairSetup.tsx +70 -0
  88. package/palmier-server/server/src/index.ts +7 -7
  89. package/palmier-server/server/src/routes/device.ts +4 -4
  90. package/palmier-server/spec.md +38 -7
  91. package/src/agents/agent.ts +0 -4
  92. package/src/agents/claude.ts +1 -1
  93. package/src/agents/codex.ts +2 -2
  94. package/src/agents/cursor.ts +1 -1
  95. package/src/agents/deepagents.ts +1 -1
  96. package/src/agents/gemini.ts +3 -2
  97. package/src/agents/goose.ts +1 -1
  98. package/src/agents/hermes.ts +1 -1
  99. package/src/agents/kiro.ts +1 -1
  100. package/src/agents/opencode.ts +1 -1
  101. package/src/agents/qoder.ts +1 -1
  102. package/src/agents/shared-prompt.ts +0 -3
  103. package/src/commands/info.ts +0 -5
  104. package/src/commands/init.ts +2 -11
  105. package/src/commands/pair.ts +3 -12
  106. package/src/commands/restart.ts +0 -3
  107. package/src/commands/run.ts +18 -65
  108. package/src/commands/serve.ts +28 -27
  109. package/src/config.ts +0 -8
  110. package/src/device-capabilities.ts +3 -2
  111. package/src/event-queues.ts +6 -21
  112. package/src/events.ts +1 -9
  113. package/src/index.ts +0 -1
  114. package/src/mcp-handler.ts +1 -2
  115. package/src/mcp-tools.ts +12 -18
  116. package/src/nats-client.ts +1 -4
  117. package/src/pending-requests.ts +4 -18
  118. package/src/platform/index.ts +5 -7
  119. package/src/platform/linux.ts +9 -20
  120. package/src/platform/macos.ts +310 -0
  121. package/src/platform/platform.ts +1 -4
  122. package/src/platform/windows.ts +19 -40
  123. package/src/rpc-handler.ts +14 -47
  124. package/src/spawn-command.ts +11 -27
  125. package/src/task.ts +7 -70
  126. package/src/transports/http-transport.ts +7 -39
  127. package/src/transports/nats-transport.ts +3 -9
  128. package/src/types.ts +3 -10
  129. package/src/update-checker.ts +2 -5
  130. package/test/macos-plist.test.ts +112 -0
  131. package/test/task-parsing.test.ts +2 -3
  132. package/test/windows-xml.test.ts +11 -12
  133. package/dist/pwa/assets/index-DQfOEB03.js +0 -120
@@ -1793,25 +1793,16 @@ body {
1793
1793
  z-index: 101;
1794
1794
  display: flex;
1795
1795
  flex-direction: column;
1796
+ overflow-y: auto;
1797
+ overscroll-behavior: contain;
1796
1798
  animation: drawerSlideIn 0.25s ease;
1797
1799
  }
1798
1800
 
1799
- .drawer-header {
1800
- display: flex;
1801
- align-items: center;
1802
- justify-content: space-between;
1803
- padding: var(--space-md);
1804
- border-bottom: 1px solid var(--color-border);
1805
- }
1806
-
1807
- .drawer-title {
1808
- font-size: 1.125rem;
1809
- font-weight: 800;
1810
- color: var(--color-primary);
1811
- letter-spacing: -0.04em;
1812
- }
1813
-
1814
1801
  .drawer-close-btn {
1802
+ position: absolute;
1803
+ top: 8px;
1804
+ right: 8px;
1805
+ z-index: 1;
1815
1806
  display: flex;
1816
1807
  align-items: center;
1817
1808
  justify-content: center;
@@ -1847,6 +1838,18 @@ body {
1847
1838
  margin-bottom: var(--space-sm);
1848
1839
  }
1849
1840
 
1841
+ .drawer-toggle-group {
1842
+ display: flex;
1843
+ flex-direction: column;
1844
+ gap: var(--space-sm);
1845
+ }
1846
+
1847
+ .drawer-toggle-group-divided {
1848
+ border-top: 1px solid var(--color-border);
1849
+ padding-top: var(--space-sm);
1850
+ margin-top: var(--space-xs);
1851
+ }
1852
+
1850
1853
  .drawer-toggle {
1851
1854
  display: flex;
1852
1855
  align-items: center;
@@ -1961,17 +1964,42 @@ body {
1961
1964
 
1962
1965
  /* ===== Tab bar ===== */
1963
1966
 
1964
- .tab-bar {
1965
- display: flex;
1966
- align-items: center;
1967
- background: color-mix(in srgb, var(--color-surface) 92%, transparent);
1968
- border-bottom: 1px solid var(--color-border);
1967
+ .app-header {
1969
1968
  position: sticky;
1970
1969
  top: 0;
1971
1970
  z-index: 10;
1971
+ background: color-mix(in srgb, var(--color-surface) 92%, transparent);
1972
+ border-bottom: 1px solid var(--color-border);
1972
1973
  backdrop-filter: blur(8px);
1973
1974
  }
1974
1975
 
1976
+ .app-title-bar {
1977
+ position: relative;
1978
+ display: flex;
1979
+ align-items: center;
1980
+ justify-content: center;
1981
+ padding: 8px 0;
1982
+ }
1983
+
1984
+ .app-title-bar .hamburger-btn {
1985
+ position: absolute;
1986
+ left: 4px;
1987
+ top: 50%;
1988
+ transform: translateY(-50%);
1989
+ }
1990
+
1991
+ .app-title {
1992
+ margin: 0;
1993
+ font-size: 1rem;
1994
+ font-weight: 600;
1995
+ color: var(--color-text);
1996
+ }
1997
+
1998
+ .tab-bar {
1999
+ display: flex;
2000
+ align-items: center;
2001
+ }
2002
+
1975
2003
  .tab-btn {
1976
2004
  flex: 1;
1977
2005
  display: flex;
@@ -2606,13 +2634,14 @@ body {
2606
2634
  position: sticky;
2607
2635
  top: 0;
2608
2636
  height: 100dvh;
2609
- width: 280px;
2610
- min-width: 280px;
2637
+ width: 320px;
2638
+ min-width: 320px;
2611
2639
  background: var(--color-surface);
2612
2640
  border-right: 1px solid var(--color-border);
2613
2641
  display: flex;
2614
2642
  flex-direction: column;
2615
2643
  overflow-y: auto;
2644
+ overscroll-behavior: contain;
2616
2645
  animation: none;
2617
2646
  }
2618
2647
 
@@ -2630,3 +2659,277 @@ body {
2630
2659
  right: max(var(--space-lg), calc((100vw - 1080px) / 2));
2631
2660
  }
2632
2661
  }
2662
+
2663
+ /* ===== Swipe-to-delete row ===== */
2664
+
2665
+ .swipe-row {
2666
+ position: relative;
2667
+ overflow: hidden;
2668
+ touch-action: pan-y; /* let vertical scroll through; capture horizontal ourselves */
2669
+ }
2670
+
2671
+ .swipe-row-action {
2672
+ position: absolute;
2673
+ top: 0;
2674
+ right: 0;
2675
+ bottom: 0;
2676
+ display: flex;
2677
+ align-items: center;
2678
+ justify-content: center;
2679
+ background: var(--color-error, #dc2626);
2680
+ color: #fff;
2681
+ border: none;
2682
+ font-size: 0.9375rem;
2683
+ font-weight: 600;
2684
+ cursor: pointer;
2685
+ padding: 0;
2686
+ }
2687
+
2688
+ .swipe-row-action:focus-visible {
2689
+ outline: 2px solid var(--color-accent, #2E5CE5);
2690
+ outline-offset: -4px;
2691
+ }
2692
+
2693
+ .swipe-row-content {
2694
+ position: relative;
2695
+ background: var(--color-surface);
2696
+ transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
2697
+ will-change: transform;
2698
+ }
2699
+
2700
+ /* During an active drag, skip the transition so the row tracks the finger 1:1.
2701
+ On release the class is removed and the snap animates. */
2702
+ .swipe-row-content-dragging {
2703
+ transition: none;
2704
+ }
2705
+
2706
+ .app-filter-help {
2707
+ margin-top: var(--space-xs);
2708
+ }
2709
+
2710
+ .app-filter-trigger {
2711
+ display: inline-block;
2712
+ padding: 0;
2713
+ font-size: 0.875rem;
2714
+ font-weight: 500;
2715
+ text-align: left;
2716
+ }
2717
+
2718
+ .app-filter-selected {
2719
+ display: inline-flex;
2720
+ align-items: center;
2721
+ gap: var(--space-sm);
2722
+ padding: 6px 10px;
2723
+ border: 1px solid var(--color-border);
2724
+ border-radius: var(--radius-md);
2725
+ background: var(--color-surface);
2726
+ max-width: 100%;
2727
+ }
2728
+
2729
+ .app-filter-selected-name {
2730
+ font-size: 0.9375rem;
2731
+ font-weight: 500;
2732
+ white-space: nowrap;
2733
+ overflow: hidden;
2734
+ text-overflow: ellipsis;
2735
+ }
2736
+
2737
+ .app-filter-selected-pkg {
2738
+ font-size: 0.75rem;
2739
+ color: var(--color-muted);
2740
+ white-space: nowrap;
2741
+ overflow: hidden;
2742
+ text-overflow: ellipsis;
2743
+ }
2744
+
2745
+ .app-filter-selected-clear {
2746
+ display: flex;
2747
+ align-items: center;
2748
+ justify-content: center;
2749
+ width: 22px;
2750
+ height: 22px;
2751
+ padding: 0;
2752
+ margin-left: auto;
2753
+ border: none;
2754
+ background: none;
2755
+ color: var(--color-muted);
2756
+ font-size: 0.875rem;
2757
+ cursor: pointer;
2758
+ border-radius: var(--radius-sm);
2759
+ }
2760
+
2761
+ .app-filter-selected-clear:hover {
2762
+ background: var(--color-border-subtle);
2763
+ color: var(--color-text);
2764
+ }
2765
+
2766
+ .app-filter-overlay {
2767
+ position: fixed;
2768
+ inset: 0;
2769
+ background: var(--color-overlay);
2770
+ display: flex;
2771
+ align-items: center;
2772
+ justify-content: center;
2773
+ z-index: 1100;
2774
+ backdrop-filter: blur(4px);
2775
+ padding: var(--space-md);
2776
+ }
2777
+
2778
+ .app-filter-dialog {
2779
+ background: var(--color-surface);
2780
+ width: 100%;
2781
+ max-width: 480px;
2782
+ max-height: 80dvh;
2783
+ border-radius: var(--radius-lg);
2784
+ box-shadow: var(--shadow-xl);
2785
+ display: flex;
2786
+ flex-direction: column;
2787
+ overflow: hidden;
2788
+ }
2789
+
2790
+ .app-filter-header {
2791
+ display: flex;
2792
+ align-items: center;
2793
+ justify-content: space-between;
2794
+ padding: var(--space-md) var(--space-lg);
2795
+ border-bottom: 1px solid var(--color-border);
2796
+ }
2797
+
2798
+ .app-filter-header h2 {
2799
+ margin: 0;
2800
+ font-size: 1rem;
2801
+ font-weight: 600;
2802
+ }
2803
+
2804
+ .app-filter-close {
2805
+ display: flex;
2806
+ align-items: center;
2807
+ justify-content: center;
2808
+ width: 32px;
2809
+ height: 32px;
2810
+ padding: 0;
2811
+ border: none;
2812
+ background: none;
2813
+ color: var(--color-muted);
2814
+ cursor: pointer;
2815
+ border-radius: var(--radius-sm);
2816
+ }
2817
+
2818
+ .app-filter-close:hover {
2819
+ background: var(--color-border-subtle);
2820
+ color: var(--color-text);
2821
+ }
2822
+
2823
+ .app-filter-search {
2824
+ margin: var(--space-md) var(--space-lg) 0;
2825
+ width: calc(100% - 2 * var(--space-lg));
2826
+ }
2827
+
2828
+ .app-filter-list {
2829
+ flex: 1;
2830
+ overflow-y: auto;
2831
+ list-style: none;
2832
+ margin: 0;
2833
+ padding: var(--space-xs) 0 var(--space-md);
2834
+ }
2835
+
2836
+ .app-filter-row {
2837
+ display: flex;
2838
+ align-items: center;
2839
+ gap: var(--space-md);
2840
+ padding: 10px var(--space-lg);
2841
+ cursor: pointer;
2842
+ user-select: none;
2843
+ }
2844
+
2845
+ .app-filter-row:hover {
2846
+ background: var(--color-hover, rgba(0, 0, 0, 0.04));
2847
+ }
2848
+
2849
+ .app-filter-row-labels {
2850
+ display: flex;
2851
+ flex-direction: column;
2852
+ min-width: 0;
2853
+ flex: 1;
2854
+ }
2855
+
2856
+ .app-filter-row-name {
2857
+ font-size: 0.9375rem;
2858
+ font-weight: 500;
2859
+ white-space: nowrap;
2860
+ overflow: hidden;
2861
+ text-overflow: ellipsis;
2862
+ }
2863
+
2864
+ .app-filter-row-pkg {
2865
+ font-size: 0.75rem;
2866
+ color: var(--color-muted);
2867
+ white-space: nowrap;
2868
+ overflow: hidden;
2869
+ text-overflow: ellipsis;
2870
+ }
2871
+
2872
+ .app-filter-empty {
2873
+ padding: var(--space-lg);
2874
+ text-align: center;
2875
+ color: var(--color-muted);
2876
+ font-size: 0.875rem;
2877
+ }
2878
+
2879
+ .app-filter-skeleton {
2880
+ cursor: default;
2881
+ }
2882
+
2883
+ .app-filter-skeleton-bar {
2884
+ flex: 1;
2885
+ height: 14px;
2886
+ border-radius: 4px;
2887
+ background: linear-gradient(90deg, var(--color-border-subtle) 25%, var(--color-border) 50%, var(--color-border-subtle) 75%);
2888
+ background-size: 200% 100%;
2889
+ animation: appFilterShimmer 1.2s ease-in-out infinite;
2890
+ }
2891
+
2892
+ @keyframes appFilterShimmer {
2893
+ 0% { background-position: 200% 0; }
2894
+ 100% { background-position: -200% 0; }
2895
+ }
2896
+
2897
+ .pair-setup {
2898
+ min-height: 100dvh;
2899
+ display: flex;
2900
+ justify-content: center;
2901
+ padding: var(--space-lg);
2902
+ background: var(--color-bg);
2903
+ }
2904
+
2905
+ .pair-setup-inner {
2906
+ width: 100%;
2907
+ max-width: 480px;
2908
+ display: flex;
2909
+ flex-direction: column;
2910
+ gap: var(--space-lg);
2911
+ }
2912
+
2913
+ .pair-setup-title {
2914
+ margin: 0;
2915
+ font-size: 1.25rem;
2916
+ font-weight: 600;
2917
+ line-height: 1.35;
2918
+ }
2919
+
2920
+ .pair-setup-description {
2921
+ margin: 0;
2922
+ color: var(--color-muted);
2923
+ font-size: 0.875rem;
2924
+ }
2925
+
2926
+ .pair-setup-loading {
2927
+ padding: var(--space-lg);
2928
+ text-align: center;
2929
+ color: var(--color-muted);
2930
+ font-size: 0.875rem;
2931
+ }
2932
+
2933
+ .pair-setup-actions {
2934
+ margin-top: var(--space-md);
2935
+ }
@@ -5,6 +5,7 @@ import { HostConnectionProvider } from "./contexts/HostConnectionContext";
5
5
  import { Device } from "./native/Device";
6
6
  import Dashboard from "./pages/Dashboard";
7
7
  import PairHost from "./pages/PairHost";
8
+ import PairSetup from "./pages/PairSetup";
8
9
 
9
10
  /** Routes FCM notification taps (fired by DevicePlugin) into the client-side router. */
10
11
  function DeepLinkRouter() {
@@ -29,6 +30,7 @@ export default function App() {
29
30
  <Route path="/runs/:taskId" element={<Dashboard />} />
30
31
  <Route path="/runs/:taskId/:runId" element={<Dashboard />} />
31
32
  <Route path="/pair" element={<PairHost />} />
33
+ <Route path="/pair/setup" element={<PairSetup />} />
32
34
  </Routes>
33
35
  </HostConnectionProvider>
34
36
  </HostStoreProvider>
@@ -0,0 +1,288 @@
1
+ import { useState, useEffect } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { Capacitor } from "@capacitor/core";
4
+ import { App as CapacitorApp } from "@capacitor/app";
5
+ import { Device, type PermissionType } from "../native/Device";
6
+
7
+ const isNative = Capacitor.isNativePlatform();
8
+
9
+ type CapabilityGroup = "Messaging" | "Data" | "Device";
10
+
11
+ const CAPABILITY_GROUPS: CapabilityGroup[] = ["Device", "Data", "Messaging"];
12
+
13
+ interface CapabilityDefinition {
14
+ capability: string;
15
+ label: string;
16
+ group: CapabilityGroup;
17
+ permission?: PermissionType;
18
+ needsFullScreenIntent?: boolean;
19
+ enableMethod?: string;
20
+ disableMethod?: string;
21
+ enableParams?(fcmToken: string): Record<string, unknown>;
22
+ disableParams?(): Record<string, unknown>;
23
+ }
24
+
25
+ const CAPABILITIES: CapabilityDefinition[] = [
26
+ { capability: "sms-read", label: "Read SMS", group: "Messaging", permission: "smsRead" },
27
+ { capability: "sms-send", label: "Send SMS", group: "Messaging", permission: "smsSend" },
28
+ { capability: "send-email", label: "Send Email", group: "Messaging", permission: "postNotifications" },
29
+ { capability: "notifications", label: "Read Notifications", group: "Data", permission: "notificationListener" },
30
+ { capability: "contacts", label: "Manage Contacts", group: "Data", permission: "contacts" },
31
+ { capability: "calendar", label: "Manage Calendar", group: "Data", permission: "calendar" },
32
+ {
33
+ capability: "location",
34
+ label: "Get Location",
35
+ group: "Device",
36
+ permission: "location",
37
+ enableMethod: "device.location.enable",
38
+ disableMethod: "device.location.disable",
39
+ enableParams: (fcmToken) => ({ fcmToken }),
40
+ disableParams: () => ({}),
41
+ },
42
+ { capability: "battery", label: "Read Battery Status", group: "Device" },
43
+ { capability: "dnd", label: "Set Ringer Mode", group: "Device", permission: "dnd" },
44
+ { capability: "alarm", label: "Trigger Alarms", group: "Device", needsFullScreenIntent: true },
45
+ ];
46
+
47
+ interface CapabilityTogglesProps {
48
+ capabilityTokens?: Record<string, string | null>;
49
+ activeClientToken?: string | null;
50
+ request<T = unknown>(method: string, params?: unknown): Promise<T>;
51
+ onCapabilityTokensChange(tokens: Record<string, string | null>): void;
52
+ }
53
+
54
+ export default function CapabilityToggles({ capabilityTokens, activeClientToken, request, onCapabilityTokensChange }: CapabilityTogglesProps) {
55
+ const [togglingCapability, setTogglingCapability] = useState<string | null>(null);
56
+ const [confirmingSwitchCapability, setConfirmingSwitchCapability] = useState<CapabilityDefinition | null>(null);
57
+ const [confirmingDisableCapability, setConfirmingDisableCapability] = useState<CapabilityDefinition | null>(null);
58
+
59
+ // Null while loading; empty set means the native plugin doesn't advertise the
60
+ // list (old APK / web) — fall back to the per-call {supported} flag.
61
+ const [supportedPerms, setSupportedPerms] = useState<Set<PermissionType> | null>(null);
62
+
63
+ useEffect(() => {
64
+ if (!isNative || !Device) {
65
+ setSupportedPerms(new Set());
66
+ return;
67
+ }
68
+ Device.getSupportedPermissions()
69
+ .then(({ types }) => setSupportedPerms(new Set(types)))
70
+ .catch(() => setSupportedPerms(new Set()));
71
+ }, []);
72
+
73
+ function isCapabilityEnabled(capability: string): boolean {
74
+ return !!(activeClientToken && capabilityTokens?.[capability] === activeClientToken);
75
+ }
76
+
77
+ function isCapabilityVisible(definition: CapabilityDefinition): boolean {
78
+ if (!supportedPerms) return false;
79
+ if (supportedPerms.size === 0) return true;
80
+ if (definition.permission && !supportedPerms.has(definition.permission)) return false;
81
+ if (definition.needsFullScreenIntent && !supportedPerms.has("fullScreenIntent")) return false;
82
+ return true;
83
+ }
84
+
85
+ function setCapabilityEnabled(capability: string, enabled: boolean) {
86
+ const updated: Record<string, string | null> = {};
87
+ for (const [k, v] of Object.entries(capabilityTokens ?? {})) updated[k] = v ?? null;
88
+ updated[capability] = enabled ? (activeClientToken ?? null) : null;
89
+ onCapabilityTokensChange(updated);
90
+ }
91
+
92
+ // If the OS location permission is revoked while the app is backgrounded,
93
+ // disable the capability on the host so agents don't keep pinging for fixes.
94
+ useEffect(() => {
95
+ if (!isNative || !Device) return;
96
+ const locationEnabled = isCapabilityEnabled("location");
97
+ if (!locationEnabled) return;
98
+
99
+ function syncPermissionState() {
100
+ Device!.checkPermission({ type: "location" }).then(({ granted }) => {
101
+ if (!granted) {
102
+ request("device.location.disable").then(() => {
103
+ setCapabilityEnabled("location", false);
104
+ }).catch(() => {});
105
+ }
106
+ });
107
+ }
108
+
109
+ syncPermissionState();
110
+ const listener = CapacitorApp.addListener("resume", syncPermissionState);
111
+ return () => { listener.then((h) => h.remove()); };
112
+ }, [capabilityTokens, activeClientToken]);
113
+
114
+ // Mirror the server-derived enabled set into native as a local kill-switch.
115
+ // Toggle paths write through immediately; this catches host-initiated changes
116
+ // (e.g. a capability revoked on another device) on the next render.
117
+ useEffect(() => {
118
+ if (!isNative || !Device) return;
119
+ const enabled = CAPABILITIES
120
+ .map((definition) => definition.capability)
121
+ .filter((capability) => capabilityTokens?.[capability] === activeClientToken);
122
+ Device.setEnabledCapabilities({ capabilities: enabled }).catch(() => {});
123
+ }, [capabilityTokens, activeClientToken]);
124
+
125
+ async function toggleCapability(definition: CapabilityDefinition, bypassConfirmation = false) {
126
+ const enabled = isCapabilityEnabled(definition.capability);
127
+
128
+ if (enabled && !bypassConfirmation) {
129
+ setConfirmingDisableCapability(definition);
130
+ return;
131
+ }
132
+
133
+ const ownedByOther = !enabled && !!capabilityTokens?.[definition.capability]
134
+ && capabilityTokens[definition.capability] !== activeClientToken;
135
+ if (ownedByOther && !bypassConfirmation) {
136
+ setConfirmingSwitchCapability(definition);
137
+ return;
138
+ }
139
+
140
+ setTogglingCapability(definition.capability);
141
+ try {
142
+ if (enabled) {
143
+ const method = definition.disableMethod ?? "device.capability.disable";
144
+ const params = definition.disableParams?.() ?? { capability: definition.capability };
145
+ await request(method, params);
146
+ setCapabilityEnabled(definition.capability, false);
147
+ return;
148
+ }
149
+
150
+ if (Device && definition.capability === "send-email") {
151
+ try {
152
+ const result = await Device.hasEmailClient();
153
+ if (result.supported && !result.available) {
154
+ alert("No email app is installed on this device. Install one (e.g. Gmail) before enabling Sending Email.");
155
+ return;
156
+ }
157
+ } catch { /* older APK: fall through */ }
158
+ }
159
+
160
+ if (Device && definition.permission) {
161
+ const check = await Device.checkPermission({ type: definition.permission });
162
+ if (!check.supported) {
163
+ console.warn(`Native build does not support permission '${definition.permission}'`);
164
+ return;
165
+ }
166
+ if (!check.granted) {
167
+ const result = await Device.requestPermission({ type: definition.permission });
168
+ if (!result.granted) return;
169
+ }
170
+ }
171
+ if (Device && definition.needsFullScreenIntent) {
172
+ const check = await Device.checkPermission({ type: "fullScreenIntent" });
173
+ if (!check.supported) {
174
+ console.warn("Native build does not support fullScreenIntent");
175
+ return;
176
+ }
177
+ if (!check.granted) {
178
+ const result = await Device.requestPermission({ type: "fullScreenIntent" });
179
+ if (!result.granted) return;
180
+ }
181
+ }
182
+
183
+ if (!Device) return;
184
+ const { token: fcmToken } = await Device.getFcmToken();
185
+ if (!fcmToken) { console.warn("No FCM token available"); return; }
186
+
187
+ // Whitelist the capability natively before enabling on the host, so an FCM
188
+ // from the host can't arrive in the gap before our useEffect syncs.
189
+ const enabledNow = CAPABILITIES
190
+ .map((c) => c.capability)
191
+ .filter((name) => name === definition.capability || capabilityTokens?.[name] === activeClientToken);
192
+ await Device.setEnabledCapabilities({ capabilities: enabledNow });
193
+
194
+ const method = definition.enableMethod ?? "device.capability.enable";
195
+ const params = definition.enableParams?.(fcmToken) ?? { capability: definition.capability, fcmToken };
196
+ await request(method, params);
197
+ setCapabilityEnabled(definition.capability, true);
198
+ } catch (err) {
199
+ console.error(`Failed to toggle ${definition.capability}:`, err);
200
+ } finally {
201
+ setTogglingCapability(null);
202
+ }
203
+ }
204
+
205
+ const visibleGroups = CAPABILITY_GROUPS
206
+ .map((group) => ({ group, items: CAPABILITIES.filter((d) => d.group === group && isCapabilityVisible(d)) }))
207
+ .filter((g) => g.items.length > 0);
208
+
209
+ if (!isNative || visibleGroups.length === 0) return null;
210
+
211
+ const switchModal = confirmingSwitchCapability && createPortal(
212
+ <div className="confirm-modal-overlay" onClick={() => setConfirmingSwitchCapability(null)}>
213
+ <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
214
+ <h2 className="confirm-modal-title">Switch {confirmingSwitchCapability.label} to this device?</h2>
215
+ <p className="confirm-modal-message">
216
+ {confirmingSwitchCapability.label} is currently enabled on another device. Switching will make this device the one the host uses for {confirmingSwitchCapability.label}, and it will be disabled on the other device.
217
+ </p>
218
+ <div className="confirm-modal-actions">
219
+ <button className="btn btn-secondary" onClick={() => setConfirmingSwitchCapability(null)}>Cancel</button>
220
+ <button
221
+ className="btn btn-primary"
222
+ onClick={() => {
223
+ const d = confirmingSwitchCapability;
224
+ setConfirmingSwitchCapability(null);
225
+ toggleCapability(d, true);
226
+ }}
227
+ >
228
+ Switch
229
+ </button>
230
+ </div>
231
+ </div>
232
+ </div>,
233
+ document.body,
234
+ );
235
+
236
+ const disableModal = confirmingDisableCapability && createPortal(
237
+ <div className="confirm-modal-overlay" onClick={() => setConfirmingDisableCapability(null)}>
238
+ <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
239
+ <h2 className="confirm-modal-title">Disable {confirmingDisableCapability.label}?</h2>
240
+ <p className="confirm-modal-message">
241
+ Agents running on the host will no longer be able to use {confirmingDisableCapability.label} until it is re-enabled on a device.
242
+ </p>
243
+ <div className="confirm-modal-actions">
244
+ <button className="btn btn-secondary" onClick={() => setConfirmingDisableCapability(null)}>Cancel</button>
245
+ <button
246
+ className="btn btn-danger"
247
+ onClick={() => {
248
+ const d = confirmingDisableCapability;
249
+ setConfirmingDisableCapability(null);
250
+ toggleCapability(d, true);
251
+ }}
252
+ >
253
+ Disable
254
+ </button>
255
+ </div>
256
+ </div>
257
+ </div>,
258
+ document.body,
259
+ );
260
+
261
+ return (
262
+ <>
263
+ {visibleGroups.map(({ group, items }, index) => (
264
+ <div key={group} className={index > 0 ? "drawer-toggle-group drawer-toggle-group-divided" : "drawer-toggle-group"}>
265
+ {items.map((definition) => {
266
+ const enabled = isCapabilityEnabled(definition.capability);
267
+ return (
268
+ <label key={definition.capability} className="drawer-toggle">
269
+ <span className="drawer-toggle-label">{definition.label}</span>
270
+ <button
271
+ className={`toggle-switch ${enabled ? "toggle-switch-on" : ""}`}
272
+ onClick={() => toggleCapability(definition)}
273
+ disabled={togglingCapability === definition.capability}
274
+ role="switch"
275
+ aria-checked={enabled}
276
+ >
277
+ <span className="toggle-switch-thumb" />
278
+ </button>
279
+ </label>
280
+ );
281
+ })}
282
+ </div>
283
+ ))}
284
+ {switchModal}
285
+ {disableModal}
286
+ </>
287
+ );
288
+ }