my-durable 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/ai_docs/durable_pi/announcement.md +860 -0
- package/ai_docs/durable_pi/changelog.md +51 -0
- package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
- package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
- package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
- package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
- package/ai_docs/durable_pi/readme.md +617 -0
- package/ai_docs/durable_pi/sources.md +22 -0
- package/ai_docs/durable_pi/spec.md +4696 -0
- package/ai_docs/durable_pi/vacation-planner.md +57 -0
- package/ai_docs/pi/chord.md +268 -0
- package/ai_docs/pi/cli-integration.md +110 -0
- package/ai_docs/pi/cli.md +325 -0
- package/ai_docs/pi/codemode.md +211 -0
- package/ai_docs/pi/compaction.md +467 -0
- package/ai_docs/pi/configuration.md +51 -0
- package/ai_docs/pi/containerization.md +187 -0
- package/ai_docs/pi/custom-provider.md +177 -0
- package/ai_docs/pi/environment-variables.md +102 -0
- package/ai_docs/pi/extensions.md +278 -0
- package/ai_docs/pi/how-pi-works.md +53 -0
- package/ai_docs/pi/index.md +43 -0
- package/ai_docs/pi/json.md +230 -0
- package/ai_docs/pi/keybindings.md +198 -0
- package/ai_docs/pi/llama-cpp.md +118 -0
- package/ai_docs/pi/mcp.md +274 -0
- package/ai_docs/pi/message-types.md +265 -0
- package/ai_docs/pi/models.md +209 -0
- package/ai_docs/pi/overview.md +166 -0
- package/ai_docs/pi/packages.md +133 -0
- package/ai_docs/pi/pi-agent-core.md +568 -0
- package/ai_docs/pi/pi-ai.md +1942 -0
- package/ai_docs/pi/prompt-templates.md +63 -0
- package/ai_docs/pi/providers.md +204 -0
- package/ai_docs/pi/quickstart.md +140 -0
- package/ai_docs/pi/readme.md +99 -0
- package/ai_docs/pi/rpc-commands.md +864 -0
- package/ai_docs/pi/rpc-extension-ui.md +204 -0
- package/ai_docs/pi/rpc.md +197 -0
- package/ai_docs/pi/sdk.md +151 -0
- package/ai_docs/pi/security.md +103 -0
- package/ai_docs/pi/session-format.md +297 -0
- package/ai_docs/pi/sessions.md +70 -0
- package/ai_docs/pi/settings.md +173 -0
- package/ai_docs/pi/shell-aliases.md +97 -0
- package/ai_docs/pi/skills.md +97 -0
- package/ai_docs/pi/slash-commands.md +64 -0
- package/ai_docs/pi/sources.md +57 -0
- package/ai_docs/pi/terminal-setup.md +223 -0
- package/ai_docs/pi/termux.md +121 -0
- package/ai_docs/pi/themes.md +140 -0
- package/ai_docs/pi/tmux.md +59 -0
- package/ai_docs/pi/toolkit.md +168 -0
- package/ai_docs/pi/tui.md +129 -0
- package/ai_docs/pi/usage.md +98 -0
- package/ai_docs/pi/virtual-models.md +118 -0
- package/ai_docs/pi/windows.md +71 -0
- package/apps/dpi-backend/.nvmrc +1 -0
- package/apps/dpi-backend/API.md +99 -0
- package/apps/dpi-backend/README.md +119 -0
- package/apps/dpi-backend/deploy/backend.env.example +9 -0
- package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
- package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
- package/apps/dpi-backend/package.json +33 -0
- package/apps/dpi-backend/prompts/one_sentence.md +3 -0
- package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
- package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
- package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
- package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
- package/apps/dpi-backend/scripts/token.ts +57 -0
- package/apps/dpi-backend/src/addons.ts +139 -0
- package/apps/dpi-backend/src/auth.ts +20 -0
- package/apps/dpi-backend/src/config.ts +54 -0
- package/apps/dpi-backend/src/contract-check.ts +118 -0
- package/apps/dpi-backend/src/errors.ts +50 -0
- package/apps/dpi-backend/src/extensions.ts +510 -0
- package/apps/dpi-backend/src/faux.ts +80 -0
- package/apps/dpi-backend/src/history.ts +165 -0
- package/apps/dpi-backend/src/idempotency.ts +20 -0
- package/apps/dpi-backend/src/jev.ts +43 -0
- package/apps/dpi-backend/src/jobs.ts +790 -0
- package/apps/dpi-backend/src/main.ts +76 -0
- package/apps/dpi-backend/src/model-catalog.ts +180 -0
- package/apps/dpi-backend/src/pairing-files.ts +142 -0
- package/apps/dpi-backend/src/pairing.ts +459 -0
- package/apps/dpi-backend/src/routes.ts +217 -0
- package/apps/dpi-backend/src/shared-state.ts +85 -0
- package/apps/dpi-backend/src/store.ts +1240 -0
- package/apps/dpi-backend/src/streams.ts +256 -0
- package/apps/dpi-backend/src/tunnel.ts +955 -0
- package/apps/dpi-backend/tsconfig.json +13 -0
- package/apps/dpi-playground/README.md +83 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
- package/apps/dpi-playground/package.json +37 -0
- package/apps/dpi-sdk/README.md +74 -0
- package/apps/dpi-sdk/package.json +20 -0
- package/apps/dpi-sdk/scripts/net.ts +73 -0
- package/apps/dpi-sdk/src/api.ts +631 -0
- package/apps/dpi-sdk/src/app-state.ts +111 -0
- package/apps/dpi-sdk/src/client.ts +330 -0
- package/apps/dpi-sdk/src/errors.ts +16 -0
- package/apps/dpi-sdk/src/jobs.ts +99 -0
- package/apps/dpi-sdk/src/models.ts +472 -0
- package/apps/dpi-sdk/src/node/chunk.ts +488 -0
- package/apps/dpi-sdk/src/node/config.ts +175 -0
- package/apps/dpi-sdk/src/node/drivers.ts +402 -0
- package/apps/dpi-sdk/src/node/ops.ts +114 -0
- package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
- package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
- package/apps/dpi-sdk/src/node/registry.ts +390 -0
- package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
- package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
- package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
- package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
- package/apps/dpi-sdk/src/sse.ts +117 -0
- package/apps/dpi-sdk/src/transcript.ts +291 -0
- package/apps/dpi-sdk/tsconfig.json +13 -0
- package/apps/dpi-sdk/w +20 -0
- package/apps/dpi-tui/README.md +54 -0
- package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
- package/apps/dpi-tui/package.json +20 -0
- package/apps/dpi-tui/src/app.ts +433 -0
- package/apps/dpi-tui/src/commands.ts +151 -0
- package/apps/dpi-tui/src/main.ts +122 -0
- package/apps/dpi-tui/src/pairing.ts +287 -0
- package/apps/dpi-tui/src/pickers.ts +238 -0
- package/apps/dpi-tui/src/render.ts +164 -0
- package/apps/dpi-tui/src/theme.ts +57 -0
- package/apps/dpi-tui/tsconfig.json +14 -0
- package/bin/device.mjs +5 -0
- package/bin/hub.mjs +5 -0
- package/bin/tui.mjs +5 -0
- package/deploy/Caddyfile +120 -0
- package/docs/verification.md +102 -0
- package/dpi-network.example.yaml +62 -0
- package/images/01_durable_agent_network.svg +185 -0
- package/images/02_pi_made_durable.svg +204 -0
- package/images/03_restart_mid_tool.svg +168 -0
- package/images/07_one_api_every_client.svg +123 -0
- package/images/08_device_session_agent.svg +123 -0
- package/images/09_compact_keep_history.svg +143 -0
- package/images/10_shared_app_state.svg +177 -0
- package/images/11_durable_jobs_wait.svg +141 -0
- package/images/12_tools_change_mid_run.svg +231 -0
- package/images/13_hook_blocks_tool.svg +122 -0
- package/images/14_fork_at_any_entry.svg +119 -0
- package/images/15_web_client.png +0 -0
- package/images/16_terminal_client.svg +43 -0
- package/images/17_devices_are_data.svg +182 -0
- package/images/18_should_every_agent_be_durable.svg +118 -0
- package/images/19_sqlite_on_every_device.svg +155 -0
- package/images/20_admission_and_effects.svg +134 -0
- package/justfile +33 -0
- package/package.json +41 -4
- package/scripts/verify.mjs +210 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* P2-24 — PairDeviceWizard: create code → device proof → FULL fingerprint
|
|
4
|
+
* approval → issued → (monitor probe) online.
|
|
5
|
+
*
|
|
6
|
+
* Security contract (P-8/P-14, plan §P2-24):
|
|
7
|
+
* - Approve is enabled ONLY after proofVerified AND the operator ticks the
|
|
8
|
+
* explicit confirmation; approval always sends the hub-computed fingerprint
|
|
9
|
+
* from the codeState DTO — never the device-declared one.
|
|
10
|
+
* - Name/UID/code alone can never enable approval.
|
|
11
|
+
* - Key change (re-pair with a different key) requires replaceKey=true and a
|
|
12
|
+
* separate warning; 409 already_issued leads to an explicit Revoke offer,
|
|
13
|
+
* never a "cancelled" toast.
|
|
14
|
+
* - The code is RAM-only: closing/cancelling the wizard cancels the code and
|
|
15
|
+
* clears every secret-adjacent field.
|
|
16
|
+
* - /pair handoff: reads ?code/&origin once, replaces the URL (no query left),
|
|
17
|
+
* pre-fills — never auto-approves.
|
|
18
|
+
*/
|
|
19
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
20
|
+
import { CheckCircle2, Copy, RefreshCw, ShieldAlert, X } from "lucide-vue-next";
|
|
21
|
+
import { createPairingSession, pairingAdmin, pollCode, type PairingSession } from "../client/pair";
|
|
22
|
+
import { qrSvg } from "../client/qrcode";
|
|
23
|
+
import type { PairCodeIssue, PairCodeState } from "@sdk/api";
|
|
24
|
+
import { DpiError } from "@sdk/errors";
|
|
25
|
+
import { settledPairPath, type PairHandoff } from "../client/pair-handoff";
|
|
26
|
+
import { CANCEL_TIMEOUT_MS, closeWizard, type CancelResult } from "../client/pair-close";
|
|
27
|
+
import { notify, network } from "../client/store";
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits<{ close: [] }>();
|
|
30
|
+
const props = defineProps<{ handoff?: PairHandoff }>();
|
|
31
|
+
|
|
32
|
+
type Step = "boot" | "code" | "proof" | "approve" | "issued" | "cancelled" | "expired";
|
|
33
|
+
|
|
34
|
+
const step = ref<Step>("boot");
|
|
35
|
+
const session = ref<PairingSession>();
|
|
36
|
+
const issue = ref<PairCodeIssue>();
|
|
37
|
+
const state = ref<PairCodeState>();
|
|
38
|
+
const error = ref("");
|
|
39
|
+
const busy = ref(false);
|
|
40
|
+
const closing = ref(false);
|
|
41
|
+
const confirmed = ref(false);
|
|
42
|
+
const replaceKey = ref(false);
|
|
43
|
+
const copied = ref(false);
|
|
44
|
+
const watcher = ref<(() => void) | undefined>();
|
|
45
|
+
|
|
46
|
+
const grouped = computed(() => {
|
|
47
|
+
const code = issue.value?.code ?? "";
|
|
48
|
+
return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
|
|
49
|
+
});
|
|
50
|
+
const qrData = computed(() => {
|
|
51
|
+
if (!issue.value) return "";
|
|
52
|
+
return issue.value.pairUrl;
|
|
53
|
+
});
|
|
54
|
+
const qr = computed(() => (qrData.value ? qrSvg(qrData.value, 148) : ""));
|
|
55
|
+
const fingerprint = computed(() => state.value?.enrollment?.keyFingerprint ?? "");
|
|
56
|
+
const deviceId = computed(() => state.value?.deviceId ?? "");
|
|
57
|
+
|
|
58
|
+
/** Online toast fires only after the MONITOR probe succeeds — never on WSS alone. */
|
|
59
|
+
watch(deviceId, (id) => {
|
|
60
|
+
watcher.value?.();
|
|
61
|
+
if (!id) return;
|
|
62
|
+
watcher.value = watch(
|
|
63
|
+
() => network.devices.value.find((d) => d.id === id)?.status,
|
|
64
|
+
(status) => {
|
|
65
|
+
if (status === "online") {
|
|
66
|
+
notify("Device paired and online.");
|
|
67
|
+
// F12: the STOP HANDLE is what watch() RETURNS — the callback's third
|
|
68
|
+
// argument is the onCleanup REGISTRAR (needs a function argument).
|
|
69
|
+
watcher.value?.();
|
|
70
|
+
watcher.value = undefined;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
{ immediate: true },
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
async function createCode(handoffCode?: string): Promise<void> {
|
|
78
|
+
step.value = "boot";
|
|
79
|
+
error.value = "";
|
|
80
|
+
confirmed.value = false;
|
|
81
|
+
replaceKey.value = false;
|
|
82
|
+
state.value = undefined;
|
|
83
|
+
issue.value = undefined;
|
|
84
|
+
session.value?.close();
|
|
85
|
+
const s = createPairingSession();
|
|
86
|
+
session.value = s;
|
|
87
|
+
try {
|
|
88
|
+
const created = handoffCode
|
|
89
|
+
? await pairingAdmin.resolveCode(handoffCode)
|
|
90
|
+
: await pairingAdmin.createCode();
|
|
91
|
+
if (s.closed()) {
|
|
92
|
+
await pairingAdmin.cancel(created.codeId).catch(() => {
|
|
93
|
+
notify(
|
|
94
|
+
"The late pairing code could not be cancelled. Check device credentials before retrying.",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
issue.value = created;
|
|
100
|
+
step.value = "code";
|
|
101
|
+
if (handoffCode) {
|
|
102
|
+
const path = settledPairPath(new URL(window.location.href));
|
|
103
|
+
if (path) window.history.replaceState(window.history.state, "", path);
|
|
104
|
+
}
|
|
105
|
+
void pollCode(s, created.codeId, {
|
|
106
|
+
intervalMs: 1_000,
|
|
107
|
+
onState: (st) => {
|
|
108
|
+
state.value = st;
|
|
109
|
+
if (st.phase === "awaiting_proof") step.value = "proof";
|
|
110
|
+
else if (st.phase === "awaiting_approval" || st.phase === "approved")
|
|
111
|
+
step.value = "approve";
|
|
112
|
+
else if (st.phase === "issued" || st.phase === "connected") step.value = "issued";
|
|
113
|
+
else if (st.phase === "cancelled") step.value = "cancelled";
|
|
114
|
+
else if (st.phase === "expired") step.value = "expired";
|
|
115
|
+
},
|
|
116
|
+
}).catch((e) => {
|
|
117
|
+
if (s.closed()) return;
|
|
118
|
+
error.value = e instanceof Error ? e.message : String(e);
|
|
119
|
+
step.value = "expired";
|
|
120
|
+
});
|
|
121
|
+
} catch (e) {
|
|
122
|
+
error.value = e instanceof Error ? e.message : String(e);
|
|
123
|
+
step.value = "expired";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function approve(): Promise<void> {
|
|
128
|
+
const st = state.value;
|
|
129
|
+
const s = session.value;
|
|
130
|
+
if (!st?.enrollment || !s || busy.value) return;
|
|
131
|
+
// P-8: the tuple must be exactly what the hub verified; P-14: key change
|
|
132
|
+
// needs the explicit replaceKey confirmation.
|
|
133
|
+
busy.value = true;
|
|
134
|
+
error.value = "";
|
|
135
|
+
try {
|
|
136
|
+
const next = await pairingAdmin.approve(issue.value!.codeId, {
|
|
137
|
+
enrollmentId: st.enrollment.enrollmentId,
|
|
138
|
+
deviceUid: st.enrollment.deviceUid,
|
|
139
|
+
keyFingerprint: st.enrollment.keyFingerprint,
|
|
140
|
+
...(replaceKey.value ? { replaceKey: true } : {}),
|
|
141
|
+
});
|
|
142
|
+
state.value = next;
|
|
143
|
+
if (next.phase === "issued" || next.phase === "connected") step.value = "issued";
|
|
144
|
+
} catch (e) {
|
|
145
|
+
error.value = e instanceof Error ? e.message : String(e);
|
|
146
|
+
} finally {
|
|
147
|
+
busy.value = false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Cancel before issuance; 409 already_issued → explicit Revoke path. */
|
|
152
|
+
async function cancelLive(keepalive: boolean): Promise<CancelResult> {
|
|
153
|
+
if (!issue.value) return "cancelled";
|
|
154
|
+
try {
|
|
155
|
+
const next = await pairingAdmin.cancel(issue.value.codeId, undefined, keepalive);
|
|
156
|
+
state.value = next;
|
|
157
|
+
if (session.value) session.value.state = next;
|
|
158
|
+
step.value = next.phase === "expired" ? "expired" : "cancelled";
|
|
159
|
+
session.value?.close();
|
|
160
|
+
return "cancelled";
|
|
161
|
+
} catch (e) {
|
|
162
|
+
if (e instanceof DpiError && e.status === 409 && String(e.code) === "already_issued") {
|
|
163
|
+
const latest = await pairingAdmin.codeState(issue.value.codeId).catch(() => undefined);
|
|
164
|
+
if (latest) {
|
|
165
|
+
state.value = latest;
|
|
166
|
+
if (session.value) session.value.state = latest;
|
|
167
|
+
}
|
|
168
|
+
step.value = "issued";
|
|
169
|
+
error.value =
|
|
170
|
+
"Credential already issued. Closing does not revoke it; choose Revoke explicitly if needed.";
|
|
171
|
+
return "issued";
|
|
172
|
+
}
|
|
173
|
+
error.value = e instanceof Error ? e.message : String(e);
|
|
174
|
+
return "failed";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const cancelCode = () => cancelLive(false);
|
|
178
|
+
|
|
179
|
+
const settled = () =>
|
|
180
|
+
["issued", "connected", "cancelled", "expired"].includes(state.value?.phase ?? "");
|
|
181
|
+
|
|
182
|
+
async function revokeIssued(): Promise<void> {
|
|
183
|
+
const id = deviceId.value;
|
|
184
|
+
if (!id) return;
|
|
185
|
+
busy.value = true;
|
|
186
|
+
try {
|
|
187
|
+
await pairingAdmin.revoke(id, "user");
|
|
188
|
+
notify("Issued credential revoked.");
|
|
189
|
+
emit("close");
|
|
190
|
+
} catch (e) {
|
|
191
|
+
error.value = e instanceof Error ? e.message : String(e);
|
|
192
|
+
} finally {
|
|
193
|
+
busy.value = false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function copyCode(): Promise<void> {
|
|
198
|
+
if (!issue.value) return;
|
|
199
|
+
try {
|
|
200
|
+
await navigator.clipboard.writeText(issue.value.code); // canonical, ungrouped
|
|
201
|
+
copied.value = true;
|
|
202
|
+
setTimeout(() => (copied.value = false), 1500);
|
|
203
|
+
} catch {
|
|
204
|
+
error.value = "Clipboard unavailable; copy the code manually.";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function close(): Promise<void> {
|
|
209
|
+
if (closing.value) return;
|
|
210
|
+
closing.value = true;
|
|
211
|
+
busy.value = true;
|
|
212
|
+
try {
|
|
213
|
+
const outcome = await closeWizard({
|
|
214
|
+
needsCancel: !!issue.value && !settled(),
|
|
215
|
+
cancel: cancelCode,
|
|
216
|
+
timeoutMs: CANCEL_TIMEOUT_MS,
|
|
217
|
+
});
|
|
218
|
+
if (!outcome.closed) return;
|
|
219
|
+
session.value?.close();
|
|
220
|
+
watcher.value?.();
|
|
221
|
+
if (!outcome.confirmed)
|
|
222
|
+
notify(
|
|
223
|
+
"Pairing cancellation was not confirmed. The code expires on its own; check device credentials before retrying.",
|
|
224
|
+
);
|
|
225
|
+
emit("close");
|
|
226
|
+
} finally {
|
|
227
|
+
closing.value = false;
|
|
228
|
+
busy.value = false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
defineExpose({ close });
|
|
233
|
+
|
|
234
|
+
onMounted(() => {
|
|
235
|
+
if (props.handoff && "error" in props.handoff) {
|
|
236
|
+
error.value = props.handoff.error;
|
|
237
|
+
step.value = "expired";
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
void createCode(props.handoff?.code);
|
|
241
|
+
});
|
|
242
|
+
onBeforeUnmount(() => {
|
|
243
|
+
const s = session.value;
|
|
244
|
+
if (s && !s.closed() && issue.value && !settled()) {
|
|
245
|
+
void cancelLive(true).then((result) => {
|
|
246
|
+
if (result !== "cancelled")
|
|
247
|
+
notify("Pairing cancellation was not confirmed. Check device credentials before retrying.");
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
s?.close();
|
|
251
|
+
watcher.value?.();
|
|
252
|
+
});
|
|
253
|
+
</script>
|
|
254
|
+
|
|
255
|
+
<template>
|
|
256
|
+
<div class="modal-backdrop" @click.self="close">
|
|
257
|
+
<div class="modal pair-wizard" role="dialog" aria-modal="true" aria-labelledby="pair-title">
|
|
258
|
+
<header>
|
|
259
|
+
<h2 id="pair-title">Pair a device</h2>
|
|
260
|
+
<button type="button" class="icon-button" aria-label="Close dialog" @click="close">
|
|
261
|
+
<X :size="18" />
|
|
262
|
+
</button>
|
|
263
|
+
</header>
|
|
264
|
+
|
|
265
|
+
<p v-if="step === 'boot'" class="hint">Minting a pairing code…</p>
|
|
266
|
+
|
|
267
|
+
<template v-else-if="issue">
|
|
268
|
+
<ol class="pair-steps" aria-label="Pairing progress">
|
|
269
|
+
<li :class="{ on: true }">Code</li>
|
|
270
|
+
<li :class="{ on: ['proof', 'approve', 'issued'].includes(step) }">Proof</li>
|
|
271
|
+
<li :class="{ on: ['approve', 'issued'].includes(step) }">Fingerprint</li>
|
|
272
|
+
<li :class="{ on: step === 'issued' }">Online</li>
|
|
273
|
+
</ol>
|
|
274
|
+
|
|
275
|
+
<div class="pair-grid">
|
|
276
|
+
<div class="pair-code-box">
|
|
277
|
+
<div class="pair-code" aria-label="Pairing code">{{ grouped }}</div>
|
|
278
|
+
<button type="button" class="small" @click="copyCode">
|
|
279
|
+
<Copy :size="14" /> {{ copied ? "Copied" : "Copy code" }}
|
|
280
|
+
</button>
|
|
281
|
+
<p class="hint">
|
|
282
|
+
Run the device pairing command and scan, or enter this code on the device. Expires in
|
|
283
|
+
5 minutes.
|
|
284
|
+
</p>
|
|
285
|
+
<div class="pair-qr" aria-label="Pairing QR code" v-html="qr" />
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<div class="pair-side">
|
|
289
|
+
<template v-if="step === 'code' || step === 'proof'">
|
|
290
|
+
<p class="hint">
|
|
291
|
+
<RefreshCw :size="14" class="spin" /> Waiting for the device to submit its signing
|
|
292
|
+
proof…
|
|
293
|
+
</p>
|
|
294
|
+
</template>
|
|
295
|
+
|
|
296
|
+
<template v-else-if="step === 'approve'">
|
|
297
|
+
<div class="pair-fingerprint">
|
|
298
|
+
<h3>Device key fingerprint</h3>
|
|
299
|
+
<code class="fp">{{ fingerprint }}</code>
|
|
300
|
+
<p class="hint">
|
|
301
|
+
Compare ALL 64 hex characters with the fingerprint shown on the device screen.
|
|
302
|
+
</p>
|
|
303
|
+
</div>
|
|
304
|
+
<label class="pair-confirm">
|
|
305
|
+
<input v-model="confirmed" type="checkbox" />
|
|
306
|
+
The fingerprint matches the device display exactly
|
|
307
|
+
</label>
|
|
308
|
+
<label v-if="state?.enrollment?.keyChanged" class="pair-confirm warn">
|
|
309
|
+
<input v-model="replaceKey" type="checkbox" />
|
|
310
|
+
<ShieldAlert :size="14" />
|
|
311
|
+
Device is already paired with a DIFFERENT key — replace the stored key (replaceKey)
|
|
312
|
+
</label>
|
|
313
|
+
<button
|
|
314
|
+
type="button"
|
|
315
|
+
class="primary"
|
|
316
|
+
:disabled="
|
|
317
|
+
!confirmed || busy || (state?.enrollment?.keyChanged === true && !replaceKey)
|
|
318
|
+
"
|
|
319
|
+
@click="approve"
|
|
320
|
+
>
|
|
321
|
+
Approve device
|
|
322
|
+
</button>
|
|
323
|
+
<button type="button" class="small" :disabled="busy" @click="cancelCode">
|
|
324
|
+
Cancel pairing
|
|
325
|
+
</button>
|
|
326
|
+
</template>
|
|
327
|
+
|
|
328
|
+
<template v-else-if="step === 'issued'">
|
|
329
|
+
<p class="hint">
|
|
330
|
+
<CheckCircle2 :size="14" /> Credential issued. The device is connecting through the
|
|
331
|
+
tunnel — you will get a toast once the monitor probe reports it online.
|
|
332
|
+
</p>
|
|
333
|
+
<button type="button" class="small" @click="close">Close</button>
|
|
334
|
+
<button
|
|
335
|
+
type="button"
|
|
336
|
+
class="small danger"
|
|
337
|
+
:disabled="!deviceId || busy"
|
|
338
|
+
title="Mistaken approval? Revoke the credential now."
|
|
339
|
+
@click="revokeIssued"
|
|
340
|
+
>
|
|
341
|
+
Revoke this credential
|
|
342
|
+
</button>
|
|
343
|
+
</template>
|
|
344
|
+
|
|
345
|
+
<template v-else-if="step === 'cancelled'">
|
|
346
|
+
<p class="hint">Pairing was cancelled (or the signature failed too many times).</p>
|
|
347
|
+
<button type="button" class="small" @click="createCode()">Start over</button>
|
|
348
|
+
</template>
|
|
349
|
+
|
|
350
|
+
<template v-else-if="step === 'expired'">
|
|
351
|
+
<p class="hint">The pairing code expired.</p>
|
|
352
|
+
<button type="button" class="small" @click="createCode()">New code</button>
|
|
353
|
+
</template>
|
|
354
|
+
|
|
355
|
+
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
</template>
|
|
359
|
+
<div v-else>
|
|
360
|
+
<p class="error" role="alert">{{ error || "This pairing link is unavailable." }}</p>
|
|
361
|
+
<button type="button" class="small" @click="createCode()">New code</button>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
</template>
|
|
366
|
+
|
|
367
|
+
<style>
|
|
368
|
+
.pair-wizard .pair-steps {
|
|
369
|
+
display: flex;
|
|
370
|
+
gap: 0.5rem;
|
|
371
|
+
list-style: none;
|
|
372
|
+
padding: 0;
|
|
373
|
+
margin: 0.25rem 0 0.75rem;
|
|
374
|
+
font-size: 0.78rem;
|
|
375
|
+
color: var(--muted, #888);
|
|
376
|
+
}
|
|
377
|
+
.pair-wizard .pair-steps li {
|
|
378
|
+
padding: 0.15rem 0.6rem;
|
|
379
|
+
border: 1px solid transparent;
|
|
380
|
+
border-radius: 999px;
|
|
381
|
+
}
|
|
382
|
+
.pair-wizard .pair-steps li.on {
|
|
383
|
+
color: var(--fg, #eee);
|
|
384
|
+
border-color: currentColor;
|
|
385
|
+
}
|
|
386
|
+
.pair-wizard .pair-grid {
|
|
387
|
+
display: grid;
|
|
388
|
+
grid-template-columns: auto 1fr;
|
|
389
|
+
gap: 1rem;
|
|
390
|
+
align-items: start;
|
|
391
|
+
}
|
|
392
|
+
.pair-wizard .pair-code {
|
|
393
|
+
font-size: 1.7rem;
|
|
394
|
+
letter-spacing: 0.18em;
|
|
395
|
+
font-family: var(--mono, monospace);
|
|
396
|
+
margin-bottom: 0.35rem;
|
|
397
|
+
}
|
|
398
|
+
.pair-wizard .pair-qr svg {
|
|
399
|
+
border: 1px solid var(--border, #333);
|
|
400
|
+
border-radius: 6px;
|
|
401
|
+
}
|
|
402
|
+
.pair-wizard .pair-side {
|
|
403
|
+
display: flex;
|
|
404
|
+
flex-direction: column;
|
|
405
|
+
gap: 0.6rem;
|
|
406
|
+
min-width: 0;
|
|
407
|
+
}
|
|
408
|
+
.pair-wizard .fp {
|
|
409
|
+
display: block;
|
|
410
|
+
word-break: break-all;
|
|
411
|
+
font-size: 0.78rem;
|
|
412
|
+
background: var(--bg2, #222);
|
|
413
|
+
padding: 0.4rem;
|
|
414
|
+
border-radius: 6px;
|
|
415
|
+
}
|
|
416
|
+
.pair-wizard .pair-confirm {
|
|
417
|
+
display: flex;
|
|
418
|
+
align-items: center;
|
|
419
|
+
gap: 0.45rem;
|
|
420
|
+
font-size: 0.85rem;
|
|
421
|
+
}
|
|
422
|
+
.pair-wizard .pair-confirm.warn {
|
|
423
|
+
color: var(--warn, #e0b040);
|
|
424
|
+
}
|
|
425
|
+
.pair-wizard .spin {
|
|
426
|
+
animation: pair-spin 1.2s linear infinite;
|
|
427
|
+
}
|
|
428
|
+
@keyframes pair-spin {
|
|
429
|
+
to {
|
|
430
|
+
transform: rotate(360deg);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
</style>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { nextTick, onMounted, ref } from "vue";
|
|
3
|
+
import { X } from "lucide-vue-next";
|
|
4
|
+
import { message } from "../client/hub";
|
|
5
|
+
import type { Dialog } from "../client/store";
|
|
6
|
+
|
|
7
|
+
// One dialog for every name/note/confirm prompt: rename, fork, reset, archive, remove.
|
|
8
|
+
const props = defineProps<{ dialog: Extract<Dialog, { kind: "prompt" }> }>();
|
|
9
|
+
const emit = defineEmits<{ close: [] }>();
|
|
10
|
+
const value = ref(props.dialog.value ?? "");
|
|
11
|
+
const busy = ref(false);
|
|
12
|
+
const error = ref("");
|
|
13
|
+
const input = ref<HTMLInputElement | HTMLTextAreaElement>();
|
|
14
|
+
const hasInput = !!props.dialog.label;
|
|
15
|
+
|
|
16
|
+
async function submit() {
|
|
17
|
+
if (busy.value) return;
|
|
18
|
+
if (hasInput && !props.dialog.optional && !value.value.trim()) return;
|
|
19
|
+
busy.value = true;
|
|
20
|
+
error.value = "";
|
|
21
|
+
try {
|
|
22
|
+
await props.dialog.run(value.value.trim());
|
|
23
|
+
emit("close");
|
|
24
|
+
} catch (e) {
|
|
25
|
+
error.value = message(e);
|
|
26
|
+
} finally {
|
|
27
|
+
busy.value = false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
onMounted(() => void nextTick(() => (input.value?.focus(), input.value?.select())));
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div class="modal-backdrop" @click.self="!busy && emit('close')">
|
|
35
|
+
<form
|
|
36
|
+
class="modal"
|
|
37
|
+
role="dialog"
|
|
38
|
+
aria-modal="true"
|
|
39
|
+
aria-labelledby="prompt-title"
|
|
40
|
+
@submit.prevent="submit"
|
|
41
|
+
>
|
|
42
|
+
<header>
|
|
43
|
+
<h2 id="prompt-title">{{ dialog.title }}</h2>
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
class="icon-button"
|
|
47
|
+
aria-label="Close dialog"
|
|
48
|
+
:disabled="busy"
|
|
49
|
+
@click="emit('close')"
|
|
50
|
+
>
|
|
51
|
+
<X :size="18" />
|
|
52
|
+
</button>
|
|
53
|
+
</header>
|
|
54
|
+
<p v-if="dialog.hint" class="hint">{{ dialog.hint }}</p>
|
|
55
|
+
<label v-if="hasInput"
|
|
56
|
+
>{{ dialog.label }}
|
|
57
|
+
<span v-if="dialog.optional" class="muted">(optional)</span>
|
|
58
|
+
<textarea
|
|
59
|
+
v-if="dialog.multiline"
|
|
60
|
+
ref="input"
|
|
61
|
+
v-model="value"
|
|
62
|
+
rows="4"
|
|
63
|
+
:placeholder="dialog.placeholder"
|
|
64
|
+
:disabled="busy" /><input
|
|
65
|
+
v-else
|
|
66
|
+
ref="input"
|
|
67
|
+
v-model="value"
|
|
68
|
+
maxlength="100"
|
|
69
|
+
:required="!dialog.optional"
|
|
70
|
+
:placeholder="dialog.placeholder"
|
|
71
|
+
:aria-label="dialog.label"
|
|
72
|
+
:disabled="busy"
|
|
73
|
+
/></label>
|
|
74
|
+
<p v-if="error" class="message-error" role="alert">{{ error }}</p>
|
|
75
|
+
<footer>
|
|
76
|
+
<button type="button" class="secondary" :disabled="busy" @click="emit('close')">
|
|
77
|
+
Cancel
|
|
78
|
+
</button>
|
|
79
|
+
<button
|
|
80
|
+
class="primary"
|
|
81
|
+
:class="{ danger: dialog.danger }"
|
|
82
|
+
:disabled="busy || (hasInput && !dialog.optional && !value.trim())"
|
|
83
|
+
>
|
|
84
|
+
{{ busy ? "Working…" : dialog.submit }}
|
|
85
|
+
</button>
|
|
86
|
+
</footer>
|
|
87
|
+
</form>
|
|
88
|
+
</div>
|
|
89
|
+
</template>
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from "vue";
|
|
3
|
+
import { Plus, Minus, Trash2 } from "lucide-vue-next";
|
|
4
|
+
import { statePath, type JsonValue, type StateChange } from "@sdk/app-state";
|
|
5
|
+
|
|
6
|
+
const props = withDefaults(
|
|
7
|
+
defineProps<{ value: JsonValue; path?: string; label?: string; disabled?: boolean }>(),
|
|
8
|
+
{ path: "", label: "App State" },
|
|
9
|
+
);
|
|
10
|
+
const emit = defineEmits<{ change: [change: StateChange] }>();
|
|
11
|
+
const kind = computed(() =>
|
|
12
|
+
props.value === null ? "null" : Array.isArray(props.value) ? "array" : typeof props.value,
|
|
13
|
+
);
|
|
14
|
+
const container = computed(() => kind.value === "object" || kind.value === "array");
|
|
15
|
+
const children = computed(() =>
|
|
16
|
+
container.value ? Object.entries(props.value as Record<string, JsonValue>) : [],
|
|
17
|
+
);
|
|
18
|
+
const draft = ref("");
|
|
19
|
+
const editing = ref(false);
|
|
20
|
+
const key = ref("");
|
|
21
|
+
const newType = ref("string");
|
|
22
|
+
const adding = ref(false);
|
|
23
|
+
const types = ["string", "number", "boolean", "object", "array", "null"];
|
|
24
|
+
const defaults: Record<string, () => JsonValue> = {
|
|
25
|
+
string: () => "",
|
|
26
|
+
number: () => 0,
|
|
27
|
+
boolean: () => false,
|
|
28
|
+
object: () => ({}),
|
|
29
|
+
array: () => [],
|
|
30
|
+
null: () => null,
|
|
31
|
+
};
|
|
32
|
+
watch(
|
|
33
|
+
() => props.value,
|
|
34
|
+
(value) => {
|
|
35
|
+
if (!editing.value) draft.value = String(value ?? "");
|
|
36
|
+
},
|
|
37
|
+
{ immediate: true },
|
|
38
|
+
);
|
|
39
|
+
function change(operation: StateChange["operation"], value?: JsonValue) {
|
|
40
|
+
emit("change", { operation, path: props.path, value });
|
|
41
|
+
}
|
|
42
|
+
function save() {
|
|
43
|
+
editing.value = false;
|
|
44
|
+
const value = kind.value === "number" ? Number(draft.value) : draft.value;
|
|
45
|
+
if (kind.value === "number" && (!String(draft.value).trim() || !Number.isFinite(value))) {
|
|
46
|
+
draft.value = String(props.value);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (value !== props.value) change("update", value);
|
|
50
|
+
}
|
|
51
|
+
function add() {
|
|
52
|
+
const name = kind.value === "array" ? "-" : key.value.trim();
|
|
53
|
+
if (!name) return;
|
|
54
|
+
emit("change", {
|
|
55
|
+
operation: "create",
|
|
56
|
+
path: statePath(props.path, name),
|
|
57
|
+
value: defaults[newType.value]!(),
|
|
58
|
+
});
|
|
59
|
+
key.value = "";
|
|
60
|
+
adding.value = false;
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<div class="state-field" :class="{ 'state-root': path === '' }" :data-state-path="path">
|
|
66
|
+
<div v-if="path !== ''" class="state-field-heading">
|
|
67
|
+
<strong>{{ label }}</strong>
|
|
68
|
+
<select
|
|
69
|
+
:value="kind"
|
|
70
|
+
:aria-label="`Type of ${path}`"
|
|
71
|
+
:disabled="disabled"
|
|
72
|
+
@change="change('update', defaults[($event.target as HTMLSelectElement).value]!())"
|
|
73
|
+
>
|
|
74
|
+
<option v-for="type in types" :key="type" :value="type">{{ type }}</option>
|
|
75
|
+
</select>
|
|
76
|
+
<button
|
|
77
|
+
class="icon-button"
|
|
78
|
+
:aria-label="`Delete ${path}`"
|
|
79
|
+
title="Delete field"
|
|
80
|
+
:disabled="disabled"
|
|
81
|
+
@click="change('delete')"
|
|
82
|
+
>
|
|
83
|
+
<Trash2 :size="18" />
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
<template v-if="container">
|
|
87
|
+
<StateEditor
|
|
88
|
+
v-for="[childKey, child] in children"
|
|
89
|
+
:key="childKey"
|
|
90
|
+
:value="child"
|
|
91
|
+
:path="statePath(path, childKey)"
|
|
92
|
+
:label="childKey"
|
|
93
|
+
:disabled="disabled"
|
|
94
|
+
@change="emit('change', $event)"
|
|
95
|
+
/>
|
|
96
|
+
<form v-if="adding && kind === 'array'" class="state-add" @submit.prevent="add">
|
|
97
|
+
<input
|
|
98
|
+
v-if="kind !== 'array'"
|
|
99
|
+
v-model="key"
|
|
100
|
+
placeholder="Field name"
|
|
101
|
+
:aria-label="`New field name in ${path || '/'}`"
|
|
102
|
+
required
|
|
103
|
+
:disabled="disabled"
|
|
104
|
+
/>
|
|
105
|
+
<select
|
|
106
|
+
v-model="newType"
|
|
107
|
+
:aria-label="`New field type in ${path || '/'}`"
|
|
108
|
+
:disabled="disabled"
|
|
109
|
+
>
|
|
110
|
+
<option v-for="type in types" :key="type" :value="type">{{ type }}</option>
|
|
111
|
+
</select>
|
|
112
|
+
<button class="secondary small" :disabled="disabled || (kind !== 'array' && !key.trim())">
|
|
113
|
+
Add
|
|
114
|
+
</button>
|
|
115
|
+
<button type="button" class="text-button" @click="adding = false">Cancel</button>
|
|
116
|
+
</form>
|
|
117
|
+
<button
|
|
118
|
+
v-else-if="kind === 'array'"
|
|
119
|
+
class="text-button state-add-button"
|
|
120
|
+
:disabled="disabled"
|
|
121
|
+
:aria-label="`Add ${kind === 'array' ? 'item' : 'field'} to ${path || '/'}`"
|
|
122
|
+
@click="adding = true"
|
|
123
|
+
>
|
|
124
|
+
<Plus :size="18" />{{ kind === "array" ? "Add item" : "Add field" }}
|
|
125
|
+
</button>
|
|
126
|
+
</template>
|
|
127
|
+
<div v-else-if="kind === 'number'" class="state-number">
|
|
128
|
+
<button
|
|
129
|
+
class="secondary"
|
|
130
|
+
:aria-label="`Decrement ${path}`"
|
|
131
|
+
:disabled="disabled"
|
|
132
|
+
@click="change('increment', -1)"
|
|
133
|
+
>
|
|
134
|
+
<Minus :size="18" />
|
|
135
|
+
</button>
|
|
136
|
+
<input
|
|
137
|
+
v-model="draft"
|
|
138
|
+
type="number"
|
|
139
|
+
step="any"
|
|
140
|
+
:aria-label="`Value of ${path}`"
|
|
141
|
+
:disabled="disabled"
|
|
142
|
+
@focus="editing = true"
|
|
143
|
+
@blur="save"
|
|
144
|
+
@keydown.enter="($event.target as HTMLInputElement).blur()"
|
|
145
|
+
/>
|
|
146
|
+
<button
|
|
147
|
+
class="secondary"
|
|
148
|
+
:aria-label="`Increment ${path}`"
|
|
149
|
+
:disabled="disabled"
|
|
150
|
+
@click="change('increment', 1)"
|
|
151
|
+
>
|
|
152
|
+
<Plus :size="18" />
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
<div v-else-if="kind === 'boolean'" class="state-boolean">
|
|
156
|
+
<button
|
|
157
|
+
class="switch"
|
|
158
|
+
role="switch"
|
|
159
|
+
:aria-label="`Value of ${path}`"
|
|
160
|
+
:aria-checked="value === true"
|
|
161
|
+
:class="{ on: value === true }"
|
|
162
|
+
:disabled="disabled"
|
|
163
|
+
@click="change('update', !value)"
|
|
164
|
+
>
|
|
165
|
+
<span /></button
|
|
166
|
+
><span>{{ value }}</span>
|
|
167
|
+
</div>
|
|
168
|
+
<textarea
|
|
169
|
+
v-else-if="kind === 'string'"
|
|
170
|
+
v-model="draft"
|
|
171
|
+
rows="2"
|
|
172
|
+
:aria-label="`Value of ${path}`"
|
|
173
|
+
:disabled="disabled"
|
|
174
|
+
@focus="editing = true"
|
|
175
|
+
@blur="save"
|
|
176
|
+
/>
|
|
177
|
+
<span v-else class="muted">null</span>
|
|
178
|
+
</div>
|
|
179
|
+
</template>
|