@peers-app/peers-ui 0.26.3 → 0.26.5

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 (64) hide show
  1. package/dist/components/group-switcher.d.ts +6 -0
  2. package/dist/components/group-switcher.js +8 -1
  3. package/dist/components/markdown-with-mentions.d.ts +4 -0
  4. package/dist/components/markdown-with-mentions.js +6 -1
  5. package/dist/components/save-button.d.ts +15 -0
  6. package/dist/components/save-button.js +14 -3
  7. package/dist/globals.js +34 -8
  8. package/dist/host-shell/host-shell-consumer.d.ts +21 -0
  9. package/dist/host-shell/host-shell-consumer.js +50 -0
  10. package/dist/host-shell/host-shell-consumer.test.d.ts +1 -0
  11. package/dist/host-shell/host-shell-consumer.test.js +28 -0
  12. package/dist/mention-configs.js +2 -1
  13. package/dist/screens/groups/group-list.js +4 -1
  14. package/dist/screens/packages/package-details.d.ts +1 -0
  15. package/dist/screens/packages/package-details.js +27 -8
  16. package/dist/screens/packages/package-helpers.d.ts +3 -23
  17. package/dist/screens/packages/package-helpers.js +12 -46
  18. package/dist/screens/packages/package-helpers.test.d.ts +1 -0
  19. package/dist/screens/packages/package-helpers.test.js +53 -0
  20. package/dist/screens/packages/package-info.js +3 -2
  21. package/dist/screens/packages/package-list.d.ts +4 -2
  22. package/dist/screens/packages/package-list.js +50 -10
  23. package/dist/screens/packages/package-new-local.js +7 -4
  24. package/dist/screens/packages/package-runtime-status.d.ts +12 -0
  25. package/dist/screens/packages/package-runtime-status.js +33 -0
  26. package/dist/screens/packages/package-runtime-status.test.d.ts +1 -0
  27. package/dist/screens/packages/package-runtime-status.test.js +50 -0
  28. package/dist/screens/packages/package-versions.js +8 -55
  29. package/dist/screens/packages/packages-ipc.d.ts +41 -0
  30. package/dist/screens/packages/packages-ipc.js +102 -0
  31. package/dist/screens/packages/packages-ipc.test.d.ts +1 -0
  32. package/dist/screens/packages/packages-ipc.test.js +43 -0
  33. package/dist/screens/tools/tool-catalog-ipc.test.js +6 -0
  34. package/dist/screens/variables/variable-details.d.ts +1 -0
  35. package/dist/screens/variables/variable-details.js +11 -5
  36. package/dist/tabs-layout/network-status-indicator.d.ts +4 -1
  37. package/dist/tabs-layout/network-status-indicator.js +12 -6
  38. package/dist/tabs-layout/network-status-indicator.test.js +1 -1
  39. package/dist/ui-router/ui-loader.js +10 -37
  40. package/package.json +3 -3
  41. package/src/components/group-switcher.tsx +12 -4
  42. package/src/components/markdown-with-mentions.tsx +7 -2
  43. package/src/components/save-button.tsx +24 -3
  44. package/src/globals.tsx +32 -11
  45. package/src/host-shell/host-shell-consumer.test.ts +32 -0
  46. package/src/host-shell/host-shell-consumer.ts +53 -0
  47. package/src/mention-configs.ts +2 -2
  48. package/src/screens/groups/group-list.tsx +6 -4
  49. package/src/screens/packages/package-details.tsx +31 -8
  50. package/src/screens/packages/package-helpers.test.ts +57 -0
  51. package/src/screens/packages/package-helpers.ts +13 -55
  52. package/src/screens/packages/package-info.tsx +3 -3
  53. package/src/screens/packages/package-list.tsx +51 -16
  54. package/src/screens/packages/package-new-local.tsx +8 -13
  55. package/src/screens/packages/package-runtime-status.test.tsx +58 -0
  56. package/src/screens/packages/package-runtime-status.tsx +68 -0
  57. package/src/screens/packages/package-versions.tsx +9 -72
  58. package/src/screens/packages/packages-ipc.test.ts +47 -0
  59. package/src/screens/packages/packages-ipc.ts +125 -0
  60. package/src/screens/tools/tool-catalog-ipc.test.ts +7 -1
  61. package/src/screens/variables/variable-details.tsx +11 -6
  62. package/src/tabs-layout/network-status-indicator.test.tsx +1 -1
  63. package/src/tabs-layout/network-status-indicator.tsx +24 -11
  64. package/src/ui-router/ui-loader.tsx +12 -51
@@ -11,7 +11,9 @@ const editor_inline_1 = require("../../components/markdown-editor/editor-inline"
11
11
  const save_button_1 = require("../../components/save-button");
12
12
  const tabs_1 = require("../../components/tabs");
13
13
  const hooks_1 = require("../../hooks");
14
+ const host_shell_consumer_1 = require("../../host-shell/host-shell-consumer");
14
15
  const tabs_state_1 = require("../../tabs-layout/tabs-state");
16
+ /** Details screen for one persistent variable: name, value (copyable), and settings. */
15
17
  const VariableDetails = (props) => {
16
18
  const persistentVariable = (0, hooks_1.usePromise)(async () => {
17
19
  const persistentVariable = await (0, peers_sdk_1.PersistentVars)().get(props.persistentVarId);
@@ -87,9 +89,9 @@ const VariableValue = (props) => {
87
89
  };
88
90
  /**
89
91
  * Button that copies a persistent variable's value to the clipboard. Decryption (for
90
- * secret values) and the clipboard write happen entirely on the server/main process via
91
- * {@link rpcServerCalls.copyPvarValueToClipboard}, so the plaintext is never exposed to
92
- * the UI. Shows transient success/error feedback on the icon.
92
+ * secret values) and the clipboard write happen entirely on the host, through System Host
93
+ * Shell `copyVariableToClipboard`, so the plaintext is never exposed to the UI. Shows
94
+ * transient success/error feedback on the icon.
93
95
  */
94
96
  const CopyValueButton = (props) => {
95
97
  const { pvar } = props;
@@ -98,8 +100,12 @@ const CopyValueButton = (props) => {
98
100
  try {
99
101
  const userContext = await (0, peers_sdk_1.getUserContext)();
100
102
  const groupId = userContext.currentlyActiveGroupId();
101
- const result = await peers_sdk_1.rpcServerCalls.copyPvarValueToClipboard(pvar.persistentVarId, groupId);
102
- setStatus(result.success ? "copied" : "error");
103
+ const tools = await (0, host_shell_consumer_1.hostShellTools)();
104
+ const result = await tools.copyVariableToClipboard({
105
+ persistentVarId: pvar.persistentVarId,
106
+ groupId,
107
+ });
108
+ setStatus(result.copied ? "copied" : "error");
103
109
  }
104
110
  catch (err) {
105
111
  console.error("Failed to copy variable value", err);
@@ -31,7 +31,10 @@ interface INetworkStatusPresentation {
31
31
  count: number;
32
32
  colorClass: "text-success" | "text-danger";
33
33
  };
34
- /** Bottom-left yellow upgrade arrow when this device has an app update available. */
34
+ /**
35
+ * Yellow upgrade arrow on the lower-left corner of the status mark when this device
36
+ * has an app update available. Kept on the mark so it does not hang below the tab strip.
37
+ */
35
38
  updateAvailableOverlay?: boolean;
36
39
  }
37
40
  /** Convert host availability and network state into accessible indicator text and styling. */
@@ -352,12 +352,18 @@ function NetworkStatusIndicator({ compact = false }) {
352
352
  fontWeight: 700,
353
353
  right: "-0.28rem",
354
354
  bottom: "-0.32rem",
355
- }, "aria-hidden": "true", children: presentation.disconnectedGroupsOverlay.count })), presentation.updateAvailableOverlay && ((0, jsx_runtime_1.jsx)("i", { className: `network-status-update-badge bi ${app_update_controls_1.APP_UPDATE_ICON_CLASS} text-warning position-absolute`, style: {
356
- fontSize: "0.5rem",
357
- lineHeight: 1,
358
- left: "-0.3rem",
359
- bottom: "-0.32rem",
360
- }, "aria-hidden": "true" }))] }), !compact && ((0, jsx_runtime_1.jsxs)("span", { className: "small", children: [viewState.status?.connectionCount ?? 0, (0, jsx_runtime_1.jsx)("span", { className: "visually-hidden", children: " connections" })] }))] }), open && ((0, jsx_runtime_1.jsx)(NetworkStatusDetails, { detailsId: detailsId, status: viewState.status, appUpdate: appUpdate, unavailable: viewState.availability !== "ready", connectionsLoadState: connectionsLoadState, onUseInvite: () => {
355
+ }, "aria-hidden": "true", children: presentation.disconnectedGroupsOverlay.count })), presentation.updateAvailableOverlay && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("i", { className: `network-status-update-badge bi ${app_update_controls_1.APP_UPDATE_ICON_CLASS} text-warning position-absolute`, style: {
356
+ fontSize: "0.5rem",
357
+ lineHeight: 1,
358
+ width: "0.5rem",
359
+ height: "0.5rem",
360
+ left: "-0.46rem",
361
+ bottom: "-0.08rem",
362
+ }, "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("style", { children: `
363
+ .network-status-update-badge.bi::before {
364
+ vertical-align: 0;
365
+ }
366
+ ` })] }))] }), !compact && ((0, jsx_runtime_1.jsxs)("span", { className: "small", children: [viewState.status?.connectionCount ?? 0, (0, jsx_runtime_1.jsx)("span", { className: "visually-hidden", children: " connections" })] }))] }), open && ((0, jsx_runtime_1.jsx)(NetworkStatusDetails, { detailsId: detailsId, status: viewState.status, appUpdate: appUpdate, unavailable: viewState.availability !== "ready", connectionsLoadState: connectionsLoadState, onUseInvite: () => {
361
367
  setOpen(false);
362
368
  (0, tabs_state_1.goToTabPath)(identity_routes_1.IDENTITY_ACCEPT_PATH);
363
369
  }, onOpenIdentity: () => {
@@ -362,7 +362,7 @@ describe("NetworkStatusIndicator", () => {
362
362
  view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
363
363
  const badge = view.container.querySelector(".network-status-update-badge");
364
364
  expect(badge).toHaveClass("bi-arrow-up-circle-fill", "text-warning");
365
- expect(badge).toHaveStyle({ left: "-0.3rem", bottom: "-0.32rem" });
365
+ expect(badge).toHaveStyle({ left: "-0.46rem", bottom: "-0.08rem" });
366
366
  react_1.fireEvent.click(view.getByRole("button", { name: /Version 1\.1\.0 is ready to install/ }));
367
367
  const section = view.getByTestId("app-update-section");
368
368
  expect(section).toHaveTextContent("Installs automatically the next time Peers restarts");
@@ -182,8 +182,6 @@ const UILoader = (args) => {
182
182
  return (0, jsx_runtime_1.jsx)(UIAsyncLoader, { peersUIId: peersUIId, props: props }, peersUIId);
183
183
  };
184
184
  const uiLoadingPromises = {};
185
- // Check if we're running in a React Native WebView (has injectUIBundle available)
186
- const isReactNativeWebView = typeof window.ReactNativeWebView !== "undefined";
187
185
  async function resolveUiBundleFileId(pkg) {
188
186
  let pvId = pkg.activePackageVersionId;
189
187
  try {
@@ -214,42 +212,17 @@ function loadUIBundle(pkg, forceRefresh) {
214
212
  if (!uiBundleFileId) {
215
213
  return;
216
214
  }
217
- if (isReactNativeWebView && peers_sdk_1.rpcServerCalls.injectUIBundle) {
218
- const _w = window;
219
- _w.__peersUIs = _w.__peersUIs || {};
220
- const loadPromise = new Promise((resolveLoad, rejectLoad) => {
221
- _w.__peersUIBundleLoaded = (loadedFileId) => {
222
- if (loadedFileId === uiBundleFileId) {
223
- const store = _w.__peersUIs || {};
224
- Object.keys(store).forEach((peersUIId) => {
225
- peersUIs[peersUIId] = store[peersUIId];
226
- });
227
- resolveLoad();
228
- }
229
- };
230
- _w.__peersUIBundleError = (errorFileId, errorMsg) => {
231
- if (errorFileId === uiBundleFileId) {
232
- rejectLoad(new Error(errorMsg));
233
- }
234
- };
235
- });
236
- await peers_sdk_1.rpcServerCalls.injectUIBundle(uiBundleFileId);
237
- await loadPromise;
238
- console.log(`finished loading ui bundle for ${pkg.name}: ${(Date.now() - sTime).toFixed(0)}ms`);
239
- }
240
- else {
241
- const bundleCode = await peers_sdk_1.rpcServerCalls.getFileContents(uiBundleFileId);
242
- if (bundleCode) {
243
- const exportUIs = (peerUIs) => {
244
- peerUIs?.uis?.forEach((ui) => {
245
- peersUIs[ui.peersUIId] = ui;
246
- });
247
- };
248
- const bundleFunction = new Function("exportUIs", bundleCode);
249
- await bundleFunction(exportUIs);
250
- }
251
- console.log(`finished loading ui bundle for ${pkg.name}: ${(Date.now() - sTime).toFixed(0)}ms, ${bundleCode ? (bundleCode.length / 1000).toFixed(0) : "0"} KB`);
215
+ const bundleCode = await peers_sdk_1.rpcServerCalls.getFileContents(uiBundleFileId);
216
+ if (bundleCode) {
217
+ const exportUIs = (peerUIs) => {
218
+ peerUIs?.uis?.forEach((ui) => {
219
+ peersUIs[ui.peersUIId] = ui;
220
+ });
221
+ };
222
+ const bundleFunction = new Function("exportUIs", bundleCode);
223
+ await bundleFunction(exportUIs);
252
224
  }
225
+ console.log(`finished loading ui bundle for ${pkg.name}: ${(Date.now() - sTime).toFixed(0)}ms, ${bundleCode ? (bundleCode.length / 1000).toFixed(0) : "0"} KB`);
253
226
  })().catch((err) => {
254
227
  console.error(`Error while loading ui module for ${pkg.name}`, err);
255
228
  throw err;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.26.3",
3
+ "version": "0.26.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -30,7 +30,7 @@
30
30
  "lint:fix": "biome check --write ."
31
31
  },
32
32
  "peerDependencies": {
33
- "@peers-app/peers-sdk": "^0.26.3",
33
+ "@peers-app/peers-sdk": "^0.26.5",
34
34
  "bootstrap": "^5.3.3",
35
35
  "react": "^18.0.0",
36
36
  "react-dom": "^18.0.0"
@@ -41,7 +41,7 @@
41
41
  "@babel/preset-react": "^7.24.1",
42
42
  "@babel/preset-typescript": "^7.27.1",
43
43
  "@biomejs/biome": "2.3.11",
44
- "@peers-app/peers-sdk": "0.26.3",
44
+ "@peers-app/peers-sdk": "0.26.5",
45
45
  "@testing-library/dom": "^10.4.0",
46
46
  "@testing-library/jest-dom": "^6.6.3",
47
47
  "@testing-library/react": "^16.3.0",
@@ -5,7 +5,6 @@ import {
5
5
  type IGroup,
6
6
  newid,
7
7
  newKeys,
8
- rpcServerCalls,
9
8
  saveGroupSecretKey,
10
9
  setUserTrustLevel,
11
10
  TrustLevel,
@@ -14,12 +13,19 @@ import {
14
13
  } from "@peers-app/peers-sdk";
15
14
  import React, { useEffect, useState } from "react";
16
15
  import { usePromise } from "../hooks";
16
+ import { packagesTools } from "../screens/packages/packages-ipc";
17
17
 
18
18
  interface GroupSwitcherProps {
19
+ /** Current theme (`light` or `dark`). */
19
20
  colorMode: string;
21
+ /** Use the compact mobile layout. */
20
22
  isMobile?: boolean;
21
23
  }
22
24
 
25
+ /**
26
+ * Header dropdown for switching the active group (or the personal context) and creating a
27
+ * new group, which also installs the default packages into it.
28
+ */
23
29
  export function GroupSwitcher({ colorMode, isMobile = false }: GroupSwitcherProps) {
24
30
  const [showDropdown, setShowDropdown] = useState(false);
25
31
  const [showCreateModal, setShowCreateModal] = useState(false);
@@ -291,9 +297,11 @@ function CreateGroupModal({
291
297
 
292
298
  // Auto-install peers-core for the new group (copy from another context that
293
299
  // already has it, else fall back to the remote updateUrl / bundle).
294
- await rpcServerCalls.seedBundledPeersCore(groupId).catch((err: unknown) => {
295
- console.error("Error auto-installing peers-core for group:", err);
296
- });
300
+ await packagesTools(groupId)
301
+ .then((tools) => tools.installDefaultPackages())
302
+ .catch((err: unknown) => {
303
+ console.error("Error auto-installing peers-core for group:", err);
304
+ });
297
305
 
298
306
  // Notify parent
299
307
  onGroupCreated(newGroup.groupId);
@@ -1,8 +1,9 @@
1
1
  // import { FileListDisplay } from './file-list-display';
2
- import { type IMentionData, mentionRegex, rpcServerCalls } from "@peers-app/peers-sdk";
2
+ import { type IMentionData, mentionRegex } from "@peers-app/peers-sdk";
3
3
  import React from "react";
4
4
  import ReactMarkdown from "react-markdown";
5
5
  import remarkGfm from "remark-gfm";
6
+ import { openOnHost } from "../host-shell/host-shell-consumer";
6
7
  import { mentionConfigs } from "../mention-configs";
7
8
 
8
9
  // Custom Link Renderer
@@ -14,7 +15,7 @@ const LinkRenderer = (props: { href?: string; children?: React.ReactNode }) => {
14
15
  onClick={(e) => {
15
16
  if (href?.startsWith("http")) {
16
17
  e.preventDefault();
17
- rpcServerCalls.openLinkInBrowser(href);
18
+ void openOnHost(href);
18
19
  }
19
20
  }}
20
21
  >
@@ -159,6 +160,10 @@ const createElementWithMentions = (Element: React.ElementType) => {
159
160
  };
160
161
  };
161
162
 
163
+ /**
164
+ * Render markdown (GitHub-flavored) with `@mentions` turned into links. Web links open in
165
+ * the system browser through System Host Shell instead of navigating the app window.
166
+ */
162
167
  export const MarkdownWithMentions: React.FC<{ content: string }> = ({ content }) => {
163
168
  const components = {
164
169
  p: createElementWithMentions("p"),
@@ -3,10 +3,13 @@ import type React from "react";
3
3
  import { isDesktop, mainContentPath } from "../globals";
4
4
  import { useObservable } from "../hooks";
5
5
 
6
+ /** Props for {@link SaveButton}. */
6
7
  export interface ISaveButtonProps {
7
8
  /** Used with many concrete table row shapes; IDoc stays generic at call sites. */
8
9
  doc: IDoc<any>;
10
+ /** Save handler; defaults to `doc.save`. */
9
11
  onClick?: () => void | Promise<void>;
12
+ /** Extra dropdown actions: buttons, or arbitrary nodes such as dividers. */
10
13
  addActions?: (
11
14
  | {
12
15
  label: string;
@@ -15,9 +18,21 @@ export interface ISaveButtonProps {
15
18
  }
16
19
  | React.ReactNode
17
20
  )[];
21
+ /** Hide the built-in Discard Changes and Delete actions. */
18
22
  noBuiltInActions?: boolean;
23
+ /** Label for the built-in delete action. Default: `Delete`. */
24
+ deleteLabel?: string;
25
+ /**
26
+ * Replace the built-in delete (`doc.delete()`), for records whose removal must go through
27
+ * a Runtime contract. Runs after the user confirms; the screen then navigates up.
28
+ */
29
+ onDelete?: () => void | Promise<void>;
19
30
  }
20
31
 
32
+ /**
33
+ * Save button with a dropdown of extra actions plus built-in Discard Changes and Delete.
34
+ * Enabled only while the document has unsaved changes.
35
+ */
21
36
  export const SaveButton = (props: ISaveButtonProps) => {
22
37
  let { doc, addActions } = props;
23
38
  useObservable(doc.q);
@@ -45,12 +60,18 @@ export const SaveButton = (props: ISaveButtonProps) => {
45
60
  if (allActions.length) {
46
61
  allActions.push(<hr className="dropdown-divider mt-3" />);
47
62
  }
63
+ const deleteLabel = props.deleteLabel ?? "Delete";
48
64
  allActions.push({
49
- label: "Delete",
65
+ label: deleteLabel,
50
66
  className: `text-bg-danger`,
51
67
  onClick: async () => {
52
- if (confirm("Are you sure you want to delete this?")) {
53
- await doc.delete();
68
+ if (confirm(`Are you sure you want to ${deleteLabel.toLowerCase()} this?`)) {
69
+ try {
70
+ await (props.onDelete ? props.onDelete() : doc.delete());
71
+ } catch (err) {
72
+ alert(`${deleteLabel} failed: ${err instanceof Error ? err.message : String(err)}`);
73
+ return;
74
+ }
54
75
  const pathParts = mainContentPath().split("/");
55
76
  pathParts.pop();
56
77
  const path = pathParts.join("/") ?? "";
package/src/globals.tsx CHANGED
@@ -9,11 +9,11 @@ import {
9
9
  Messages,
10
10
  observable,
11
11
  reloadPackagesOnPageRefresh,
12
- rpcClientCalls,
13
- rpcServerCalls,
14
12
  sleep,
13
+ subscribe,
15
14
  } from "@peers-app/peers-sdk";
16
15
  import { canonicalizeRoute } from "./screens/identity/identity-routes";
16
+ import { packagesTools } from "./screens/packages/packages-ipc";
17
17
  import { handleMainPathChanged, openNewTab } from "./tabs-layout/tabs-state";
18
18
  import { stripMentions } from "./utils";
19
19
 
@@ -107,14 +107,10 @@ export async function openThreadInTab(thread: string | IMessage) {
107
107
  });
108
108
  }
109
109
 
110
- rpcClientCalls.setClientPath = async (url: string) => {
111
- mainContentPath((url && String(url).trim()) || "");
112
- };
113
-
114
- rpcClientCalls.openThread = async (threadId: string) => {
115
- console.warn("Opening thread:", threadId);
116
- openThread(threadId);
117
- };
110
+ // A notification clicked in the desktop app's main process opens its thread here.
111
+ subscribe<{ threadId?: string }>("ui:openThread", (event) => {
112
+ if (event.data?.threadId) void openThread(event.data.threadId);
113
+ });
118
114
 
119
115
  /** Signed profile for the active user after {@link loadGlobals}. */
120
116
  export let me: IUser = null as unknown as IUser;
@@ -179,6 +175,31 @@ export function initializeHashNavigation() {
179
175
  }
180
176
  }
181
177
 
178
+ /** Longest page startup waits for package folders to be re-read. */
179
+ const PACKAGE_FOLDER_RELOAD_WAIT_MS = 30_000;
180
+
181
+ /**
182
+ * Re-read this device's package folders so rebuilt dev bundles load before the UI does.
183
+ * Skipped on hosts without package folders; failures are logged, and the wait is bounded so
184
+ * a slow host cannot stall startup.
185
+ */
186
+ async function reloadPackageFolders(): Promise<void> {
187
+ const reload = (async () => {
188
+ const tools = await packagesTools();
189
+ const { capabilities } = await tools.getCapabilities();
190
+ if (!capabilities.fileSystem) return;
191
+ const { skipped, results } = await tools.reloadAllPackages();
192
+ if (skipped) console.warn(skipped);
193
+ for (const result of results) {
194
+ if (!result.ok) console.warn(`[packages] Reloading ${result.name} failed: ${result.error}`);
195
+ if (result.warning) console.warn(`[packages] ${result.name}: ${result.warning}`);
196
+ }
197
+ })().catch((err: unknown) => {
198
+ console.warn("[packages] Reloading package folders failed:", err);
199
+ });
200
+ await Promise.race([reload, sleep(PACKAGE_FOLDER_RELOAD_WAIT_MS)]);
201
+ }
202
+
182
203
  /** Load global user state required before the root layout renders. */
183
204
  export async function loadGlobals() {
184
205
  if (globalsLoaded) return true;
@@ -186,7 +207,7 @@ export async function loadGlobals() {
186
207
  me = await getMe();
187
208
  await reloadPackagesOnPageRefresh.loadingPromise;
188
209
  if (reloadPackagesOnPageRefresh()) {
189
- await rpcServerCalls.addOrUpdatePackage("updateAll");
210
+ await reloadPackageFolders();
190
211
  }
191
212
  await Promise.all([
192
213
  Groups()
@@ -0,0 +1,32 @@
1
+ const mockTools = { open: jest.fn(), openInEditor: jest.fn() };
2
+ jest.mock("@peers-app/peers-sdk", () => ({
3
+ getHostShellContractDefinition: () => ({ contractId: "host-shell" }),
4
+ hostContractConsumer: () => ({ get: async () => ({ tools: mockTools }), dispose: () => {} }),
5
+ }));
6
+
7
+ import { openInEditorOnHost, openOnHost } from "./host-shell-consumer";
8
+
9
+ const alert = jest.fn();
10
+ beforeEach(() => {
11
+ Object.assign(window, { alert });
12
+ jest.spyOn(console, "error").mockImplementation(() => {});
13
+ mockTools.open.mockReset();
14
+ mockTools.openInEditor.mockReset();
15
+ alert.mockReset();
16
+ });
17
+
18
+ it("opens links and folders on the host", async () => {
19
+ await openOnHost("https://peers.app");
20
+ expect(mockTools.open).toHaveBeenCalledWith({ target: "https://peers.app" });
21
+ await openInEditorOnHost("~/peers/packages/desk");
22
+ expect(mockTools.openInEditor).toHaveBeenCalledWith({ path: "~/peers/packages/desk" });
23
+ expect(alert).not.toHaveBeenCalled();
24
+ });
25
+
26
+ it("shows why the host could not open something", async () => {
27
+ mockTools.open.mockRejectedValueOnce(new Error("open refuses git: links"));
28
+ await openOnHost("git@github.com:me/pkg.git");
29
+ expect(alert).toHaveBeenCalledWith(
30
+ "Could not open git@github.com:me/pkg.git: open refuses git: links",
31
+ );
32
+ });
@@ -0,0 +1,53 @@
1
+ import {
2
+ getHostShellContractDefinition,
3
+ hostContractConsumer,
4
+ type IHostShellConsumer,
5
+ } from "@peers-app/peers-sdk";
6
+
7
+ /**
8
+ * Shared handle for System Host Shell on the local host. Calls name no data context, so the
9
+ * host runs them in the personal context, where the signed-in user has Self access.
10
+ */
11
+ export const hostShellContract = hostContractConsumer<IHostShellConsumer>(
12
+ getHostShellContractDefinition(),
13
+ );
14
+
15
+ /** Return the System Host Shell tools of the local host. */
16
+ export async function hostShellTools(): Promise<IHostShellConsumer["tools"]> {
17
+ return (await hostShellContract.get()).tools;
18
+ }
19
+
20
+ /**
21
+ * Open a web link in the browser, or a local file or folder with the OS. Failures (an
22
+ * unsupported host, a refused link scheme, a missing folder) are logged and shown.
23
+ *
24
+ * @param target - Link or local path (`~` allowed).
25
+ */
26
+ export async function openOnHost(target: string): Promise<void> {
27
+ try {
28
+ await (await hostShellTools()).open({ target });
29
+ } catch (error) {
30
+ reportHostShellError(`Could not open ${target}`, error);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Open a folder in a code editor (VS Code when installed), else with the OS.
36
+ *
37
+ * @param path - Folder or file (`~` allowed).
38
+ */
39
+ export async function openInEditorOnHost(path: string): Promise<void> {
40
+ try {
41
+ await (await hostShellTools()).openInEditor({ path });
42
+ } catch (error) {
43
+ reportHostShellError(`Could not open ${path}`, error);
44
+ }
45
+ }
46
+
47
+ function reportHostShellError(what: string, error: unknown): void {
48
+ const message = error instanceof Error ? error.message : String(error);
49
+ console.error(`${what}:`, error);
50
+ if (typeof window !== "undefined" && typeof window.alert === "function") {
51
+ window.alert(`${what}: ${message}`);
52
+ }
53
+ }
@@ -2,11 +2,11 @@ import {
2
2
  Assistants,
3
3
  type IMentionData,
4
4
  PeerTypes,
5
- rpcClientCalls,
6
5
  Tools,
7
6
  Users,
8
7
  Workflows,
9
8
  } from "@peers-app/peers-sdk";
9
+ import { mainContentPath } from "./globals";
10
10
 
11
11
  import { listWorkflowMentionTools } from "./screens/tools/tool-catalog-ipc";
12
12
 
@@ -163,7 +163,7 @@ export const valueTypeMentionConfig: IMentionConfig = {
163
163
  },
164
164
  ),
165
165
  onClick(data) {
166
- rpcClientCalls.setClientPath(`peer-types/${data.id}`);
166
+ mainContentPath(`peer-types/${data.id}`);
167
167
  },
168
168
  };
169
169
 
@@ -8,7 +8,6 @@ import {
8
8
  type IGroup,
9
9
  newid,
10
10
  newKeys,
11
- rpcServerCalls,
12
11
  saveGroupSecretKey,
13
12
  setUserTrustLevel,
14
13
  TrustLevel,
@@ -21,6 +20,7 @@ import { mainContentPath } from "../../globals";
21
20
  import { usePromise } from "../../hooks";
22
21
  import { IDENTITY_GROUP_NEW_PATH, IDENTITY_GROUPS_PATH } from "../identity/identity-routes";
23
22
  import { PendingWorkLink } from "../invites/activity-view";
23
+ import { packagesTools } from "../packages/packages-ipc";
24
24
 
25
25
  /** Search, summarize, and create groups owned by the signed-in user. */
26
26
  export function GroupList(props: { creating?: boolean } = {}) {
@@ -151,9 +151,11 @@ export function GroupList(props: { creating?: boolean } = {}) {
151
151
  await Groups(groupDataContext).signAndSave(group);
152
152
 
153
153
  // Auto-install peers-core for the new group
154
- await rpcServerCalls.seedBundledPeersCore(groupId).catch((err: unknown) => {
155
- console.error("Error auto-installing peers-core for group:", err);
156
- });
154
+ await packagesTools(groupId)
155
+ .then((tools) => tools.installDefaultPackages())
156
+ .catch((err: unknown) => {
157
+ console.error("Error auto-installing peers-core for group:", err);
158
+ });
157
159
 
158
160
  mainContentPath(`${IDENTITY_GROUPS_PATH}/${group.groupId}`);
159
161
  return group;
@@ -1,4 +1,4 @@
1
- import { Packages, rpcServerCalls } from "@peers-app/peers-sdk";
1
+ import { Packages } from "@peers-app/peers-sdk";
2
2
  import { Input } from "../../components/input";
3
3
  import { LoadingIndicator } from "../../components/loading-indicator";
4
4
  import { type ISaveButtonProps, SaveButton } from "../../components/save-button";
@@ -7,9 +7,12 @@ import { useObservableState, usePromise } from "../../hooks";
7
7
  import { updateActiveTabTitle } from "../../tabs-layout/tabs-state";
8
8
  import { LocalFolderGrantsPanel } from "./local-folder-grants-panel";
9
9
  import { PackageInfo } from "./package-info";
10
+ import { PackageRuntimeStatus } from "./package-runtime-status";
10
11
  import { PackageVersionsList } from "./package-versions";
12
+ import { packagesTools } from "./packages-ipc";
11
13
 
12
14
  interface IProps {
15
+ /** Package to show. */
13
16
  packageId: string;
14
17
  }
15
18
 
@@ -38,7 +41,15 @@ export const PackageDetails = (props: IProps) => {
38
41
  {
39
42
  label: "Reload Package",
40
43
  onClick: async () => {
41
- await rpcServerCalls.addOrUpdatePackage(pkg.packageId, { update: true });
44
+ try {
45
+ const { warning } = await (await packagesTools()).reloadPackage({
46
+ packageId: pkg.packageId,
47
+ });
48
+ if (warning) alert(warning);
49
+ } catch (err) {
50
+ alert(`Reload failed: ${err instanceof Error ? err.message : String(err)}`);
51
+ return;
52
+ }
42
53
  await pkg.load();
43
54
  window.location.reload();
44
55
  },
@@ -50,15 +61,21 @@ export const PackageDetails = (props: IProps) => {
50
61
  // className: 'text-bg-warning',
51
62
  onClick: async () => {
52
63
  if (confirm(`Create a remote repository from this local one: ${pkg.name}`)) {
64
+ const tools = await packagesTools();
65
+ try {
66
+ await tools.linkRemoteRepo({ packageId: pkg.packageId });
67
+ } catch (err) {
68
+ alert(`Error linking remote repo: ${err instanceof Error ? err.message : err}`);
69
+ return;
70
+ }
53
71
  try {
54
- await rpcServerCalls.addOrUpdatePackage(pkg.packageId, {
55
- linkRemote: true,
56
- update: true,
57
- });
58
- refresh(Date.now());
72
+ await tools.reloadPackage({ packageId: pkg.packageId });
59
73
  } catch (err) {
60
- alert(`Error linking remote repo: ${err}`);
74
+ alert(
75
+ `Linked the remote repo, but reloading failed: ${err instanceof Error ? err.message : err}`,
76
+ );
61
77
  }
78
+ refresh(Date.now());
62
79
  }
63
80
  },
64
81
  });
@@ -91,10 +108,16 @@ export const PackageDetails = (props: IProps) => {
91
108
  pkg.q(0);
92
109
  }}
93
110
  addActions={[...addActions]}
111
+ deleteLabel="Uninstall"
112
+ onDelete={async () => {
113
+ await (await packagesTools()).uninstallPackage({ packageId: pkg.packageId });
114
+ }}
94
115
  />
95
116
  </div>
96
117
  </div>
97
118
 
119
+ <PackageRuntimeStatus packageId={pkg.packageId} />
120
+
98
121
  <Tabs
99
122
  key={pkg.packageId}
100
123
  tabs={[
@@ -0,0 +1,57 @@
1
+ import { GroupMemberRole, type IPackageVersion, newid } from "@peers-app/peers-sdk";
2
+ import { activatePackageVersion } from "./package-helpers";
3
+
4
+ const mockActivate = jest.fn(async () => ({}));
5
+ const mockRole = jest.fn();
6
+ const mockPrefs = jest.fn();
7
+
8
+ jest.mock("./packages-ipc", () => ({
9
+ packagesTools: async () => ({ activatePackageVersion: mockActivate }),
10
+ }));
11
+ jest.mock("@peers-app/peers-sdk", () => {
12
+ const actual = jest.requireActual("@peers-app/peers-sdk");
13
+ return {
14
+ ...actual,
15
+ getUserContext: async () => ({
16
+ userId: "user",
17
+ defaultDataContext: () => ({ dataContextId: "group" }),
18
+ }),
19
+ getUserRole: () => mockRole(),
20
+ packagePrefsVar: () => Object.assign(mockPrefs, { loadingPromise: Promise.resolve() }),
21
+ };
22
+ });
23
+
24
+ beforeEach(() => {
25
+ jest.clearAllMocks();
26
+ mockRole.mockResolvedValue(GroupMemberRole.Admin);
27
+ mockPrefs.mockReturnValue({});
28
+ });
29
+
30
+ test.each([
31
+ "dev",
32
+ "",
33
+ undefined,
34
+ ])("activates dev tag %p only on this device", async (versionTag) => {
35
+ const packageId = newid();
36
+ const packageVersionId = newid();
37
+ await activatePackageVersion(packageId, { packageVersionId, versionTag } as IPackageVersion);
38
+ expect(mockActivate).toHaveBeenCalledWith({ packageId, packageVersionId, scope: "device" });
39
+ });
40
+
41
+ test.each(["beta", "stable"])("an admin activates %s for the group", async (versionTag) => {
42
+ const packageId = newid();
43
+ const packageVersionId = newid();
44
+ await activatePackageVersion(packageId, { packageVersionId, versionTag } as IPackageVersion);
45
+ expect(mockActivate).toHaveBeenCalledWith({ packageId, packageVersionId, scope: "group" });
46
+ });
47
+
48
+ test("a device follow override keeps release activation local", async () => {
49
+ mockPrefs.mockReturnValue({ followTags: "stable,beta" });
50
+ const packageId = newid();
51
+ const packageVersionId = newid();
52
+ await activatePackageVersion(packageId, {
53
+ packageVersionId,
54
+ versionTag: "stable",
55
+ } as IPackageVersion);
56
+ expect(mockActivate).toHaveBeenCalledWith({ packageId, packageVersionId, scope: "device" });
57
+ });