palmier 0.8.10 → 0.9.2
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.
- package/README.md +8 -1
- package/dist/commands/init.js +13 -2
- package/dist/commands/pair.js +3 -9
- package/dist/linked-device.d.ts +9 -0
- package/dist/linked-device.js +45 -0
- package/dist/mcp-tools.js +19 -19
- package/dist/network.d.ts +0 -5
- package/dist/network.js +75 -9
- package/dist/pwa/assets/index-BLCVzS_l.js +120 -0
- package/dist/pwa/assets/{index-DQJHVyP6.css → index-Cjjw24Ok.css} +1 -1
- package/dist/pwa/assets/{web-CaRUL7Kz.js → web-C2AU9S9n.js} +1 -1
- package/dist/pwa/assets/{web-C9g_YGd8.js → web-CfD_ah7K.js} +1 -1
- package/dist/pwa/assets/{web-D4ty3qtI.js → web-DugGj1t8.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +2 -2
- package/dist/rpc-handler.js +17 -23
- package/package.json +1 -2
- package/palmier-server/README.md +3 -2
- package/palmier-server/pwa/src/App.css +45 -4
- package/palmier-server/pwa/src/App.tsx +36 -15
- package/palmier-server/pwa/src/components/CapabilityToggles.tsx +65 -225
- package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +54 -12
- package/palmier-server/pwa/src/components/HostMenu.tsx +110 -21
- package/palmier-server/pwa/src/components/RunDetailView.tsx +2 -2
- package/palmier-server/pwa/src/components/SessionComposer.tsx +9 -8
- package/palmier-server/pwa/src/components/SessionsView.tsx +5 -3
- package/palmier-server/pwa/src/components/TabBar.tsx +7 -5
- package/palmier-server/pwa/src/components/TaskForm.tsx +5 -3
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +41 -41
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +17 -60
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +6 -7
- package/palmier-server/pwa/src/native/Device.ts +23 -38
- package/palmier-server/pwa/src/pages/Dashboard.tsx +36 -41
- package/palmier-server/pwa/src/pages/PairHost.tsx +20 -1
- package/palmier-server/pwa/src/pages/PairSetup.tsx +98 -39
- package/palmier-server/pwa/src/service-worker.ts +9 -6
- package/palmier-server/pwa/src/types.ts +2 -0
- package/palmier-server/spec.md +44 -15
- package/src/commands/init.ts +13 -2
- package/src/commands/pair.ts +3 -9
- package/src/linked-device.ts +52 -0
- package/src/mcp-tools.ts +19 -19
- package/src/network.ts +73 -9
- package/src/rpc-handler.ts +14 -22
- package/dist/device-capabilities.d.ts +0 -9
- package/dist/device-capabilities.js +0 -36
- package/dist/pwa/assets/index-iL_NTbsT.js +0 -120
- package/src/device-capabilities.ts +0 -57
|
@@ -10,19 +10,16 @@ import type { PairedHost } from "../types";
|
|
|
10
10
|
|
|
11
11
|
interface HostStoreContextValue {
|
|
12
12
|
pairedHosts: PairedHost[];
|
|
13
|
-
activeHostId: string | null;
|
|
14
13
|
addPairedHost(host: PairedHost): void;
|
|
15
14
|
removePairedHost(hostId: string): void;
|
|
16
15
|
renamePairedHost(hostId: string, name: string): void;
|
|
17
16
|
setHostLanUrl(hostId: string, lanUrl: string | undefined): void;
|
|
18
|
-
|
|
19
|
-
getActiveHost(): PairedHost | null;
|
|
17
|
+
setHostLastAgent(hostId: string, agent: string): void;
|
|
20
18
|
}
|
|
21
19
|
|
|
22
20
|
const HostStoreContext = createContext<HostStoreContextValue | null>(null);
|
|
23
21
|
|
|
24
22
|
const STORAGE_KEY = "palmier_paired_hosts";
|
|
25
|
-
const ACTIVE_KEY = "palmier_active_host";
|
|
26
23
|
|
|
27
24
|
function loadHosts(): PairedHost[] {
|
|
28
25
|
try {
|
|
@@ -38,76 +35,36 @@ function saveHosts(hosts: PairedHost[]) {
|
|
|
38
35
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(hosts));
|
|
39
36
|
}
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
return localStorage.getItem(ACTIVE_KEY);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function saveActiveId(id: string | null) {
|
|
46
|
-
if (id) localStorage.setItem(ACTIVE_KEY, id);
|
|
47
|
-
else localStorage.removeItem(ACTIVE_KEY);
|
|
48
|
-
}
|
|
38
|
+
export const LOCAL_HOST_ID = "local";
|
|
49
39
|
|
|
50
|
-
/** Local mode: served by palmier serve on localhost — auto-
|
|
40
|
+
/** Local mode: served by palmier serve on localhost — auto-inject a local host entry. */
|
|
51
41
|
const isLocalMode = !!(window as any).__PALMIER_SERVE__
|
|
52
42
|
&& (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
|
|
53
43
|
|
|
54
44
|
export function HostStoreProvider({ children }: { children: ReactNode }) {
|
|
55
|
-
const [pairedHosts, setPairedHosts] = useState<PairedHost[]>(
|
|
56
|
-
const [activeHostId, setActiveHostIdState] = useState<string | null>(() => {
|
|
57
|
-
const saved = loadActiveId();
|
|
45
|
+
const [pairedHosts, setPairedHosts] = useState<PairedHost[]>(() => {
|
|
58
46
|
const hosts = loadHosts();
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
// Auto-connect in local mode: inject a local host entry without pairing
|
|
64
|
-
useEffect(() => {
|
|
65
|
-
if (!isLocalMode) return;
|
|
66
|
-
const localHostId = "local";
|
|
67
|
-
const existing = loadHosts().find((h) => h.hostId === localHostId);
|
|
68
|
-
if (!existing) {
|
|
69
|
-
const localHost: PairedHost = { hostId: localHostId, clientToken: "", directUrl: window.location.origin };
|
|
70
|
-
setPairedHosts((prev) => [...prev.filter((h) => h.hostId !== localHostId), localHost]);
|
|
71
|
-
setActiveHostIdState(localHostId);
|
|
47
|
+
if (isLocalMode && !hosts.some((h) => h.hostId === LOCAL_HOST_ID)) {
|
|
48
|
+
hosts.push({ hostId: LOCAL_HOST_ID, clientToken: "", directUrl: window.location.origin });
|
|
72
49
|
}
|
|
73
|
-
|
|
50
|
+
return hosts;
|
|
51
|
+
});
|
|
74
52
|
|
|
75
53
|
useEffect(() => {
|
|
76
54
|
saveHosts(pairedHosts);
|
|
77
55
|
}, [pairedHosts]);
|
|
78
56
|
|
|
79
|
-
useEffect(() => {
|
|
80
|
-
saveActiveId(activeHostId);
|
|
81
|
-
}, [activeHostId]);
|
|
82
|
-
|
|
83
57
|
const addPairedHost = useCallback((host: PairedHost) => {
|
|
84
58
|
setPairedHosts((prev) => {
|
|
85
59
|
const filtered = prev.filter((h) => h.hostId !== host.hostId);
|
|
86
60
|
return [...filtered, host];
|
|
87
61
|
});
|
|
88
|
-
setActiveHostIdState(host.hostId);
|
|
89
62
|
}, []);
|
|
90
63
|
|
|
91
64
|
const removePairedHost = useCallback((hostId: string) => {
|
|
92
65
|
setPairedHosts((prev) => prev.filter((h) => h.hostId !== hostId));
|
|
93
|
-
// Only change the active host when the active one is the one being deleted.
|
|
94
|
-
setActiveHostIdState((current) => {
|
|
95
|
-
if (current !== hostId) return current;
|
|
96
|
-
// The deleted host was active; we'll re-select once pairedHosts settles.
|
|
97
|
-
return null;
|
|
98
|
-
});
|
|
99
66
|
}, []);
|
|
100
67
|
|
|
101
|
-
// When the active host disappears (e.g. it was just unpaired), fall back to
|
|
102
|
-
// any remaining paired host, otherwise null.
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
if (activeHostId !== null && !pairedHosts.find((h) => h.hostId === activeHostId)) {
|
|
105
|
-
setActiveHostIdState(pairedHosts[0]?.hostId ?? null);
|
|
106
|
-
} else if (activeHostId === null && pairedHosts.length > 0) {
|
|
107
|
-
setActiveHostIdState(pairedHosts[0].hostId);
|
|
108
|
-
}
|
|
109
|
-
}, [pairedHosts, activeHostId]);
|
|
110
|
-
|
|
111
68
|
const renamePairedHost = useCallback((hostId: string, name: string) => {
|
|
112
69
|
setPairedHosts((prev) =>
|
|
113
70
|
prev.map((h) => (h.hostId === hostId ? { ...h, name } : h))
|
|
@@ -124,24 +81,24 @@ export function HostStoreProvider({ children }: { children: ReactNode }) {
|
|
|
124
81
|
);
|
|
125
82
|
}, []);
|
|
126
83
|
|
|
127
|
-
const
|
|
128
|
-
|
|
84
|
+
const setHostLastAgent = useCallback((hostId: string, agent: string) => {
|
|
85
|
+
setPairedHosts((prev) =>
|
|
86
|
+
prev.map((h) => {
|
|
87
|
+
if (h.hostId !== hostId) return h;
|
|
88
|
+
if (h.lastAgent === agent) return h;
|
|
89
|
+
return { ...h, lastAgent: agent };
|
|
90
|
+
})
|
|
91
|
+
);
|
|
129
92
|
}, []);
|
|
130
93
|
|
|
131
|
-
const getActiveHost = useCallback((): PairedHost | null => {
|
|
132
|
-
return pairedHosts.find((h) => h.hostId === activeHostId) ?? null;
|
|
133
|
-
}, [pairedHosts, activeHostId]);
|
|
134
|
-
|
|
135
94
|
return (
|
|
136
95
|
<HostStoreContext.Provider value={{
|
|
137
96
|
pairedHosts,
|
|
138
|
-
activeHostId,
|
|
139
97
|
addPairedHost,
|
|
140
98
|
removePairedHost,
|
|
141
99
|
renamePairedHost,
|
|
142
100
|
setHostLanUrl,
|
|
143
|
-
|
|
144
|
-
getActiveHost,
|
|
101
|
+
setHostLastAgent,
|
|
145
102
|
}}>
|
|
146
103
|
{children}
|
|
147
104
|
</HostStoreContext.Provider>
|
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
2
|
import { Capacitor } from "@capacitor/core";
|
|
3
|
-
import {
|
|
3
|
+
import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
4
4
|
import { apiPost, apiGet } from "../api";
|
|
5
5
|
|
|
6
6
|
export function usePushSubscription() {
|
|
7
|
-
const {
|
|
8
|
-
const activeHost = getActiveHost();
|
|
7
|
+
const { activeHost } = useHostConnection();
|
|
9
8
|
const subscribedRef = useRef<string | null>(null);
|
|
10
9
|
|
|
11
10
|
useEffect(() => {
|
|
12
11
|
// Native app uses FCM for notifications, not Web Push
|
|
13
12
|
if (Capacitor.isNativePlatform()) return;
|
|
14
13
|
// Skip push subscription for direct-only (LAN) hosts — no cloud server to relay through
|
|
15
|
-
if (
|
|
14
|
+
if (activeHost.directUrl || subscribedRef.current === activeHost.hostId) return;
|
|
16
15
|
|
|
17
16
|
async function subscribe() {
|
|
18
17
|
try {
|
|
@@ -26,7 +25,7 @@ export function usePushSubscription() {
|
|
|
26
25
|
// Send hostId to SW so it can include it in push responses
|
|
27
26
|
registration.active?.postMessage({
|
|
28
27
|
type: "set-host-id",
|
|
29
|
-
hostId: activeHost
|
|
28
|
+
hostId: activeHost.hostId,
|
|
30
29
|
});
|
|
31
30
|
|
|
32
31
|
let subscription = await registration.pushManager.getSubscription();
|
|
@@ -59,7 +58,7 @@ export function usePushSubscription() {
|
|
|
59
58
|
// Always ensure subscription is saved to server
|
|
60
59
|
const sub = subscription.toJSON();
|
|
61
60
|
await apiPost("/api/push/subscribe", {
|
|
62
|
-
hostId: activeHost
|
|
61
|
+
hostId: activeHost.hostId,
|
|
63
62
|
endpoint: sub.endpoint,
|
|
64
63
|
keys: {
|
|
65
64
|
p256dh: sub.keys!.p256dh,
|
|
@@ -67,7 +66,7 @@ export function usePushSubscription() {
|
|
|
67
66
|
},
|
|
68
67
|
});
|
|
69
68
|
|
|
70
|
-
subscribedRef.current = activeHost
|
|
69
|
+
subscribedRef.current = activeHost.hostId;
|
|
71
70
|
} catch (err) {
|
|
72
71
|
console.error("[Push] Subscription failed:", err);
|
|
73
72
|
}
|
|
@@ -1,26 +1,5 @@
|
|
|
1
1
|
import { Capacitor, registerPlugin, type PluginListenerHandle } from "@capacitor/core";
|
|
2
2
|
|
|
3
|
-
export type PermissionType =
|
|
4
|
-
| "location"
|
|
5
|
-
| "smsRead"
|
|
6
|
-
| "smsSend"
|
|
7
|
-
| "contacts"
|
|
8
|
-
| "calendar"
|
|
9
|
-
| "notificationListener"
|
|
10
|
-
| "dnd"
|
|
11
|
-
| "fullScreenIntent"
|
|
12
|
-
| "postNotifications";
|
|
13
|
-
|
|
14
|
-
export interface PermissionResult {
|
|
15
|
-
granted: boolean;
|
|
16
|
-
/**
|
|
17
|
-
* False when the native build doesn't recognize this permission type — the PWA
|
|
18
|
-
* is served remotely and may ship ahead of the installed APK. Callers should
|
|
19
|
-
* treat unsupported types as "cannot enable" rather than as a hard error.
|
|
20
|
-
*/
|
|
21
|
-
supported: boolean;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
3
|
export interface DeepLinkEvent {
|
|
25
4
|
path: string;
|
|
26
5
|
}
|
|
@@ -30,28 +9,34 @@ export interface InstalledApp {
|
|
|
30
9
|
appName: string;
|
|
31
10
|
}
|
|
32
11
|
|
|
12
|
+
export interface CapabilityStatus {
|
|
13
|
+
name: string;
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
supported: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type SetCapabilityReason = "denied" | "no-email-client" | "unsupported";
|
|
19
|
+
|
|
20
|
+
export interface SetCapabilityResult {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
reason?: SetCapabilityReason;
|
|
23
|
+
}
|
|
24
|
+
|
|
33
25
|
interface DevicePlugin {
|
|
34
26
|
getFcmToken(): Promise<{ token: string }>;
|
|
35
|
-
/** Returns the
|
|
36
|
-
|
|
37
|
-
checkPermission(opts: { type: PermissionType }): Promise<PermissionResult>;
|
|
38
|
-
requestPermission(opts: { type: PermissionType }): Promise<PermissionResult>;
|
|
27
|
+
/** Returns one entry per capability the APK knows about. The PWA renders only these. */
|
|
28
|
+
getCapabilityStatus(): Promise<{ capabilities: CapabilityStatus[] }>;
|
|
39
29
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
30
|
+
* Atomically gates a single capability. When `enabled: true`, the plugin drives
|
|
31
|
+
* any required permission dialog or system-Settings round-trip and writes the
|
|
32
|
+
* native kill-switch only on grant. When `enabled: false`, the kill-switch is
|
|
33
|
+
* removed (no OS dialog). The returned `enabled` reflects the actual stored
|
|
34
|
+
* state; `reason` distinguishes denial from preconditions like
|
|
35
|
+
* "no email app installed".
|
|
44
36
|
*/
|
|
45
|
-
|
|
46
|
-
/** Returns user-visible (launcher) apps on the device
|
|
37
|
+
setCapabilityEnabled(opts: { capability: string; enabled: boolean }): Promise<SetCapabilityResult>;
|
|
38
|
+
/** Returns user-visible (launcher) apps on the device. */
|
|
47
39
|
getInstalledApps(): Promise<{ apps: InstalledApp[] }>;
|
|
48
|
-
/**
|
|
49
|
-
* Returns whether the device has at least one app that can handle a mailto:
|
|
50
|
-
* intent. Used to gate the Sending Email capability — silent PackageManager
|
|
51
|
-
* lookup, no side effects. `supported: false` on older APKs that don't expose
|
|
52
|
-
* this method; treat as "cannot enable" rather than as a hard error.
|
|
53
|
-
*/
|
|
54
|
-
hasEmailClient(): Promise<{ available: boolean; supported: boolean }>;
|
|
55
40
|
addListener(
|
|
56
41
|
event: "deepLink",
|
|
57
42
|
handler: (ev: DeepLinkEvent) => void
|
|
@@ -10,6 +10,7 @@ import HostMenu from "../components/HostMenu";
|
|
|
10
10
|
import ConnectionStatusIcon from "../components/ConnectionStatusIcon";
|
|
11
11
|
import SessionsView from "../components/SessionsView";
|
|
12
12
|
import RunDetailView from "../components/RunDetailView";
|
|
13
|
+
import { loadEnabledCapabilities } from "../components/CapabilityToggles";
|
|
13
14
|
import { usePushSubscription } from "../hooks/usePushSubscription";
|
|
14
15
|
import { useMediaQuery } from "../hooks/useMediaQuery";
|
|
15
16
|
import { setAgentLabels } from "../agentLabels";
|
|
@@ -45,14 +46,16 @@ interface PermissionPrompt { permissions: RequiredPermission[]; sessionName?: st
|
|
|
45
46
|
interface InputPrompt { questions: string[]; description?: string; sessionName?: string }
|
|
46
47
|
|
|
47
48
|
export default function Dashboard() {
|
|
48
|
-
const {
|
|
49
|
-
const { connected, request, subscribeEvents, unauthorized } = useHostConnection();
|
|
49
|
+
const { removePairedHost, setHostLanUrl } = useHostStore();
|
|
50
|
+
const { connected, request, subscribeEvents, unauthorized, activeHost } = useHostConnection();
|
|
51
|
+
const hostId = activeHost.hostId;
|
|
52
|
+
const activeClientToken = activeHost.clientToken || null;
|
|
50
53
|
const navigate = useNavigate();
|
|
51
54
|
const location = useLocation();
|
|
52
55
|
const params = useParams<{ taskId?: string; runId?: string }>();
|
|
53
56
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
54
57
|
|
|
55
|
-
const isTasksTab = location.pathname.
|
|
58
|
+
const isTasksTab = location.pathname.endsWith("/tasks");
|
|
56
59
|
const runsFilterTaskId = params.runId ? undefined : params.taskId;
|
|
57
60
|
|
|
58
61
|
// "latest" is passed through to RunDetailView, which does its own resolution
|
|
@@ -63,10 +66,13 @@ export default function Dashboard() {
|
|
|
63
66
|
const [updating, setUpdating] = useState(false);
|
|
64
67
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
|
65
68
|
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
|
|
66
|
-
const [
|
|
69
|
+
const [linkedClientToken, setLinkedClientToken] = useState<string | null>(null);
|
|
70
|
+
const [enabledCapabilities, setEnabledCapabilities] = useState<Set<string>>(new Set());
|
|
67
71
|
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
68
72
|
const [hostPlatform, setHostPlatform] = useState<string | undefined>();
|
|
69
73
|
|
|
74
|
+
const isLinkedDevice = !!activeClientToken && linkedClientToken === activeClientToken;
|
|
75
|
+
|
|
70
76
|
// Pending prompt state — owned by Dashboard because these modals must show
|
|
71
77
|
// regardless of which tab (Sessions/Tasks/RunDetail) is currently rendered.
|
|
72
78
|
const [pendingConfirms, setPendingConfirms] = useState<Map<string, ConfirmPrompt>>(new Map());
|
|
@@ -78,7 +84,13 @@ export default function Dashboard() {
|
|
|
78
84
|
|
|
79
85
|
useEffect(() => {
|
|
80
86
|
window.scrollTo(0, 0);
|
|
81
|
-
}, [
|
|
87
|
+
}, [hostId]);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
let cancelled = false;
|
|
91
|
+
loadEnabledCapabilities().then((caps) => { if (!cancelled) setEnabledCapabilities(caps); });
|
|
92
|
+
return () => { cancelled = true; };
|
|
93
|
+
}, []);
|
|
82
94
|
|
|
83
95
|
// host.info bootstrap: agents/version/platform + any prompts that were already
|
|
84
96
|
// pending when this PWA connected. Runs once per (host, connection).
|
|
@@ -88,21 +100,19 @@ export default function Dashboard() {
|
|
|
88
100
|
agents?: AgentInfo[];
|
|
89
101
|
version?: string | null;
|
|
90
102
|
host_platform?: string;
|
|
91
|
-
|
|
103
|
+
linked_client_token?: string | null;
|
|
92
104
|
pending_prompts?: PendingPrompt[];
|
|
93
105
|
lan_url?: string | null;
|
|
94
106
|
}>("host.info")
|
|
95
107
|
.then((result) => {
|
|
96
108
|
setAgents(result.agents ?? []);
|
|
97
109
|
setHostPlatform(result.host_platform);
|
|
98
|
-
|
|
110
|
+
setLinkedClientToken(result.linked_client_token ?? null);
|
|
99
111
|
setAgentLabels(result.agents ?? []);
|
|
100
112
|
const version = result.version ?? null;
|
|
101
113
|
setDaemonVersion(version);
|
|
102
114
|
setUpdateRequired(!!version && isOlderThan(version, MIN_HOST_VERSION));
|
|
103
|
-
|
|
104
|
-
setHostLanUrl(activeHostId, result.lan_url ?? undefined);
|
|
105
|
-
}
|
|
115
|
+
setHostLanUrl(hostId, result.lan_url ?? undefined);
|
|
106
116
|
|
|
107
117
|
// Seed modal state from already-pending prompts.
|
|
108
118
|
const confirms = new Map<string, ConfirmPrompt>();
|
|
@@ -136,13 +146,13 @@ export default function Dashboard() {
|
|
|
136
146
|
setInputValues(inputVals);
|
|
137
147
|
})
|
|
138
148
|
.catch(() => { /* silent — update-required prompt guards the broken case */ });
|
|
139
|
-
}, [connected,
|
|
149
|
+
}, [connected, hostId, request, setHostLanUrl]);
|
|
140
150
|
|
|
141
151
|
// Always-on event subscription for modal lifecycle. Independent of which tab
|
|
142
152
|
// is active. Task-card status updates happen inside TasksView while mounted.
|
|
143
153
|
useEffect(() => {
|
|
144
|
-
if (!connected
|
|
145
|
-
const unsubscribe = subscribeEvents(
|
|
154
|
+
if (!connected) return;
|
|
155
|
+
const unsubscribe = subscribeEvents(hostId, (msg) => {
|
|
146
156
|
const tokens = msg.subject.split(".");
|
|
147
157
|
if (tokens.length < 3) return;
|
|
148
158
|
const taskId = tokens.slice(2).join(".");
|
|
@@ -239,7 +249,7 @@ export default function Dashboard() {
|
|
|
239
249
|
}
|
|
240
250
|
});
|
|
241
251
|
return unsubscribe;
|
|
242
|
-
}, [connected,
|
|
252
|
+
}, [connected, hostId, subscribeEvents]);
|
|
243
253
|
|
|
244
254
|
async function respondToConfirm(sessionId: string, response: "confirmed" | "aborted") {
|
|
245
255
|
try {
|
|
@@ -267,11 +277,8 @@ export default function Dashboard() {
|
|
|
267
277
|
|
|
268
278
|
function handleViewRun(taskId: string, runId?: string) {
|
|
269
279
|
if (!confirmLeaveDraft()) return;
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
} else {
|
|
273
|
-
navigate(`/runs/${encodeURIComponent(taskId)}`);
|
|
274
|
-
}
|
|
280
|
+
const base = `/hosts/${encodeURIComponent(hostId)}/runs/${encodeURIComponent(taskId)}`;
|
|
281
|
+
navigate(runId ? `${base}/${encodeURIComponent(runId)}` : base);
|
|
275
282
|
}
|
|
276
283
|
|
|
277
284
|
async function handleUpdate() {
|
|
@@ -290,18 +297,16 @@ export default function Dashboard() {
|
|
|
290
297
|
setTimeout(() => window.location.reload(), 15000);
|
|
291
298
|
}
|
|
292
299
|
|
|
293
|
-
const
|
|
294
|
-
const showTaskContent = hasHosts && connected && activeHostId && !unauthorized;
|
|
295
|
-
const activeClientToken = pairedHosts.find((h) => h.hostId === activeHostId)?.clientToken ?? null;
|
|
300
|
+
const showTaskContent = connected && !unauthorized;
|
|
296
301
|
|
|
297
302
|
return (
|
|
298
303
|
<div className="dashboard">
|
|
299
|
-
{isDesktop && <HostMenu daemonVersion={daemonVersion}
|
|
304
|
+
{isDesktop && <HostMenu daemonVersion={daemonVersion} linkedClientToken={linkedClientToken} request={request} onEnabledCapabilitiesChange={setEnabledCapabilities} onLinkedClientTokenChange={setLinkedClientToken} />}
|
|
300
305
|
|
|
301
306
|
<div className="dashboard-content">
|
|
302
307
|
<header className="app-header">
|
|
303
308
|
<div className="app-title-bar">
|
|
304
|
-
{!isDesktop && <HostMenu daemonVersion={daemonVersion}
|
|
309
|
+
{!isDesktop && <HostMenu daemonVersion={daemonVersion} linkedClientToken={linkedClientToken} request={request} onEnabledCapabilitiesChange={setEnabledCapabilities} onLinkedClientTokenChange={setLinkedClientToken} />}
|
|
305
310
|
<h1 className="app-title">Palmier</h1>
|
|
306
311
|
<ConnectionStatusIcon />
|
|
307
312
|
</div>
|
|
@@ -336,9 +341,7 @@ export default function Dashboard() {
|
|
|
336
341
|
</button>
|
|
337
342
|
<button
|
|
338
343
|
className="btn btn-secondary"
|
|
339
|
-
onClick={() => {
|
|
340
|
-
if (activeHostId) removePairedHost(activeHostId);
|
|
341
|
-
}}
|
|
344
|
+
onClick={() => { removePairedHost(hostId); navigate("/", { replace: true }); }}
|
|
342
345
|
>
|
|
343
346
|
Remove Host
|
|
344
347
|
</button>
|
|
@@ -349,19 +352,19 @@ export default function Dashboard() {
|
|
|
349
352
|
{isTasksTab && !isRunDetail && (
|
|
350
353
|
<TasksView
|
|
351
354
|
connected={connected}
|
|
352
|
-
hostId={
|
|
355
|
+
hostId={hostId}
|
|
353
356
|
request={request}
|
|
354
357
|
subscribeEvents={subscribeEvents}
|
|
355
358
|
agents={agents}
|
|
356
359
|
hostPlatform={hostPlatform}
|
|
357
|
-
isNotificationListener={
|
|
360
|
+
isNotificationListener={isLinkedDevice && enabledCapabilities.has("notifications")}
|
|
358
361
|
onViewRun={handleViewRun}
|
|
359
362
|
/>
|
|
360
363
|
)}
|
|
361
364
|
{isRunDetail ? (
|
|
362
365
|
<RunDetailView
|
|
363
366
|
connected={connected}
|
|
364
|
-
hostId={
|
|
367
|
+
hostId={hostId}
|
|
365
368
|
request={request}
|
|
366
369
|
subscribeEvents={subscribeEvents}
|
|
367
370
|
taskId={params.taskId!}
|
|
@@ -370,27 +373,19 @@ export default function Dashboard() {
|
|
|
370
373
|
) : !isTasksTab ? (
|
|
371
374
|
<SessionsView
|
|
372
375
|
connected={connected}
|
|
373
|
-
hostId={
|
|
376
|
+
hostId={hostId}
|
|
374
377
|
request={request}
|
|
375
378
|
subscribeEvents={subscribeEvents}
|
|
376
379
|
agents={agents}
|
|
377
380
|
hostPlatform={hostPlatform}
|
|
378
381
|
filterTaskId={runsFilterTaskId}
|
|
379
|
-
onClearFilter={() => { if (confirmLeaveDraft()) navigate(
|
|
382
|
+
onClearFilter={() => { if (confirmLeaveDraft()) navigate(`/hosts/${encodeURIComponent(hostId)}`); }}
|
|
380
383
|
/>
|
|
381
384
|
) : null}
|
|
382
385
|
</>
|
|
383
386
|
) : (
|
|
384
387
|
<div className="empty-state">
|
|
385
|
-
<p>
|
|
386
|
-
{!hasHosts && (
|
|
387
|
-
<button
|
|
388
|
-
className="btn btn-primary"
|
|
389
|
-
onClick={() => navigate("/pair")}
|
|
390
|
-
>
|
|
391
|
-
Pair Host
|
|
392
|
-
</button>
|
|
393
|
-
)}
|
|
388
|
+
<p>Connecting to host...</p>
|
|
394
389
|
</div>
|
|
395
390
|
)}
|
|
396
391
|
</main>
|
|
@@ -18,8 +18,11 @@ interface PairResponse {
|
|
|
18
18
|
const isLoopback = !!(window as any).__PALMIER_SERVE__
|
|
19
19
|
&& (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
|
|
20
20
|
|
|
21
|
+
const isNative = Capacitor.isNativePlatform();
|
|
22
|
+
|
|
21
23
|
export default function PairHost() {
|
|
22
24
|
const [code, setCode] = useState("");
|
|
25
|
+
const [makeLinked, setMakeLinked] = useState(true);
|
|
23
26
|
const [pairing, setPairing] = useState(false);
|
|
24
27
|
const [error, setError] = useState<string | null>(null);
|
|
25
28
|
const { addPairedHost } = useHostStore();
|
|
@@ -106,7 +109,8 @@ export default function PairHost() {
|
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
|
|
112
|
+
const base = `/hosts/${encodeURIComponent(response.hostId)}`;
|
|
113
|
+
navigate(isNative && makeLinked ? `${base}/pair/setup` : base);
|
|
110
114
|
} catch (err) {
|
|
111
115
|
const message = err instanceof Error ? err.message : String(err);
|
|
112
116
|
if (message.includes("timeout") || message.includes("TIMEOUT") || message.includes("503") || message.toLowerCase().includes("no responders")) {
|
|
@@ -176,6 +180,21 @@ export default function PairHost() {
|
|
|
176
180
|
/>
|
|
177
181
|
</label>
|
|
178
182
|
|
|
183
|
+
{isNative && (
|
|
184
|
+
<label className="pair-checkbox">
|
|
185
|
+
<input
|
|
186
|
+
type="checkbox"
|
|
187
|
+
checked={makeLinked}
|
|
188
|
+
onChange={(e) => setMakeLinked(e.target.checked)}
|
|
189
|
+
disabled={pairing}
|
|
190
|
+
/>
|
|
191
|
+
<span className="pair-checkbox-text">
|
|
192
|
+
<span className="pair-checkbox-title">Link this device</span>
|
|
193
|
+
<span className="pair-checkbox-hint">The host will use this device for SMS, contacts, calendar, location, and alarms. Only one device can be linked at a time.</span>
|
|
194
|
+
</span>
|
|
195
|
+
</label>
|
|
196
|
+
)}
|
|
197
|
+
|
|
179
198
|
{error && <p className="pair-error">{error}</p>}
|
|
180
199
|
|
|
181
200
|
<button
|