@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
@@ -39,6 +39,7 @@ function tagBadgeClass(tag) {
39
39
  return "text-bg-success";
40
40
  return "text-bg-secondary";
41
41
  }
42
+ /** Lists package versions with activate, promote, pin, edit-semver, and delete actions. */
42
43
  const PackageVersionsList = (props) => {
43
44
  const { pkg } = props;
44
45
  const refreshKey = (0, hooks_1.useObservableState)(0);
@@ -115,24 +116,41 @@ const PackageVersionsList = (props) => {
115
116
  signature: "",
116
117
  });
117
118
  const pvHash = (0, peers_sdk_1.computePackageVersionHash)(pv.version, newTag, pv.packageBundleFileHash, pv.routesBundleFileHash, pv.uiBundleFileHash);
119
+ // Auto-sign with package author key if available (server/PWA host — key never
120
+ // exposed to UI). Soft-fail when the key is missing so group admins can still
121
+ // promote, but never keep a signature that was computed for the old tag.
122
+ const authorSig = await peers_sdk_1.rpcServerCalls.signPackageAuthorVersion({
123
+ packageId: pv.packageId,
124
+ packageVersionId: pv.packageVersionId,
125
+ version: pv.version,
126
+ versionTag: newTag,
127
+ packageBundleFileHash: pv.packageBundleFileHash,
128
+ routesBundleFileHash: pv.routesBundleFileHash,
129
+ uiBundleFileHash: pv.uiBundleFileHash,
130
+ appNavs: pv.appNavs,
131
+ createdBy: pv.createdBy,
132
+ createdAt: pv.createdAt,
133
+ });
118
134
  const updated = {
119
135
  ...pv,
120
136
  versionTag: newTag,
121
137
  packageVersionHash: pvHash,
122
138
  history,
139
+ packageAuthorSignature: authorSig,
123
140
  };
124
- // Auto-sign with package author key if available (server-side, key never exposed)
125
- const authorSig = await peers_sdk_1.rpcServerCalls.signPackageAuthorVersion({
126
- packageId: updated.packageId,
127
- packageVersionId: updated.packageVersionId,
128
- version: updated.version,
129
- versionTag: newTag,
130
- packageBundleFileHash: updated.packageBundleFileHash,
131
- routesBundleFileHash: updated.routesBundleFileHash,
132
- uiBundleFileHash: updated.uiBundleFileHash,
133
- });
134
- updated.packageAuthorSignature = authorSig;
135
141
  await (0, peers_sdk_1.PackageVersions)().signAndSave(updated, { saveAsSnapshot: true });
142
+ // Promoting the version this device is already running should advance the
143
+ // group active version when the new tag matches group follow policy
144
+ // (e.g. promote active → stable with default follow settings).
145
+ const pkgJs = pkg.toJS();
146
+ const activated = await (0, package_helpers_1.maybeActivateAfterPromote)(pkg.packageId, updated, {
147
+ wasEffectiveActive: pv.packageVersionId === effectiveActiveVersionId,
148
+ groupFollowTags: pkgJs.followVersionTags,
149
+ groupPinned: pkgJs.versionFollowRange === "pinned",
150
+ });
151
+ if (activated) {
152
+ await pkg.load();
153
+ }
136
154
  refreshKey(refreshKey() + 1);
137
155
  }
138
156
  catch (err) {
@@ -142,7 +160,25 @@ const PackageVersionsList = (props) => {
142
160
  async function updateVersion(pv, newSemver) {
143
161
  try {
144
162
  const pvHash = (0, peers_sdk_1.computePackageVersionHash)(newSemver, pv.versionTag || "dev", pv.packageBundleFileHash, pv.routesBundleFileHash, pv.uiBundleFileHash);
145
- const updated = { ...pv, version: newSemver, packageVersionHash: pvHash };
163
+ // Semver is part of the author-signed payload — re-sign or clear the old signature.
164
+ const authorSig = await peers_sdk_1.rpcServerCalls.signPackageAuthorVersion({
165
+ packageId: pv.packageId,
166
+ packageVersionId: pv.packageVersionId,
167
+ version: newSemver,
168
+ versionTag: pv.versionTag,
169
+ packageBundleFileHash: pv.packageBundleFileHash,
170
+ routesBundleFileHash: pv.routesBundleFileHash,
171
+ uiBundleFileHash: pv.uiBundleFileHash,
172
+ appNavs: pv.appNavs,
173
+ createdBy: pv.createdBy,
174
+ createdAt: pv.createdAt,
175
+ });
176
+ const updated = {
177
+ ...pv,
178
+ version: newSemver,
179
+ packageVersionHash: pvHash,
180
+ packageAuthorSignature: authorSig,
181
+ };
146
182
  await (0, peers_sdk_1.PackageVersions)().signAndSave(updated, { saveAsSnapshot: true });
147
183
  refreshKey(refreshKey() + 1);
148
184
  }
@@ -43,6 +43,7 @@ const tooltip_1 = require("../../components/tooltip");
43
43
  const globals = __importStar(require("../../globals"));
44
44
  const hooks_1 = require("../../hooks");
45
45
  const layout_picker_1 = require("../../root-layout/layout-picker");
46
+ const device_pairing_1 = require("../device-pairing");
46
47
  const color_mode_dropdown_1 = require("./color-mode-dropdown");
47
48
  const voice_settings_1 = require("./voice-settings");
48
49
  function getAppVersion() {
@@ -72,7 +73,7 @@ const SettingsPage = () => {
72
73
  };
73
74
  exports.SettingsPage = SettingsPage;
74
75
  const UserSettingsTab = () => {
75
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(ProfileSection, {}), (0, jsx_runtime_1.jsx)(LogoutSection, {})] }));
76
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(ProfileSection, {}), (0, jsx_runtime_1.jsx)(AddDeviceSection, {}), (0, jsx_runtime_1.jsx)(LogoutSection, {})] }));
76
77
  };
77
78
  const AppearanceSettingsTab = () => {
78
79
  return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h5", { className: "mb-3", children: "Appearance" }), (0, jsx_runtime_1.jsx)("div", { className: "mb-3", children: (0, jsx_runtime_1.jsx)(color_mode_dropdown_1.ColorModeDropdown, {}) }), (0, jsx_runtime_1.jsx)("div", { className: "mb-3", children: (0, jsx_runtime_1.jsx)(layout_picker_1.LayoutPicker, {}) })] }));
@@ -148,6 +149,10 @@ const ProfileSection = () => {
148
149
  const hasChanges = !!me.q() || deviceName() !== savedDeviceName();
149
150
  return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center mb-3", children: [(0, jsx_runtime_1.jsx)("h5", { className: "mb-0 me-auto", children: "Settings" }), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-primary btn-sm", onClick: handleSave, disabled: !hasChanges, children: "Save" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsx)("small", { className: "form-label", children: "Display Name:" }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: me.qs.name, className: "form-control", maxLength: peers_sdk_1.DISPLAY_NAME_MAX_LENGTH, placeholder: "Enter your name" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { className: "form-label", children: ["Device Name:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "Give this device a friendly name to identify it in your network." })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: deviceName, className: "form-control", maxLength: peers_sdk_1.DISPLAY_NAME_MAX_LENGTH, placeholder: "e.g., My Laptop" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { className: "form-label", children: ["User ID:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "This uniquely identifies you to all other users." })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: me.userId, className: "form-control", disabled: true })] }), (0, jsx_runtime_1.jsxs)("div", { className: "row", children: [(0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { className: "form-label", children: ["Public Key:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "This is your public key that other users will use to verify your signatures." })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: me.publicKey, className: "form-control form-control-sm", disabled: true })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { className: "form-label", children: ["Public Box Key:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "This is the public key that other users can use to encrypt data so that only you can open it." })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: me.publicBoxKey, className: "form-control form-control-sm", disabled: true })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { className: "form-label", children: ["Device ID:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "This uniquely identifies this device on Peers networks." })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: deviceId || "", className: "form-control form-control-sm", disabled: true })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsx)("small", { className: "form-label", children: "App Version:" }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: getAppVersion(), className: "form-control form-control-sm", disabled: true })] })] }));
150
151
  };
152
+ const AddDeviceSection = () => {
153
+ const [open, setOpen] = (0, react_1.useState)(false);
154
+ return ((0, jsx_runtime_1.jsxs)("section", { className: "mt-4 pt-3 border-top", children: [(0, jsx_runtime_1.jsx)("h6", { children: "Add another device" }), (0, jsx_runtime_1.jsx)("p", { className: "small text-muted", children: "Create a five-minute invitation, compare the same number on both devices, and approve on each one before account access is transferred." }), open ? ((0, jsx_runtime_1.jsx)(device_pairing_1.SourceDevicePairing, { onClose: () => setOpen(false) })) : ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-primary", onClick: () => setOpen(true), children: "Add another device" }))] }));
155
+ };
151
156
  const PackagesRootDirectory = () => {
152
157
  return ((0, jsx_runtime_1.jsxs)("div", { className: "mt-3", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Packages Directory:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: `Changing this will not effect existing packages.` })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: peers_sdk_1.packagesRootDir, className: "form-control" })] }));
153
158
  };
@@ -5,10 +5,13 @@ const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  const peers_sdk_1 = require("@peers-app/peers-sdk");
6
6
  const react_1 = require("react");
7
7
  const hooks_1 = require("../hooks");
8
+ const device_pairing_1 = require("./device-pairing");
8
9
  const color_mode_dropdown_1 = require("./settings/color-mode-dropdown");
9
10
  const SetupUser = () => {
10
11
  const [_colorMode] = (0, hooks_1.useObservable)(color_mode_dropdown_1.colorMode);
11
12
  const [step, _setStep] = (0, react_1.useState)("select-user-type");
13
+ const [pairingInvitation, setPairingInvitation] = (0, react_1.useState)(() => (0, device_pairing_1.consumePendingDevicePairingInvite)());
14
+ const [showPairing, setShowPairing] = (0, react_1.useState)(() => Boolean(pairingInvitation));
12
15
  const [isExistingUser, setIsExistingUser] = (0, react_1.useState)(null);
13
16
  const [userId, setUserId] = (0, react_1.useState)("");
14
17
  const [secretKey, setSecretKey] = (0, react_1.useState)("");
@@ -16,6 +19,16 @@ const SetupUser = () => {
16
19
  const [isValidating, setIsValidating] = (0, react_1.useState)(false);
17
20
  document.getElementById("appLoadingDiv")?.remove();
18
21
  const isDark = _colorMode === "dark";
22
+ (0, react_1.useEffect)(() => {
23
+ const capture = (event) => {
24
+ const invitation = event.detail;
25
+ setPairingInvitation(invitation);
26
+ (0, device_pairing_1.consumePendingDevicePairingInvite)();
27
+ setShowPairing(true);
28
+ };
29
+ window.addEventListener(device_pairing_1.DEVICE_PAIRING_INVITE_CAPTURED_EVENT, capture);
30
+ return () => window.removeEventListener(device_pairing_1.DEVICE_PAIRING_INVITE_CAPTURED_EVENT, capture);
31
+ }, []);
19
32
  const handleNewUser = async () => {
20
33
  setError("");
21
34
  setIsValidating(true);
@@ -65,13 +78,23 @@ const SetupUser = () => {
65
78
  setIsValidating(false);
66
79
  }
67
80
  };
81
+ if (showPairing) {
82
+ return ((0, jsx_runtime_1.jsx)("div", { className: "container-fluid d-flex align-items-start justify-content-center", style: { paddingTop: "20px", backgroundColor: isDark ? "#212529" : "#f8f9fa" }, children: (0, jsx_runtime_1.jsx)("div", { className: "card shadow", style: {
83
+ maxWidth: "560px",
84
+ width: "100%",
85
+ backgroundColor: isDark ? "#2d3238" : "#ffffff",
86
+ }, children: (0, jsx_runtime_1.jsx)("div", { className: "card-body p-4 p-md-5", children: (0, jsx_runtime_1.jsx)(device_pairing_1.DestinationDevicePairing, { initialInvitation: pairingInvitation, onInvitationConsumed: () => setPairingInvitation(undefined), onBack: () => {
87
+ setPairingInvitation(undefined);
88
+ setShowPairing(false);
89
+ } }) }) }) }));
90
+ }
68
91
  // Step 1: Select User Type
69
92
  if (step === "select-user-type" && isExistingUser === null) {
70
93
  return ((0, jsx_runtime_1.jsx)("div", { className: "container-fluid d-flex align-items-start justify-content-center", style: { paddingTop: "20px", backgroundColor: isDark ? "#212529" : "#f8f9fa" }, children: (0, jsx_runtime_1.jsx)("div", { className: "card shadow", style: {
71
94
  maxWidth: "500px",
72
95
  width: "100%",
73
96
  backgroundColor: isDark ? "#2d3238" : "#ffffff",
74
- }, children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body p-4 p-md-5", children: [(0, jsx_runtime_1.jsxs)("div", { className: "text-center mb-4", children: [(0, jsx_runtime_1.jsx)("h2", { className: `fw-bold mb-2 ${isDark ? "text-light" : ""}`, children: "Welcome to Peers" }), (0, jsx_runtime_1.jsx)("p", { className: isDark ? "text-light opacity-75" : "text-muted", children: "Let's get you set up" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-grid gap-3", children: [(0, jsx_runtime_1.jsx)("button", { className: "btn btn-lg btn-primary py-3", onClick: () => setIsExistingUser(false), children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center justify-content-center", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-plus fs-4 me-2" }), (0, jsx_runtime_1.jsxs)("div", { className: "text-start", children: [(0, jsx_runtime_1.jsx)("div", { className: "fw-semibold", children: "I'm a New User" }), (0, jsx_runtime_1.jsx)("small", { className: "opacity-75", children: "Create a new account" })] })] }) }), (0, jsx_runtime_1.jsx)("button", { className: `btn btn-lg py-3 ${isDark ? "btn-outline-light" : "btn-outline-primary"}`, onClick: () => setIsExistingUser(true), children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center justify-content-center", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-box-arrow-in-right fs-4 me-2" }), (0, jsx_runtime_1.jsxs)("div", { className: "text-start", children: [(0, jsx_runtime_1.jsx)("div", { className: "fw-semibold", children: "I'm an Existing User" }), (0, jsx_runtime_1.jsx)("small", { className: "opacity-75", children: "Sign in with your credentials" })] })] }) })] })] }) }) }));
97
+ }, children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body p-4 p-md-5", children: [(0, jsx_runtime_1.jsxs)("div", { className: "text-center mb-4", children: [(0, jsx_runtime_1.jsx)("h2", { className: `fw-bold mb-2 ${isDark ? "text-light" : ""}`, children: "Welcome to Peers" }), (0, jsx_runtime_1.jsx)("p", { className: isDark ? "text-light opacity-75" : "text-muted", children: "Let's get you set up" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-grid gap-3", children: [(0, jsx_runtime_1.jsx)("button", { className: "btn btn-lg btn-primary py-3", onClick: () => setIsExistingUser(false), children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center justify-content-center", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-plus fs-4 me-2" }), (0, jsx_runtime_1.jsxs)("div", { className: "text-start", children: [(0, jsx_runtime_1.jsx)("div", { className: "fw-semibold", children: "I'm a New User" }), (0, jsx_runtime_1.jsx)("small", { className: "opacity-75", children: "Create a new account" })] })] }) }), (0, jsx_runtime_1.jsx)("button", { className: `btn btn-lg py-3 ${isDark ? "btn-outline-light" : "btn-outline-primary"}`, onClick: () => setIsExistingUser(true), children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center justify-content-center", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-box-arrow-in-right fs-4 me-2" }), (0, jsx_runtime_1.jsxs)("div", { className: "text-start", children: [(0, jsx_runtime_1.jsx)("div", { className: "fw-semibold", children: "I'm an Existing User" }), (0, jsx_runtime_1.jsx)("small", { className: "opacity-75", children: "Sign in with your credentials" })] })] }) }), (0, jsx_runtime_1.jsx)("button", { className: `btn btn-lg py-3 ${isDark ? "btn-outline-light" : "btn-outline-primary"}`, onClick: () => setShowPairing(true), children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center justify-content-center", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-phone fs-4 me-2" }), (0, jsx_runtime_1.jsxs)("div", { className: "text-start", children: [(0, jsx_runtime_1.jsx)("div", { className: "fw-semibold", children: "Use Another Device" }), (0, jsx_runtime_1.jsx)("small", { className: "opacity-75", children: "Sign in with a device already on Peers" })] })] }) })] })] }) }) }));
75
98
  }
76
99
  // Step 2: New User Confirmation
77
100
  if (isExistingUser === false) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.20.6",
3
+ "version": "0.21.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -28,7 +28,7 @@
28
28
  "lint:fix": "biome check --write ."
29
29
  },
30
30
  "peerDependencies": {
31
- "@peers-app/peers-sdk": "^0.20.6",
31
+ "@peers-app/peers-sdk": "^0.21.1",
32
32
  "bootstrap": "^5.3.3",
33
33
  "react": "^18.0.0",
34
34
  "react-dom": "^18.0.0"
@@ -39,7 +39,7 @@
39
39
  "@babel/preset-react": "^7.24.1",
40
40
  "@babel/preset-typescript": "^7.27.1",
41
41
  "@biomejs/biome": "2.3.11",
42
- "@peers-app/peers-sdk": "0.20.6",
42
+ "@peers-app/peers-sdk": "0.21.1",
43
43
  "@testing-library/dom": "^10.4.0",
44
44
  "@testing-library/jest-dom": "^6.6.3",
45
45
  "@testing-library/react": "^16.3.0",
@@ -25,6 +25,7 @@ import {
25
25
  captureUserConnectInviteFromLocation,
26
26
  readUserConnectPendingIntent,
27
27
  } from "../screens/contacts/user-connect-invite";
28
+ import { captureDevicePairingInviteFromLocation } from "../screens/device-pairing";
28
29
  import { colorMode } from "../screens/settings/color-mode-dropdown";
29
30
  import { SetupUser } from "../screens/setup-user";
30
31
  import { WelcomeModal } from "../screens/welcome-modal";
@@ -41,6 +42,7 @@ import { selectedRootLayoutId } from "./root-layout-state";
41
42
  */
42
43
  export function RootLayoutHost() {
43
44
  useState(() => {
45
+ captureDevicePairingInviteFromLocation();
44
46
  captureUserConnectInviteFromLocation();
45
47
  return true;
46
48
  });
@@ -55,6 +57,7 @@ export function RootLayoutHost() {
55
57
  useEffect(() => {
56
58
  const captureInviteFromHashChange = () => {
57
59
  const previousHash = window.location.hash;
60
+ captureDevicePairingInviteFromLocation();
58
61
  captureUserConnectInviteFromLocation();
59
62
  if (window.location.hash === previousHash) {
60
63
  return;
@@ -0,0 +1,50 @@
1
+ import {
2
+ createDevicePairingInvitation,
3
+ createDevicePairingInvitationUrl,
4
+ parseDevicePairingInvitation,
5
+ } from "@peers-app/peers-sdk";
6
+ import {
7
+ captureDevicePairingInviteFromLocation,
8
+ clearPendingDevicePairingInvite,
9
+ consumePendingDevicePairingInvite,
10
+ readPendingDevicePairingInvite,
11
+ } from "./device-pairing-invite";
12
+
13
+ describe("device pairing invitation capture", () => {
14
+ beforeEach(() => {
15
+ clearPendingDevicePairingInvite();
16
+ window.history.replaceState({}, "", "/?app");
17
+ });
18
+
19
+ it("captures the fragment only in memory and removes it from browser history", () => {
20
+ const invitation = createDevicePairingInvitation({
21
+ serviceOrigin: "https://peers.app",
22
+ });
23
+ const inviteUrl = createDevicePairingInvitationUrl(
24
+ `${window.location.origin}/?app`,
25
+ invitation,
26
+ );
27
+ window.history.replaceState(
28
+ {},
29
+ "",
30
+ new URL(inviteUrl).pathname + new URL(inviteUrl).search + new URL(inviteUrl).hash,
31
+ );
32
+
33
+ const token = captureDevicePairingInviteFromLocation();
34
+
35
+ expect(token).toBeDefined();
36
+ expect(parseDevicePairingInvitation(token ?? "")).toEqual(invitation);
37
+ expect(window.location.hash).toBe("");
38
+ expect(readPendingDevicePairingInvite()).toBe(token);
39
+ expect(consumePendingDevicePairingInvite()).toBe(token);
40
+ expect(readPendingDevicePairingInvite()).toBeUndefined();
41
+ });
42
+
43
+ it("ignores invalid fragments without retaining them", () => {
44
+ window.history.replaceState({}, "", "/?app#pair=invalid");
45
+
46
+ expect(captureDevicePairingInviteFromLocation()).toBeUndefined();
47
+ expect(readPendingDevicePairingInvite()).toBeUndefined();
48
+ expect(window.location.hash).toBe("");
49
+ });
50
+ });
@@ -0,0 +1,55 @@
1
+ import { encodeDevicePairingInvitation, parseDevicePairingInvitation } from "@peers-app/peers-sdk";
2
+
3
+ let pendingInvitation: string | undefined;
4
+
5
+ /** Browser event emitted after a pairing invitation is captured and removed from the URL. */
6
+ export const DEVICE_PAIRING_INVITE_CAPTURED_EVENT = "device-pairing:invite-captured";
7
+
8
+ /** Captures a pairing fragment in memory and immediately removes it from browser history. */
9
+ export function captureDevicePairingInviteFromLocation(
10
+ location: Location = window.location,
11
+ ): string | undefined {
12
+ if (!location.hash.startsWith("#pair=") && !location.hash.startsWith("#pair?")) {
13
+ return undefined;
14
+ }
15
+ let capturedInvitation: string | undefined;
16
+ try {
17
+ capturedInvitation = encodeDevicePairingInvitation(parseDevicePairingInvitation(location.href));
18
+ pendingInvitation = capturedInvitation;
19
+ return capturedInvitation;
20
+ } catch {
21
+ return undefined;
22
+ } finally {
23
+ if (typeof window !== "undefined" && location === window.location) {
24
+ window.history.replaceState(
25
+ window.history.state,
26
+ document.title,
27
+ `${location.pathname}${location.search}`,
28
+ );
29
+ if (capturedInvitation) {
30
+ window.dispatchEvent(
31
+ new CustomEvent(DEVICE_PAIRING_INVITE_CAPTURED_EVENT, {
32
+ detail: capturedInvitation,
33
+ }),
34
+ );
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ /** Returns the in-memory invitation without consuming it. */
41
+ export function readPendingDevicePairingInvite(): string | undefined {
42
+ return pendingInvitation;
43
+ }
44
+
45
+ /** Returns and erases the in-memory invitation. */
46
+ export function consumePendingDevicePairingInvite(): string | undefined {
47
+ const invitation = pendingInvitation;
48
+ pendingInvitation = undefined;
49
+ return invitation;
50
+ }
51
+
52
+ /** Erases any unconsumed invitation. */
53
+ export function clearPendingDevicePairingInvite(): void {
54
+ pendingInvitation = undefined;
55
+ }
@@ -0,0 +1,100 @@
1
+ import { type DevicePairingState, rpcServerCalls } from "@peers-app/peers-sdk";
2
+ import { fireEvent, render, waitFor } from "@testing-library/react";
3
+ import { DestinationDevicePairing, SourceDevicePairing } from "./device-pairing";
4
+
5
+ const mockRpc = {
6
+ startSource: jest.fn(),
7
+ startDestination: jest.fn(),
8
+ approve: jest.fn(),
9
+ reject: jest.fn(),
10
+ cancel: jest.fn(),
11
+ };
12
+ let mockPairingState: DevicePairingState = { phase: "idle" };
13
+
14
+ rpcServerCalls.devicePairingStartSource =
15
+ mockRpc.startSource as typeof rpcServerCalls.devicePairingStartSource;
16
+ rpcServerCalls.devicePairingStartDestination =
17
+ mockRpc.startDestination as typeof rpcServerCalls.devicePairingStartDestination;
18
+ rpcServerCalls.devicePairingApprove = mockRpc.approve as typeof rpcServerCalls.devicePairingApprove;
19
+ rpcServerCalls.devicePairingReject = mockRpc.reject as typeof rpcServerCalls.devicePairingReject;
20
+ rpcServerCalls.devicePairingCancel = mockRpc.cancel as typeof rpcServerCalls.devicePairingCancel;
21
+
22
+ jest.mock("./use-device-pairing-state", () => ({
23
+ useDevicePairingState: () => mockPairingState,
24
+ }));
25
+
26
+ describe("device pairing UI lifecycle", () => {
27
+ beforeEach(() => {
28
+ jest.clearAllMocks();
29
+ mockPairingState = { phase: "idle" };
30
+ mockRpc.cancel.mockResolvedValue({ phase: "idle" });
31
+ });
32
+
33
+ it("uses the host-owned invitation URL and cancels a source that resolves after unmount", async () => {
34
+ let resolveStart!: (value: { state: DevicePairingState; invitationUrl: string }) => void;
35
+ mockRpc.startSource.mockReturnValue(
36
+ new Promise((resolve) => {
37
+ resolveStart = resolve;
38
+ }),
39
+ );
40
+ const view = render(<SourceDevicePairing onClose={jest.fn()} />);
41
+
42
+ expect(mockRpc.startSource).toHaveBeenCalledWith();
43
+ view.unmount();
44
+ resolveStart({
45
+ state: { sessionId: "00mh0wlipkdbeaw8tests0001", role: "source", phase: "inviting" },
46
+ invitationUrl: "https://peers.app/?app#pair=token",
47
+ });
48
+
49
+ await waitFor(() => {
50
+ expect(mockRpc.cancel).toHaveBeenCalledWith({
51
+ sessionId: "00mh0wlipkdbeaw8tests0001",
52
+ });
53
+ });
54
+ });
55
+
56
+ it("keeps source cancellation available while waiting for remote approval", async () => {
57
+ const onClose = jest.fn();
58
+ mockPairingState = {
59
+ sessionId: "00mh0wlipkdbeaw8tests0002",
60
+ role: "source",
61
+ phase: "waiting-for-approval",
62
+ };
63
+ mockRpc.startSource.mockResolvedValue({
64
+ state: mockPairingState,
65
+ invitationUrl: "https://peers.app/?app#pair=token",
66
+ });
67
+ const view = render(<SourceDevicePairing onClose={onClose} />);
68
+ await waitFor(() => expect(mockRpc.startSource).toHaveBeenCalled());
69
+
70
+ fireEvent.click(view.getByRole("button", { name: "Cancel" }));
71
+
72
+ await waitFor(() => {
73
+ expect(mockRpc.cancel).toHaveBeenCalledWith({
74
+ sessionId: "00mh0wlipkdbeaw8tests0002",
75
+ });
76
+ expect(onClose).toHaveBeenCalled();
77
+ });
78
+ });
79
+
80
+ it("does not clear the destination success reload when Done unmounts the flow", () => {
81
+ jest.useFakeTimers();
82
+ mockPairingState = {
83
+ sessionId: "00mh0wlipkdbeaw8tests0003",
84
+ role: "destination",
85
+ phase: "complete",
86
+ };
87
+ const setTimeoutSpy = jest.spyOn(window, "setTimeout");
88
+ const clearTimeoutSpy = jest.spyOn(window, "clearTimeout");
89
+ const view = render(<DestinationDevicePairing onBack={jest.fn()} />);
90
+ const reloadTimer = setTimeoutSpy.mock.calls.find(([, delay]) => delay === 750);
91
+
92
+ expect(reloadTimer).toBeDefined();
93
+ view.unmount();
94
+ expect(clearTimeoutSpy).not.toHaveBeenCalled();
95
+
96
+ setTimeoutSpy.mockRestore();
97
+ clearTimeoutSpy.mockRestore();
98
+ jest.useRealTimers();
99
+ });
100
+ });