@xpufx/paseo-twofado 0.1.0

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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/client/approvals.tsx +1525 -0
  4. package/client/error-boundary.tsx +28 -0
  5. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  6. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  7. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  8. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  9. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  10. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  11. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  12. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  13. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  14. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  15. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  16. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  17. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  18. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  19. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  20. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  21. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  22. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  23. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  24. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  25. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  26. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  27. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  28. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  29. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  30. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  31. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  32. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  33. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  34. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  35. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  36. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  37. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  38. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  39. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  40. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  41. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  42. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  43. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  44. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  45. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  46. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  47. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  48. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  49. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  50. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  51. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  52. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  53. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  54. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  55. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  56. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  57. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  58. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  59. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  60. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  61. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  62. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  63. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  64. package/package.json +36 -0
  65. package/paseo-plugin.json +6 -0
  66. package/server/twofado.ts +536 -0
  67. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  68. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  69. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  70. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  71. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  72. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  73. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  74. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  75. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  76. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  77. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  78. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  79. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  80. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  81. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  82. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  83. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  84. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  85. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  86. package/shared/approval.ts +254 -0
  87. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  88. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  89. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  90. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  91. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  92. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  93. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  94. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  95. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  96. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  97. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
@@ -0,0 +1,79 @@
1
+ import { useState, useMemo } from "react";
2
+ import type { PluginRpcContract } from "../../../shared/vendor/paseo-plugin-helper/rpc";
3
+ import { useRpcQuery, type RpcQueryOptions } from "./query";
4
+ import type { RpcInput, RpcOutput } from "../../../shared/vendor/paseo-plugin-helper/rpc";
5
+
6
+ export type RefreshRate =
7
+ | "1s"
8
+ | "2s"
9
+ | "5s"
10
+ | "10s"
11
+ | "15s"
12
+ | "30s"
13
+ | "60s"
14
+ | "5m"
15
+ | "paused";
16
+
17
+ export const REFRESH_INTERVALS: Record<RefreshRate, number | false> = {
18
+ "1s": 1000,
19
+ "2s": 2000,
20
+ "5s": 5000,
21
+ "10s": 10000,
22
+ "15s": 15000,
23
+ "30s": 30000,
24
+ "60s": 60000,
25
+ "5m": 300000,
26
+ paused: false,
27
+ };
28
+
29
+ export interface UseAutoRefreshQueryOptions<TOutput> extends RpcQueryOptions<TOutput> {
30
+ defaultRate?: RefreshRate;
31
+ /**
32
+ * Custom interval in milliseconds (overrides preset rates when not paused).
33
+ */
34
+ customIntervalMs?: number;
35
+ /**
36
+ * Whether the containing modal or panel is actively open/visible.
37
+ * If false, background refetching is automatically paused to conserve mobile CPU and battery.
38
+ */
39
+ isOpen?: boolean;
40
+ }
41
+
42
+ /**
43
+ * Enhanced React Query hook for live polling metrics.
44
+ * Automatically halts background polling when modal/panel is closed (`isOpen === false`),
45
+ * and provides state controls for user-selectable refresh intervals ("1s", "5s", "30s", "paused", etc.).
46
+ */
47
+ export function useAutoRefreshQuery<
48
+ TContract extends PluginRpcContract<any, any>,
49
+ TInput = RpcInput<TContract>,
50
+ TOutput = RpcOutput<TContract>,
51
+ >(
52
+ contract: TContract,
53
+ input: TInput,
54
+ options?: UseAutoRefreshQueryOptions<TOutput>,
55
+ ) {
56
+ const { defaultRate = "2s", customIntervalMs, isOpen = true, ...queryOptions } = options ?? {};
57
+ const [rate, setRate] = useState<RefreshRate>(defaultRate);
58
+
59
+ const effectiveInterval = useMemo<number | false>(() => {
60
+ if (!isOpen || rate === "paused") return false;
61
+ if (typeof customIntervalMs === "number" && customIntervalMs > 0) {
62
+ return customIntervalMs;
63
+ }
64
+ return REFRESH_INTERVALS[rate] ?? false;
65
+ }, [isOpen, rate, customIntervalMs]);
66
+
67
+ const query = useRpcQuery(contract, input, {
68
+ ...queryOptions,
69
+ refetchInterval: effectiveInterval,
70
+ });
71
+
72
+ return {
73
+ ...query,
74
+ rate,
75
+ setRate,
76
+ isPolling: effectiveInterval !== false,
77
+ effectiveInterval,
78
+ };
79
+ }
@@ -0,0 +1,66 @@
1
+ import { getClientHost, type HostRpcContract } from "./host";
2
+ import type { PluginRpcContract } from "../../../shared/vendor/paseo-plugin-helper/rpc";
3
+ import type { RpcInput, RpcOutput } from "../../../shared/vendor/paseo-plugin-helper/rpc";
4
+ import {
5
+ useQuery,
6
+ useMutation,
7
+ type UseQueryOptions,
8
+ type UseQueryResult,
9
+ type UseMutationOptions,
10
+ type UseMutationResult,
11
+ } from "@tanstack/react-query";
12
+
13
+ export type RpcQueryOptions<TOutput> = Omit<
14
+ UseQueryOptions<TOutput, Error, TOutput, readonly unknown[]>,
15
+ "queryKey" | "queryFn"
16
+ >;
17
+
18
+ /**
19
+ * Executes a Paseo RPC contract as a cached, reactive React Query.
20
+ * Automatically hashes contract name and input arguments into query keys.
21
+ */
22
+ export function useRpcQuery<
23
+ TContract extends PluginRpcContract<any, any>,
24
+ TInput = RpcInput<TContract>,
25
+ TOutput = RpcOutput<TContract>,
26
+ >(
27
+ contract: TContract,
28
+ input: TInput,
29
+ options?: RpcQueryOptions<TOutput>,
30
+ ): UseQueryResult<TOutput, Error> {
31
+ const { useRpc } = getClientHost();
32
+ const callRpc = useRpc(contract);
33
+
34
+ return useQuery({
35
+ queryKey: [contract.name, input],
36
+ queryFn: () => callRpc(input),
37
+ ...options,
38
+ });
39
+ }
40
+
41
+ export type RpcMutationOptions<TInput, TOutput> = UseMutationOptions<
42
+ TOutput,
43
+ Error,
44
+ TInput,
45
+ unknown
46
+ >;
47
+
48
+ /**
49
+ * Executes a Paseo RPC contract as a mutation (for state changes, write operations).
50
+ */
51
+ export function useRpcMutation<
52
+ TContract extends PluginRpcContract<any, any>,
53
+ TInput = RpcInput<TContract>,
54
+ TOutput = RpcOutput<TContract>,
55
+ >(
56
+ contract: TContract,
57
+ options?: RpcMutationOptions<TInput, TOutput>,
58
+ ): UseMutationResult<TOutput, Error, TInput, unknown> {
59
+ const { useRpc } = getClientHost();
60
+ const callRpc = useRpc(contract);
61
+
62
+ return useMutation({
63
+ mutationFn: (input: TInput) => callRpc(input),
64
+ ...options,
65
+ });
66
+ }
@@ -0,0 +1,372 @@
1
+ import React, { type ComponentType, type ReactNode } from "react";
2
+ import type { SettingsContract } from "../../../shared/vendor/paseo-plugin-helper/settings";
3
+ import type { HostSurfaceProps, PluginCleanup } from "./host";
4
+ import { usePluginSettings } from "./settings";
5
+
6
+ export interface HelperSettingsCardProps {
7
+ children: ReactNode;
8
+ testID?: string;
9
+ }
10
+
11
+ export interface HelperSettingsSectionProps {
12
+ title: string;
13
+ info?: ReactNode;
14
+ trailing?: ReactNode;
15
+ children: ReactNode;
16
+ testID?: string;
17
+ }
18
+
19
+ export interface HelperSettingsRowBaseProps {
20
+ label: string;
21
+ hint?: string;
22
+ error?: string | null;
23
+ children?: ReactNode;
24
+ testID?: string;
25
+ }
26
+
27
+ export interface HelperSettingsSwitchProps extends HelperSettingsRowBaseProps {
28
+ value: boolean;
29
+ onValueChange(value: boolean): void;
30
+ disabled?: boolean;
31
+ }
32
+
33
+ export interface HelperSettingsSelectProps<Value extends string = string>
34
+ extends HelperSettingsRowBaseProps {
35
+ value: Value;
36
+ options: readonly { label: string; value: Value }[];
37
+ onValueChange(value: Value): void;
38
+ disabled?: boolean;
39
+ }
40
+
41
+ export interface HelperSettingsInputProps extends HelperSettingsRowBaseProps {
42
+ initialValue?: string;
43
+ onChangeText(text: string): void;
44
+ placeholder?: string;
45
+ disabled?: boolean;
46
+ secureTextEntry?: boolean;
47
+ }
48
+
49
+ export type HelperSettingsSelectComponent = <Value extends string = string>(
50
+ props: HelperSettingsSelectProps<Value>,
51
+ ) => ReactNode;
52
+
53
+ export interface HelperSettingsUiBundle {
54
+ SettingsCard: ComponentType<HelperSettingsCardProps>;
55
+ SettingsSection: ComponentType<HelperSettingsSectionProps>;
56
+ SettingsSwitch: ComponentType<HelperSettingsSwitchProps>;
57
+ SettingsSelect: HelperSettingsSelectComponent;
58
+ SettingsInput: ComponentType<HelperSettingsInputProps>;
59
+ }
60
+
61
+ export interface HelperSettingsScreenContribution {
62
+ id: string;
63
+ title: string;
64
+ icon: string;
65
+ Component: ComponentType<HostSurfaceProps>;
66
+ }
67
+
68
+ export interface HelperSettingsScreenRegistrar {
69
+ addSettingsScreen(contribution: HelperSettingsScreenContribution): PluginCleanup;
70
+ }
71
+
72
+ export type HelperSettingsFieldKind = "boolean" | "enum" | "string" | "number";
73
+
74
+ export interface HelperSettingsField {
75
+ key: string;
76
+ kind: HelperSettingsFieldKind;
77
+ label: string;
78
+ description?: string;
79
+ options?: string[];
80
+ }
81
+
82
+ export interface HelperSettingsFieldOverrides {
83
+ labels?: Record<string, string>;
84
+ descriptions?: Record<string, string>;
85
+ }
86
+
87
+ const WRAPPER_TYPES = new Set([
88
+ "default",
89
+ "ZodDefault",
90
+ "prefault",
91
+ "ZodPrefault",
92
+ "optional",
93
+ "ZodOptional",
94
+ "nullable",
95
+ "ZodNullable",
96
+ "readonly",
97
+ "ZodReadonly",
98
+ "catch",
99
+ "ZodCatch",
100
+ "nonoptional",
101
+ "ZodNonOptional",
102
+ ]);
103
+
104
+ function readDescription(schema: any): string | undefined {
105
+ if (!schema || typeof schema !== "object") return undefined;
106
+ if (typeof schema.description === "string" && schema.description.length > 0) {
107
+ return schema.description;
108
+ }
109
+ const def = schema._def ?? schema._zod?.def;
110
+ if (def && typeof def.description === "string" && def.description.length > 0) {
111
+ return def.description;
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ function readInnerType(schema: any): any {
117
+ if (!schema || typeof schema !== "object") return undefined;
118
+ const def = schema._def ?? schema._zod?.def;
119
+ const inner = def?.innerType ?? schema._def?.innerType ?? schema._zod?.def?.innerType;
120
+ return inner;
121
+ }
122
+
123
+ function unwrapField(raw: any): { leaf: any; description?: string } {
124
+ let current = raw;
125
+ let description = readDescription(raw);
126
+ let guard = 0;
127
+ while (current && typeof current === "object" && guard++ < 20) {
128
+ const def = current._def ?? current._zod?.def;
129
+ const type = def?.type ?? def?.typeName;
130
+ const ctor = current.constructor?.name;
131
+ if (
132
+ (typeof type === "string" && WRAPPER_TYPES.has(type)) ||
133
+ (typeof ctor === "string" && WRAPPER_TYPES.has(ctor))
134
+ ) {
135
+ const inner = readInnerType(current);
136
+ if (!inner || inner === current) break;
137
+ current = inner;
138
+ if (!description) description = readDescription(current);
139
+ continue;
140
+ }
141
+ break;
142
+ }
143
+ if (!description) description = readDescription(current);
144
+ return { leaf: current, description };
145
+ }
146
+
147
+ function leafKind(leaf: any): HelperSettingsFieldKind | undefined {
148
+ if (!leaf || typeof leaf !== "object") return undefined;
149
+ const def = leaf._def ?? leaf._zod?.def ?? {};
150
+ const type = def.type ?? def.typeName;
151
+ const ctor = leaf.constructor?.name;
152
+ if (type === "enum" || type === "ZodEnum" || ctor === "ZodEnum") return "enum";
153
+ if (type === "boolean" || type === "ZodBoolean" || ctor === "ZodBoolean") return "boolean";
154
+ if (type === "number" || type === "ZodNumber" || ctor === "ZodNumber") return "number";
155
+ if (
156
+ type === "string" ||
157
+ type === "ZodString" ||
158
+ type === "string_format" ||
159
+ ctor === "ZodString" ||
160
+ (typeof ctor === "string" && ctor.startsWith("ZodString"))
161
+ ) {
162
+ return "string";
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ function enumValues(leaf: any): string[] | undefined {
168
+ const found: unknown[] = [];
169
+ const sources: any[] = [leaf?._def, leaf?._zod?.def, leaf];
170
+ for (const source of sources) {
171
+ if (!source || typeof source !== "object") continue;
172
+ if (Array.isArray(source.values)) found.push(...source.values);
173
+ if (Array.isArray(source.options)) {
174
+ for (const option of source.options) {
175
+ if (typeof option === "string") found.push(option);
176
+ else if (option && typeof option === "object" && typeof option.value === "string") {
177
+ found.push(option.value);
178
+ }
179
+ }
180
+ }
181
+ if (source.entries && typeof source.entries === "object") {
182
+ for (const value of Object.values(source.entries)) {
183
+ if (typeof value === "string") found.push(value);
184
+ }
185
+ }
186
+ if (source.enum && typeof source.enum === "object") {
187
+ for (const value of Object.values(source.enum)) {
188
+ if (typeof value === "string") found.push(value);
189
+ }
190
+ }
191
+ }
192
+ const seen = new Set<string>();
193
+ const out: string[] = [];
194
+ for (const candidate of found) {
195
+ if (typeof candidate === "string" && !seen.has(candidate)) {
196
+ seen.add(candidate);
197
+ out.push(candidate);
198
+ }
199
+ }
200
+ return out.length > 0 ? out : undefined;
201
+ }
202
+
203
+ function getObjectShape(schema: any): Record<string, any> | undefined {
204
+ if (!schema || typeof schema !== "object") return undefined;
205
+ const direct = schema.shape;
206
+ if (direct && typeof direct === "object" && !Array.isArray(direct)) {
207
+ return direct as Record<string, any>;
208
+ }
209
+ const def = schema._def ?? schema._zod?.def;
210
+ if (def && def.shape && typeof def.shape === "object") {
211
+ return def.shape as Record<string, any>;
212
+ }
213
+ if (def && typeof def.shape === "function") {
214
+ try {
215
+ const resolved = def.shape();
216
+ if (resolved && typeof resolved === "object") return resolved as Record<string, any>;
217
+ } catch {
218
+ return undefined;
219
+ }
220
+ }
221
+ return undefined;
222
+ }
223
+
224
+ export function contractSchemaToFields(
225
+ schema: unknown,
226
+ overrides: HelperSettingsFieldOverrides = {},
227
+ ): HelperSettingsField[] {
228
+ const shape = getObjectShape(schema);
229
+ if (!shape) {
230
+ console.warn("paseo-plugin-helper: settings schema has no object shape, no fields mapped");
231
+ return [];
232
+ }
233
+ const fields: HelperSettingsField[] = [];
234
+ for (const key of Object.keys(shape)) {
235
+ try {
236
+ const { leaf, description } = unwrapField(shape[key]);
237
+ const kind = leafKind(leaf);
238
+ if (!kind) {
239
+ console.warn(`paseo-plugin-helper: skipping unsupported settings field "${key}"`);
240
+ continue;
241
+ }
242
+ let options: string[] | undefined;
243
+ if (kind === "enum") {
244
+ options = enumValues(leaf);
245
+ if (!options || options.length === 0) {
246
+ console.warn(`paseo-plugin-helper: skipping enum settings field "${key}" with no options`);
247
+ continue;
248
+ }
249
+ }
250
+ const label = overrides.labels?.[key] ?? description ?? key;
251
+ const hint = overrides.descriptions?.[key] ?? description;
252
+ fields.push({
253
+ key,
254
+ kind,
255
+ label,
256
+ ...(hint !== undefined ? { description: hint } : {}),
257
+ ...(options ? { options } : {}),
258
+ });
259
+ } catch {
260
+ console.warn(`paseo-plugin-helper: skipping unreadable settings field "${key}"`);
261
+ }
262
+ }
263
+ return fields;
264
+ }
265
+
266
+ export interface RegisterHelperSettingsScreenOptions {
267
+ ui: HelperSettingsUiBundle;
268
+ id?: string;
269
+ title?: string;
270
+ icon?: string;
271
+ labels?: Record<string, string>;
272
+ descriptions?: Record<string, string>;
273
+ }
274
+
275
+ function createSettingsScreenComponent<TSettings extends Record<string, any>>(
276
+ contract: SettingsContract<TSettings>,
277
+ ui: HelperSettingsUiBundle,
278
+ fields: HelperSettingsField[],
279
+ sectionTitle: string,
280
+ ): ComponentType<HostSurfaceProps> {
281
+ const Select = ui.SettingsSelect as unknown as ComponentType<
282
+ HelperSettingsSelectProps<string>
283
+ >;
284
+ return function HelperSettingsScreen(_props: HostSurfaceProps) {
285
+ const { settings, updateSettings } = usePluginSettings(contract);
286
+ return (
287
+ <ui.SettingsSection title={sectionTitle}>
288
+ <ui.SettingsCard>
289
+ {fields.map((field) => {
290
+ if (field.kind === "boolean") {
291
+ return (
292
+ <ui.SettingsSwitch
293
+ key={field.key}
294
+ label={field.label}
295
+ hint={field.description}
296
+ value={Boolean(settings[field.key])}
297
+ onValueChange={(value: boolean) =>
298
+ updateSettings({ [field.key]: value } as Partial<TSettings>)
299
+ }
300
+ />
301
+ );
302
+ }
303
+ if (field.kind === "enum") {
304
+ const selectOptions = (field.options ?? []).map((value) => ({ label: value, value }));
305
+ const current = String(settings[field.key] ?? field.options?.[0] ?? "");
306
+ return (
307
+ <Select
308
+ key={field.key}
309
+ label={field.label}
310
+ hint={field.description}
311
+ value={current}
312
+ options={selectOptions}
313
+ onValueChange={(value: string) =>
314
+ updateSettings({ [field.key]: value } as Partial<TSettings>)
315
+ }
316
+ />
317
+ );
318
+ }
319
+ const raw = settings[field.key];
320
+ const currentText = raw === undefined || raw === null ? "" : String(raw);
321
+ if (field.kind === "number") {
322
+ return (
323
+ <ui.SettingsInput
324
+ key={field.key}
325
+ label={field.label}
326
+ hint={field.description}
327
+ initialValue={currentText}
328
+ onChangeText={(text: string) => {
329
+ if (text.trim() === "") return;
330
+ const next = Number(text);
331
+ if (Number.isNaN(next)) return;
332
+ updateSettings({ [field.key]: next } as Partial<TSettings>);
333
+ }}
334
+ />
335
+ );
336
+ }
337
+ return (
338
+ <ui.SettingsInput
339
+ key={field.key}
340
+ label={field.label}
341
+ hint={field.description}
342
+ initialValue={currentText}
343
+ onChangeText={(text: string) =>
344
+ updateSettings({ [field.key]: text } as Partial<TSettings>)
345
+ }
346
+ />
347
+ );
348
+ })}
349
+ </ui.SettingsCard>
350
+ </ui.SettingsSection>
351
+ );
352
+ };
353
+ }
354
+
355
+ export function registerHelperSettingsScreen<TSettings extends Record<string, any>>(
356
+ client: HelperSettingsScreenRegistrar,
357
+ contract: SettingsContract<TSettings>,
358
+ options: RegisterHelperSettingsScreenOptions,
359
+ ): PluginCleanup {
360
+ const id = options.id ?? contract.name;
361
+ const rawDescription = (contract as { description?: unknown }).description;
362
+ const title =
363
+ options.title ??
364
+ (typeof rawDescription === "string" && rawDescription.length > 0 ? rawDescription : contract.name);
365
+ const icon = options.icon ?? "Settings";
366
+ const fields = contractSchemaToFields(contract.schema, {
367
+ labels: options.labels,
368
+ descriptions: options.descriptions,
369
+ });
370
+ const Component = createSettingsScreenComponent(contract, options.ui, fields, title);
371
+ return client.addSettingsScreen({ id, title, icon, Component });
372
+ }
@@ -0,0 +1,181 @@
1
+ import { getClientHost } from "./host";
2
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
3
+ import type { SettingsContract } from "../../../shared/vendor/paseo-plugin-helper/settings";
4
+
5
+ export interface UsePluginSettingsOptions<TSettings> {
6
+ /**
7
+ * Optional initial settings data. Defaults to `contract.defaultSettings`.
8
+ */
9
+ initialData?: TSettings;
10
+ /**
11
+ * Whether to automatically refetch settings when the window/app regains focus.
12
+ * Defaults to true.
13
+ */
14
+ refetchOnWindowFocus?: boolean;
15
+ /**
16
+ * Stale time in milliseconds before settings are considered stale.
17
+ * Defaults to 0 so that newly opened modals/components always verify fresh
18
+ * state against the daemon without waiting.
19
+ */
20
+ staleTime?: number;
21
+ /**
22
+ * Whether to refetch settings every time a component mounts.
23
+ * Defaults to "always".
24
+ */
25
+ refetchOnMount?: boolean | "always";
26
+ /**
27
+ * Optional background polling interval in milliseconds.
28
+ * When specified, keeps multi-window and mobile/desktop clients automatically in sync.
29
+ */
30
+ refetchInterval?: number | false;
31
+ /**
32
+ * Callback invoked after a successful update.
33
+ */
34
+ onSuccess?: (updated: TSettings) => void;
35
+ /**
36
+ * Callback invoked when an update fails.
37
+ */
38
+ onError?: (error: Error, rollbackSettings?: TSettings) => void;
39
+ }
40
+
41
+ export interface UsePluginSettingsResult<TSettings> {
42
+ /**
43
+ * Current settings object. Never undefined (falls back to initialData or contract.defaultSettings).
44
+ */
45
+ settings: TSettings;
46
+ /**
47
+ * Triggers an optimistic update and persists via RPC.
48
+ */
49
+ updateSettings: (updates: Partial<TSettings>) => void;
50
+ /**
51
+ * Async version of updateSettings that returns a promise of the updated settings.
52
+ */
53
+ updateSettingsAsync: (updates: Partial<TSettings>) => Promise<TSettings>;
54
+ /**
55
+ * Resets settings back to their default values.
56
+ */
57
+ resetSettings: () => Promise<TSettings>;
58
+ /**
59
+ * Whether the initial query is loading.
60
+ */
61
+ isLoading: boolean;
62
+ /**
63
+ * Whether an update mutation is currently in-flight.
64
+ */
65
+ isUpdating: boolean;
66
+ /**
67
+ * Whether the last query or mutation encountered an error.
68
+ */
69
+ isError: boolean;
70
+ /**
71
+ * Error object if any error occurred.
72
+ */
73
+ error: Error | null;
74
+ /**
75
+ * Refetches settings from the server.
76
+ */
77
+ refetch: () => Promise<unknown>;
78
+ }
79
+
80
+ /**
81
+ * Reactive hook for managing plugin settings with optimistic updates,
82
+ * error rollbacks, and automatic caching via React Query.
83
+ */
84
+ export function usePluginSettings<TSettings extends Record<string, any>>(
85
+ contract: SettingsContract<TSettings>,
86
+ options: UsePluginSettingsOptions<TSettings> = {},
87
+ ): UsePluginSettingsResult<TSettings> {
88
+ const { useRpc } = getClientHost();
89
+ const queryClient = useQueryClient();
90
+ const queryKey = ["plugin-settings", contract.name] as const;
91
+
92
+ const callGet = useRpc(contract.get);
93
+ const callUpdate = useRpc(contract.update);
94
+ const callReset = useRpc(contract.reset);
95
+
96
+ const query = useQuery({
97
+ queryKey,
98
+ queryFn: async () => {
99
+ const res = await callGet({} as any);
100
+ return res as TSettings;
101
+ },
102
+ // Use placeholderData so UI displays immediately without marking the cache as fresh forever
103
+ placeholderData: (options.initialData ?? contract.defaultSettings) as any,
104
+ // Ensure newly mounted components (e.g. Opening modal) immediately re-verify from daemon
105
+ staleTime: options.staleTime ?? 0,
106
+ refetchOnMount: options.refetchOnMount ?? "always",
107
+ refetchOnWindowFocus: options.refetchOnWindowFocus ?? true,
108
+ refetchInterval: options.refetchInterval,
109
+ });
110
+
111
+ const updateMutation = useMutation({
112
+ mutationFn: async (updates: Partial<TSettings>) => {
113
+ const res = await callUpdate(updates as any);
114
+ return res as TSettings;
115
+ },
116
+ onMutate: async (newUpdates: Partial<TSettings>) => {
117
+ await queryClient.cancelQueries({ queryKey });
118
+ const previousSettings =
119
+ queryClient.getQueryData<TSettings>(queryKey) ?? contract.defaultSettings;
120
+
121
+ const optimistic: TSettings = {
122
+ ...previousSettings,
123
+ ...newUpdates,
124
+ };
125
+
126
+ queryClient.setQueryData<TSettings>(queryKey, optimistic);
127
+ return { previousSettings };
128
+ },
129
+ onError: (err: unknown, _newUpdates, context) => {
130
+ if (context?.previousSettings) {
131
+ queryClient.setQueryData(queryKey, context.previousSettings);
132
+ }
133
+ const error = err instanceof Error ? err : new Error(String(err));
134
+ options.onError?.(error, context?.previousSettings);
135
+ },
136
+ onSuccess: (data: TSettings) => {
137
+ queryClient.setQueryData(queryKey, data);
138
+ options.onSuccess?.(data);
139
+ },
140
+ onSettled: () => {
141
+ queryClient.invalidateQueries({ queryKey });
142
+ },
143
+ });
144
+
145
+ const resetMutation = useMutation({
146
+ mutationFn: async () => {
147
+ const res = await callReset({} as any);
148
+ return res as TSettings;
149
+ },
150
+ onSuccess: (data: TSettings) => {
151
+ queryClient.setQueryData(queryKey, data);
152
+ options.onSuccess?.(data);
153
+ },
154
+ onSettled: () => {
155
+ queryClient.invalidateQueries({ queryKey });
156
+ },
157
+ });
158
+
159
+ const settings: TSettings = query.data ?? options.initialData ?? contract.defaultSettings;
160
+
161
+ return {
162
+ settings,
163
+ updateSettings: (updates: Partial<TSettings>) => {
164
+ updateMutation.mutate(updates);
165
+ },
166
+ updateSettingsAsync: (updates: Partial<TSettings>) => {
167
+ return updateMutation.mutateAsync(updates);
168
+ },
169
+ resetSettings: () => {
170
+ return resetMutation.mutateAsync();
171
+ },
172
+ isLoading: query.isLoading,
173
+ isUpdating: updateMutation.isPending || resetMutation.isPending,
174
+ isError: query.isError || updateMutation.isError || resetMutation.isError,
175
+ error:
176
+ (query.error as Error | null) ||
177
+ (updateMutation.error as Error | null) ||
178
+ (resetMutation.error as Error | null),
179
+ refetch: () => query.refetch(),
180
+ };
181
+ }