@peers-app/peers-ui 0.20.6 → 0.21.1

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 (31) hide show
  1. package/dist/root-layout/root-layout-host.js +3 -0
  2. package/dist/screens/device-pairing/device-pairing-invite.d.ts +10 -0
  3. package/dist/screens/device-pairing/device-pairing-invite.js +50 -0
  4. package/dist/screens/device-pairing/device-pairing-invite.test.d.ts +1 -0
  5. package/dist/screens/device-pairing/device-pairing-invite.test.js +30 -0
  6. package/dist/screens/device-pairing/device-pairing.d.ts +10 -0
  7. package/dist/screens/device-pairing/device-pairing.js +237 -0
  8. package/dist/screens/device-pairing/device-pairing.test.d.ts +1 -0
  9. package/dist/screens/device-pairing/device-pairing.test.js +88 -0
  10. package/dist/screens/device-pairing/index.d.ts +3 -0
  11. package/dist/screens/device-pairing/index.js +19 -0
  12. package/dist/screens/device-pairing/use-device-pairing-state.d.ts +3 -0
  13. package/dist/screens/device-pairing/use-device-pairing-state.js +31 -0
  14. package/dist/screens/packages/package-helpers.d.ts +21 -0
  15. package/dist/screens/packages/package-helpers.js +32 -0
  16. package/dist/screens/packages/package-versions.d.ts +1 -0
  17. package/dist/screens/packages/package-versions.js +48 -12
  18. package/dist/screens/settings/settings-page.js +6 -1
  19. package/dist/screens/setup-user.js +24 -1
  20. package/package.json +3 -3
  21. package/src/root-layout/root-layout-host.tsx +3 -0
  22. package/src/screens/device-pairing/device-pairing-invite.test.ts +50 -0
  23. package/src/screens/device-pairing/device-pairing-invite.ts +55 -0
  24. package/src/screens/device-pairing/device-pairing.test.tsx +100 -0
  25. package/src/screens/device-pairing/device-pairing.tsx +390 -0
  26. package/src/screens/device-pairing/index.ts +3 -0
  27. package/src/screens/device-pairing/use-device-pairing-state.ts +38 -0
  28. package/src/screens/packages/package-helpers.ts +39 -0
  29. package/src/screens/packages/package-versions.tsx +52 -13
  30. package/src/screens/settings/settings-page.tsx +22 -0
  31. package/src/screens/setup-user.tsx +63 -1
@@ -21,6 +21,7 @@ const command_palette_ui_1 = require("../command-palette/command-palette-ui");
21
21
  const globals_1 = require("../globals");
22
22
  const hooks_1 = require("../hooks");
23
23
  const user_connect_invite_1 = require("../screens/contacts/user-connect-invite");
24
+ const device_pairing_1 = require("../screens/device-pairing");
24
25
  const color_mode_dropdown_1 = require("../screens/settings/color-mode-dropdown");
25
26
  const setup_user_1 = require("../screens/setup-user");
26
27
  const welcome_modal_1 = require("../screens/welcome-modal");
@@ -36,6 +37,7 @@ const root_layout_state_1 = require("./root-layout-state");
36
37
  */
37
38
  function RootLayoutHost() {
38
39
  (0, react_1.useState)(() => {
40
+ (0, device_pairing_1.captureDevicePairingInviteFromLocation)();
39
41
  (0, user_connect_invite_1.captureUserConnectInviteFromLocation)();
40
42
  return true;
41
43
  });
@@ -48,6 +50,7 @@ function RootLayoutHost() {
48
50
  (0, react_1.useEffect)(() => {
49
51
  const captureInviteFromHashChange = () => {
50
52
  const previousHash = window.location.hash;
53
+ (0, device_pairing_1.captureDevicePairingInviteFromLocation)();
51
54
  (0, user_connect_invite_1.captureUserConnectInviteFromLocation)();
52
55
  if (window.location.hash === previousHash) {
53
56
  return;
@@ -0,0 +1,10 @@
1
+ /** Browser event emitted after a pairing invitation is captured and removed from the URL. */
2
+ export declare const DEVICE_PAIRING_INVITE_CAPTURED_EVENT = "device-pairing:invite-captured";
3
+ /** Captures a pairing fragment in memory and immediately removes it from browser history. */
4
+ export declare function captureDevicePairingInviteFromLocation(location?: Location): string | undefined;
5
+ /** Returns the in-memory invitation without consuming it. */
6
+ export declare function readPendingDevicePairingInvite(): string | undefined;
7
+ /** Returns and erases the in-memory invitation. */
8
+ export declare function consumePendingDevicePairingInvite(): string | undefined;
9
+ /** Erases any unconsumed invitation. */
10
+ export declare function clearPendingDevicePairingInvite(): void;
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEVICE_PAIRING_INVITE_CAPTURED_EVENT = void 0;
4
+ exports.captureDevicePairingInviteFromLocation = captureDevicePairingInviteFromLocation;
5
+ exports.readPendingDevicePairingInvite = readPendingDevicePairingInvite;
6
+ exports.consumePendingDevicePairingInvite = consumePendingDevicePairingInvite;
7
+ exports.clearPendingDevicePairingInvite = clearPendingDevicePairingInvite;
8
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
9
+ let pendingInvitation;
10
+ /** Browser event emitted after a pairing invitation is captured and removed from the URL. */
11
+ exports.DEVICE_PAIRING_INVITE_CAPTURED_EVENT = "device-pairing:invite-captured";
12
+ /** Captures a pairing fragment in memory and immediately removes it from browser history. */
13
+ function captureDevicePairingInviteFromLocation(location = window.location) {
14
+ if (!location.hash.startsWith("#pair=") && !location.hash.startsWith("#pair?")) {
15
+ return undefined;
16
+ }
17
+ let capturedInvitation;
18
+ try {
19
+ capturedInvitation = (0, peers_sdk_1.encodeDevicePairingInvitation)((0, peers_sdk_1.parseDevicePairingInvitation)(location.href));
20
+ pendingInvitation = capturedInvitation;
21
+ return capturedInvitation;
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ finally {
27
+ if (typeof window !== "undefined" && location === window.location) {
28
+ window.history.replaceState(window.history.state, document.title, `${location.pathname}${location.search}`);
29
+ if (capturedInvitation) {
30
+ window.dispatchEvent(new CustomEvent(exports.DEVICE_PAIRING_INVITE_CAPTURED_EVENT, {
31
+ detail: capturedInvitation,
32
+ }));
33
+ }
34
+ }
35
+ }
36
+ }
37
+ /** Returns the in-memory invitation without consuming it. */
38
+ function readPendingDevicePairingInvite() {
39
+ return pendingInvitation;
40
+ }
41
+ /** Returns and erases the in-memory invitation. */
42
+ function consumePendingDevicePairingInvite() {
43
+ const invitation = pendingInvitation;
44
+ pendingInvitation = undefined;
45
+ return invitation;
46
+ }
47
+ /** Erases any unconsumed invitation. */
48
+ function clearPendingDevicePairingInvite() {
49
+ pendingInvitation = undefined;
50
+ }
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
4
+ const device_pairing_invite_1 = require("./device-pairing-invite");
5
+ describe("device pairing invitation capture", () => {
6
+ beforeEach(() => {
7
+ (0, device_pairing_invite_1.clearPendingDevicePairingInvite)();
8
+ window.history.replaceState({}, "", "/?app");
9
+ });
10
+ it("captures the fragment only in memory and removes it from browser history", () => {
11
+ const invitation = (0, peers_sdk_1.createDevicePairingInvitation)({
12
+ serviceOrigin: "https://peers.app",
13
+ });
14
+ const inviteUrl = (0, peers_sdk_1.createDevicePairingInvitationUrl)(`${window.location.origin}/?app`, invitation);
15
+ window.history.replaceState({}, "", new URL(inviteUrl).pathname + new URL(inviteUrl).search + new URL(inviteUrl).hash);
16
+ const token = (0, device_pairing_invite_1.captureDevicePairingInviteFromLocation)();
17
+ expect(token).toBeDefined();
18
+ expect((0, peers_sdk_1.parseDevicePairingInvitation)(token ?? "")).toEqual(invitation);
19
+ expect(window.location.hash).toBe("");
20
+ expect((0, device_pairing_invite_1.readPendingDevicePairingInvite)()).toBe(token);
21
+ expect((0, device_pairing_invite_1.consumePendingDevicePairingInvite)()).toBe(token);
22
+ expect((0, device_pairing_invite_1.readPendingDevicePairingInvite)()).toBeUndefined();
23
+ });
24
+ it("ignores invalid fragments without retaining them", () => {
25
+ window.history.replaceState({}, "", "/?app#pair=invalid");
26
+ expect((0, device_pairing_invite_1.captureDevicePairingInviteFromLocation)()).toBeUndefined();
27
+ expect((0, device_pairing_invite_1.readPendingDevicePairingInvite)()).toBeUndefined();
28
+ expect(window.location.hash).toBe("");
29
+ });
30
+ });
@@ -0,0 +1,10 @@
1
+ /** Signed-in source flow opened from Settings. */
2
+ export declare function SourceDevicePairing(props: {
3
+ onClose: () => void;
4
+ }): import("react/jsx-runtime").JSX.Element;
5
+ /** Signed-out destination flow opened by a QR link or manual invitation paste. */
6
+ export declare function DestinationDevicePairing(props: {
7
+ initialInvitation?: string;
8
+ onInvitationConsumed?: () => void;
9
+ onBack: () => void;
10
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,237 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.SourceDevicePairing = SourceDevicePairing;
7
+ exports.DestinationDevicePairing = DestinationDevicePairing;
8
+ const jsx_runtime_1 = require("react/jsx-runtime");
9
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
10
+ const react_1 = require("react");
11
+ const react_qr_code_1 = __importDefault(require("react-qr-code"));
12
+ const use_device_pairing_state_1 = require("./use-device-pairing-state");
13
+ function formatCountdown(expiresAt) {
14
+ if (!expiresAt) {
15
+ return "";
16
+ }
17
+ const seconds = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
18
+ return `${Math.floor(seconds / 60)}:${(seconds % 60).toString().padStart(2, "0")}`;
19
+ }
20
+ function PairingProgress({ state }) {
21
+ const messages = {
22
+ inviting: "Waiting for the new device…",
23
+ connecting: "Establishing a secure connection…",
24
+ "waiting-for-approval": "Waiting for approval on the other device…",
25
+ transferring: "Installing encrypted account credentials…",
26
+ };
27
+ return ((0, jsx_runtime_1.jsxs)("output", { className: "text-center py-4 d-block", children: [(0, jsx_runtime_1.jsx)("div", { className: "spinner-border text-primary mb-3", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted mb-0", children: messages[state.phase] ?? "Working…" })] }));
28
+ }
29
+ function PairingComparison(props) {
30
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 440, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("h4", { children: "Compare these numbers" }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted", children: "Confirm that both devices show the same number and the expected device before approving." }), (0, jsx_runtime_1.jsx)("div", { className: "display-4 font-monospace text-primary mb-3", style: { letterSpacing: "0.12em" }, children: props.state.sas }), (0, jsx_runtime_1.jsxs)("p", { className: "mb-4", children: [props.state.remoteDevice?.deviceName || "Unnamed device", props.state.remoteDevice?.deviceType ? ` (${props.state.remoteDevice.deviceType})` : ""] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 justify-content-center", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary", disabled: props.approving, onClick: props.onApprove, children: props.approving ? "Approving…" : "Numbers match — approve" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-danger", disabled: props.approving, onClick: props.onReject, children: "Reject" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", disabled: props.approving, onClick: props.onCancel, children: "Cancel" })] })] }));
31
+ }
32
+ function TerminalPanel(props) {
33
+ const complete = props.state.phase === "complete";
34
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 440, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("output", { className: `alert d-block ${complete ? "alert-success" : "alert-warning"}`, children: complete
35
+ ? "Device added successfully."
36
+ : props.state.phase === "rejected"
37
+ ? "Pairing was rejected. No credentials were installed."
38
+ : props.state.error || "Pairing could not be completed. No credentials were installed." }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onClose, children: complete ? "Done" : "Back" })] }));
39
+ }
40
+ function ProgressWithCancel(props) {
41
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(PairingProgress, { state: props.state }), (0, jsx_runtime_1.jsx)("div", { className: "text-center", children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onCancel, children: "Cancel" }) })] }));
42
+ }
43
+ function usePairingSessionLifecycle(state) {
44
+ const sessionId = (0, react_1.useRef)(undefined);
45
+ const phase = (0, react_1.useRef)("idle");
46
+ const mounted = (0, react_1.useRef)(true);
47
+ (0, react_1.useEffect)(() => {
48
+ if (state.sessionId && state.sessionId === sessionId.current) {
49
+ phase.current = state.phase;
50
+ }
51
+ }, [state.phase, state.sessionId]);
52
+ (0, react_1.useEffect)(() => {
53
+ mounted.current = true;
54
+ return () => {
55
+ mounted.current = false;
56
+ const activeSessionId = sessionId.current;
57
+ if (activeSessionId && !["idle", "complete", "rejected", "error"].includes(phase.current)) {
58
+ void peers_sdk_1.rpcServerCalls
59
+ .devicePairingCancel({ sessionId: activeSessionId })
60
+ .catch(() => undefined);
61
+ }
62
+ };
63
+ }, []);
64
+ return { sessionId, phase, mounted };
65
+ }
66
+ /** Signed-in source flow opened from Settings. */
67
+ function SourceDevicePairing(props) {
68
+ const state = (0, use_device_pairing_state_1.useDevicePairingState)();
69
+ const lifecycle = usePairingSessionLifecycle(state);
70
+ const started = (0, react_1.useRef)(false);
71
+ const [invitationUrl, setInvitationUrl] = (0, react_1.useState)("");
72
+ const [localError, setLocalError] = (0, react_1.useState)("");
73
+ const [approving, setApproving] = (0, react_1.useState)(false);
74
+ const [, setClock] = (0, react_1.useState)(0);
75
+ (0, react_1.useEffect)(() => {
76
+ if (started.current) {
77
+ return;
78
+ }
79
+ started.current = true;
80
+ void peers_sdk_1.rpcServerCalls
81
+ .devicePairingStartSource()
82
+ .then(async (result) => {
83
+ lifecycle.sessionId.current = result.state.sessionId;
84
+ lifecycle.phase.current = result.state.phase;
85
+ if (!lifecycle.mounted.current && result.state.sessionId) {
86
+ await peers_sdk_1.rpcServerCalls
87
+ .devicePairingCancel({ sessionId: result.state.sessionId })
88
+ .catch(() => undefined);
89
+ return;
90
+ }
91
+ setInvitationUrl(result.invitationUrl);
92
+ })
93
+ .catch(() => setLocalError("Could not create a pairing invitation."));
94
+ }, []);
95
+ (0, react_1.useEffect)(() => {
96
+ if (!state.expiresAt) {
97
+ return;
98
+ }
99
+ const timer = window.setInterval(() => setClock((value) => value + 1), 1000);
100
+ return () => window.clearInterval(timer);
101
+ }, [state.expiresAt]);
102
+ (0, react_1.useEffect)(() => {
103
+ if (state.phase !== "inviting") {
104
+ setInvitationUrl("");
105
+ }
106
+ }, [state.phase]);
107
+ const act = async (action) => {
108
+ const sessionId = lifecycle.sessionId.current;
109
+ if (!sessionId) {
110
+ return;
111
+ }
112
+ setApproving(true);
113
+ try {
114
+ if (action === "approve") {
115
+ await peers_sdk_1.rpcServerCalls.devicePairingApprove({ sessionId });
116
+ }
117
+ else {
118
+ await peers_sdk_1.rpcServerCalls.devicePairingReject({ sessionId });
119
+ }
120
+ }
121
+ catch {
122
+ setLocalError("Could not update the pairing ceremony.");
123
+ }
124
+ finally {
125
+ setApproving(false);
126
+ }
127
+ };
128
+ const cancel = async () => {
129
+ const sessionId = lifecycle.sessionId.current;
130
+ lifecycle.phase.current = "idle";
131
+ if (sessionId) {
132
+ await peers_sdk_1.rpcServerCalls.devicePairingCancel({ sessionId }).catch(() => undefined);
133
+ }
134
+ props.onClose();
135
+ };
136
+ if (localError) {
137
+ return (0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger", children: localError });
138
+ }
139
+ if (["complete", "rejected", "error"].includes(state.phase)) {
140
+ return (0, jsx_runtime_1.jsx)(TerminalPanel, { state: state, onClose: props.onClose });
141
+ }
142
+ if (state.phase === "comparing" && state.sas) {
143
+ return ((0, jsx_runtime_1.jsx)(PairingComparison, { state: state, approving: approving, onApprove: () => void act("approve"), onReject: () => void act("reject"), onCancel: () => void cancel() }));
144
+ }
145
+ if (state.phase === "waiting-for-approval" || state.phase === "transferring") {
146
+ return (0, jsx_runtime_1.jsx)(ProgressWithCancel, { state: state, onCancel: () => void cancel() });
147
+ }
148
+ if (invitationUrl) {
149
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 440, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("p", { className: "text-muted", children: "Scan this on the signed-out device you want to add." }), (0, jsx_runtime_1.jsx)("div", { className: "d-inline-block bg-white rounded p-3 mb-3", children: (0, jsx_runtime_1.jsx)(react_qr_code_1.default, { value: invitationUrl, size: 220, "aria-label": "Device pairing QR code" }) }), (0, jsx_runtime_1.jsxs)("p", { className: "small text-muted", children: ["Expires in ", formatCountdown(state.expiresAt)] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 justify-content-center", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-primary", onClick: () => void (0, peers_sdk_1.copyToClipboard)(invitationUrl), children: "Copy pairing link" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: () => void cancel(), children: "Cancel" })] })] }));
150
+ }
151
+ return (0, jsx_runtime_1.jsx)(PairingProgress, { state: state });
152
+ }
153
+ /** Signed-out destination flow opened by a QR link or manual invitation paste. */
154
+ function DestinationDevicePairing(props) {
155
+ const state = (0, use_device_pairing_state_1.useDevicePairingState)();
156
+ const lifecycle = usePairingSessionLifecycle(state);
157
+ const [invitation, setInvitation] = (0, react_1.useState)(props.initialInvitation ?? "");
158
+ const [localError, setLocalError] = (0, react_1.useState)("");
159
+ const [approving, setApproving] = (0, react_1.useState)(false);
160
+ const autoStarted = (0, react_1.useRef)(false);
161
+ const start = async (value = invitation) => {
162
+ setLocalError("");
163
+ try {
164
+ const result = await peers_sdk_1.rpcServerCalls.devicePairingStartDestination({
165
+ invitation: value.trim(),
166
+ deviceType: /Mobi|Android/i.test(navigator.userAgent) ? "mobile" : "browser",
167
+ });
168
+ lifecycle.sessionId.current = result.sessionId;
169
+ lifecycle.phase.current = result.phase;
170
+ if (!lifecycle.mounted.current && result.sessionId) {
171
+ await peers_sdk_1.rpcServerCalls
172
+ .devicePairingCancel({ sessionId: result.sessionId })
173
+ .catch(() => undefined);
174
+ return;
175
+ }
176
+ setInvitation("");
177
+ props.onInvitationConsumed?.();
178
+ }
179
+ catch {
180
+ setLocalError("That pairing invitation is invalid, expired, or unavailable.");
181
+ }
182
+ };
183
+ (0, react_1.useEffect)(() => {
184
+ if (!props.initialInvitation || autoStarted.current) {
185
+ return;
186
+ }
187
+ autoStarted.current = true;
188
+ void start(props.initialInvitation);
189
+ }, [props.initialInvitation]);
190
+ (0, react_1.useEffect)(() => {
191
+ if (state.phase !== "complete") {
192
+ return;
193
+ }
194
+ window.setTimeout(() => window.location.reload(), 750);
195
+ }, [state.phase]);
196
+ const act = async (action) => {
197
+ const sessionId = lifecycle.sessionId.current;
198
+ if (!sessionId) {
199
+ return;
200
+ }
201
+ setApproving(true);
202
+ try {
203
+ if (action === "approve") {
204
+ await peers_sdk_1.rpcServerCalls.devicePairingApprove({ sessionId });
205
+ }
206
+ else {
207
+ await peers_sdk_1.rpcServerCalls.devicePairingReject({ sessionId });
208
+ }
209
+ }
210
+ catch {
211
+ setLocalError("Could not update the pairing ceremony.");
212
+ }
213
+ finally {
214
+ setApproving(false);
215
+ }
216
+ };
217
+ const cancel = async () => {
218
+ const sessionId = lifecycle.sessionId.current;
219
+ lifecycle.phase.current = "idle";
220
+ if (sessionId) {
221
+ await peers_sdk_1.rpcServerCalls.devicePairingCancel({ sessionId }).catch(() => undefined);
222
+ }
223
+ props.onBack();
224
+ };
225
+ if (["complete", "rejected", "error"].includes(state.phase)) {
226
+ return (0, jsx_runtime_1.jsx)(TerminalPanel, { state: state, onClose: props.onBack });
227
+ }
228
+ if (state.phase === "comparing" && state.sas) {
229
+ return ((0, jsx_runtime_1.jsx)(PairingComparison, { state: state, approving: approving, onApprove: () => void act("approve"), onReject: () => void act("reject"), onCancel: () => void cancel() }));
230
+ }
231
+ if (state.phase === "connecting" ||
232
+ state.phase === "waiting-for-approval" ||
233
+ state.phase === "transferring") {
234
+ return (0, jsx_runtime_1.jsx)(ProgressWithCancel, { state: state, onCancel: () => void cancel() });
235
+ }
236
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 500, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("h3", { children: "Sign in using another device" }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted", children: "On a device already signed in to Peers, choose Add another device. Scan its QR code, or paste the complete pairing link or token below." }), (0, jsx_runtime_1.jsx)("textarea", { className: "form-control font-monospace mb-3", rows: 4, placeholder: "Paste pairing link or token", value: invitation, onChange: (event) => setInvitation(event.target.value), autoComplete: "off", "aria-label": "Device pairing invitation" }), localError ? (0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger", children: localError }) : null, (0, jsx_runtime_1.jsxs)("div", { className: "d-grid gap-2", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary btn-lg", disabled: !invitation.trim(), onClick: () => void start(), children: "Connect securely" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onBack, children: "Back" })] })] }));
237
+ }
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const jsx_runtime_1 = require("react/jsx-runtime");
4
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
5
+ const react_1 = require("@testing-library/react");
6
+ const device_pairing_1 = require("./device-pairing");
7
+ const mockRpc = {
8
+ startSource: jest.fn(),
9
+ startDestination: jest.fn(),
10
+ approve: jest.fn(),
11
+ reject: jest.fn(),
12
+ cancel: jest.fn(),
13
+ };
14
+ let mockPairingState = { phase: "idle" };
15
+ peers_sdk_1.rpcServerCalls.devicePairingStartSource =
16
+ mockRpc.startSource;
17
+ peers_sdk_1.rpcServerCalls.devicePairingStartDestination =
18
+ mockRpc.startDestination;
19
+ peers_sdk_1.rpcServerCalls.devicePairingApprove = mockRpc.approve;
20
+ peers_sdk_1.rpcServerCalls.devicePairingReject = mockRpc.reject;
21
+ peers_sdk_1.rpcServerCalls.devicePairingCancel = mockRpc.cancel;
22
+ jest.mock("./use-device-pairing-state", () => ({
23
+ useDevicePairingState: () => mockPairingState,
24
+ }));
25
+ describe("device pairing UI lifecycle", () => {
26
+ beforeEach(() => {
27
+ jest.clearAllMocks();
28
+ mockPairingState = { phase: "idle" };
29
+ mockRpc.cancel.mockResolvedValue({ phase: "idle" });
30
+ });
31
+ it("uses the host-owned invitation URL and cancels a source that resolves after unmount", async () => {
32
+ let resolveStart;
33
+ mockRpc.startSource.mockReturnValue(new Promise((resolve) => {
34
+ resolveStart = resolve;
35
+ }));
36
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(device_pairing_1.SourceDevicePairing, { onClose: jest.fn() }));
37
+ expect(mockRpc.startSource).toHaveBeenCalledWith();
38
+ view.unmount();
39
+ resolveStart({
40
+ state: { sessionId: "00mh0wlipkdbeaw8tests0001", role: "source", phase: "inviting" },
41
+ invitationUrl: "https://peers.app/?app#pair=token",
42
+ });
43
+ await (0, react_1.waitFor)(() => {
44
+ expect(mockRpc.cancel).toHaveBeenCalledWith({
45
+ sessionId: "00mh0wlipkdbeaw8tests0001",
46
+ });
47
+ });
48
+ });
49
+ it("keeps source cancellation available while waiting for remote approval", async () => {
50
+ const onClose = jest.fn();
51
+ mockPairingState = {
52
+ sessionId: "00mh0wlipkdbeaw8tests0002",
53
+ role: "source",
54
+ phase: "waiting-for-approval",
55
+ };
56
+ mockRpc.startSource.mockResolvedValue({
57
+ state: mockPairingState,
58
+ invitationUrl: "https://peers.app/?app#pair=token",
59
+ });
60
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(device_pairing_1.SourceDevicePairing, { onClose: onClose }));
61
+ await (0, react_1.waitFor)(() => expect(mockRpc.startSource).toHaveBeenCalled());
62
+ react_1.fireEvent.click(view.getByRole("button", { name: "Cancel" }));
63
+ await (0, react_1.waitFor)(() => {
64
+ expect(mockRpc.cancel).toHaveBeenCalledWith({
65
+ sessionId: "00mh0wlipkdbeaw8tests0002",
66
+ });
67
+ expect(onClose).toHaveBeenCalled();
68
+ });
69
+ });
70
+ it("does not clear the destination success reload when Done unmounts the flow", () => {
71
+ jest.useFakeTimers();
72
+ mockPairingState = {
73
+ sessionId: "00mh0wlipkdbeaw8tests0003",
74
+ role: "destination",
75
+ phase: "complete",
76
+ };
77
+ const setTimeoutSpy = jest.spyOn(window, "setTimeout");
78
+ const clearTimeoutSpy = jest.spyOn(window, "clearTimeout");
79
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(device_pairing_1.DestinationDevicePairing, { onBack: jest.fn() }));
80
+ const reloadTimer = setTimeoutSpy.mock.calls.find(([, delay]) => delay === 750);
81
+ expect(reloadTimer).toBeDefined();
82
+ view.unmount();
83
+ expect(clearTimeoutSpy).not.toHaveBeenCalled();
84
+ setTimeoutSpy.mockRestore();
85
+ clearTimeoutSpy.mockRestore();
86
+ jest.useRealTimers();
87
+ });
88
+ });
@@ -0,0 +1,3 @@
1
+ export * from "./device-pairing";
2
+ export * from "./device-pairing-invite";
3
+ export * from "./use-device-pairing-state";
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./device-pairing"), exports);
18
+ __exportStar(require("./device-pairing-invite"), exports);
19
+ __exportStar(require("./use-device-pairing-state"), exports);
@@ -0,0 +1,3 @@
1
+ import { type DevicePairingState } from "@peers-app/peers-sdk";
2
+ /** Subscribes to renderer-safe host pairing state and loads its current snapshot. */
3
+ export declare function useDevicePairingState(): DevicePairingState;
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useDevicePairingState = useDevicePairingState;
4
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
5
+ const react_1 = require("react");
6
+ const IDLE_PAIRING_STATE = { phase: "idle" };
7
+ /** Subscribes to renderer-safe host pairing state and loads its current snapshot. */
8
+ function useDevicePairingState() {
9
+ const [state, setState] = (0, react_1.useState)(IDLE_PAIRING_STATE);
10
+ (0, react_1.useEffect)(() => {
11
+ let active = true;
12
+ const subscription = (0, peers_sdk_1.subscribe)(peers_sdk_1.DEVICE_PAIRING_STATE_EVENT, (event) => {
13
+ if (active) {
14
+ setState(peers_sdk_1.devicePairingStateSchema.parse(event.data));
15
+ }
16
+ });
17
+ void peers_sdk_1.rpcServerCalls
18
+ .devicePairingGetState()
19
+ .then((value) => {
20
+ if (active) {
21
+ setState(peers_sdk_1.devicePairingStateSchema.parse(value));
22
+ }
23
+ })
24
+ .catch(() => undefined);
25
+ return () => {
26
+ active = false;
27
+ subscription.unsubscribe();
28
+ };
29
+ }, []);
30
+ return state;
31
+ }
@@ -29,5 +29,26 @@ export declare function isGroupAdmin(): Promise<boolean>;
29
29
  * @param pv - The package version to activate.
30
30
  */
31
31
  export declare function activatePackageVersion(packageId: string, pv: IPackageVersion): Promise<void>;
32
+ /**
33
+ * After promoting a version that is currently effective-active on this device,
34
+ * activate it when the new tag matches the group's auto-activate / follow rules.
35
+ *
36
+ * This closes the gotcha where a device is already running a `dev` build, promotes
37
+ * it to `beta`/`stable`, and expects the group active version to advance without a
38
+ * separate Activate click — but only when the group would accept that channel
39
+ * (e.g. default follow is `stable`; `beta` only when the group follows beta).
40
+ *
41
+ * @param packageId - Package that was promoted
42
+ * @param promotedPv - PV after the tag change (must include the new `versionTag`)
43
+ * @param opts.wasEffectiveActive - True when this PV was this device's effective active before promote
44
+ * @param opts.groupFollowTags - Group `IPackage.followVersionTags` (not device override)
45
+ * @param opts.groupPinned - True when group `versionFollowRange` is `pinned`
46
+ * @returns Whether activation was attempted
47
+ */
48
+ export declare function maybeActivateAfterPromote(packageId: string, promotedPv: IPackageVersion, opts: {
49
+ wasEffectiveActive: boolean;
50
+ groupFollowTags: string | undefined;
51
+ groupPinned: boolean;
52
+ }): Promise<boolean>;
32
53
  /** Numeric rank for sorting packages by update urgency. */
33
54
  export declare const updateLevelRank: Record<string, number>;
@@ -4,6 +4,7 @@ exports.updateLevelRank = void 0;
4
4
  exports.checkVersionStatus = checkVersionStatus;
5
5
  exports.isGroupAdmin = isGroupAdmin;
6
6
  exports.activatePackageVersion = activatePackageVersion;
7
+ exports.maybeActivateAfterPromote = maybeActivateAfterPromote;
7
8
  const peers_sdk_1 = require("@peers-app/peers-sdk");
8
9
  /**
9
10
  * Check a package's version status: what's currently active, whether a newer
@@ -106,5 +107,36 @@ async function activatePackageVersion(packageId, pv) {
106
107
  await (0, peers_sdk_1.updatePackagePrefs)(packageId, { activePackageVersionId: pv.packageVersionId });
107
108
  }
108
109
  }
110
+ /**
111
+ * After promoting a version that is currently effective-active on this device,
112
+ * activate it when the new tag matches the group's auto-activate / follow rules.
113
+ *
114
+ * This closes the gotcha where a device is already running a `dev` build, promotes
115
+ * it to `beta`/`stable`, and expects the group active version to advance without a
116
+ * separate Activate click — but only when the group would accept that channel
117
+ * (e.g. default follow is `stable`; `beta` only when the group follows beta).
118
+ *
119
+ * @param packageId - Package that was promoted
120
+ * @param promotedPv - PV after the tag change (must include the new `versionTag`)
121
+ * @param opts.wasEffectiveActive - True when this PV was this device's effective active before promote
122
+ * @param opts.groupFollowTags - Group `IPackage.followVersionTags` (not device override)
123
+ * @param opts.groupPinned - True when group `versionFollowRange` is `pinned`
124
+ * @returns Whether activation was attempted
125
+ */
126
+ async function maybeActivateAfterPromote(packageId, promotedPv, opts) {
127
+ if (!opts.wasEffectiveActive)
128
+ return false;
129
+ if (opts.groupPinned)
130
+ return false;
131
+ if (!promotedPv.versionTag || promotedPv.versionTag === "dev")
132
+ return false;
133
+ // Docs default: groups follow `stable` when followVersionTags is unset.
134
+ const followTags = opts.groupFollowTags ?? "stable";
135
+ if (!(0, peers_sdk_1.doesTagMatch)(undefined, promotedPv.versionTag, followTags, undefined)) {
136
+ return false;
137
+ }
138
+ await activatePackageVersion(packageId, promotedPv);
139
+ return true;
140
+ }
109
141
  /** Numeric rank for sorting packages by update urgency. */
110
142
  exports.updateLevelRank = { major: 3, minor: 2, patch: 1 };
@@ -1,4 +1,5 @@
1
1
  import { type IDoc, type IPackage } from "@peers-app/peers-sdk";
2
+ /** Lists package versions with activate, promote, pin, edit-semver, and delete actions. */
2
3
  export declare const PackageVersionsList: (props: {
3
4
  pkg: IDoc<IPackage>;
4
5
  }) => import("react/jsx-runtime").JSX.Element;