commit e95f03f229a8851e34a71e88b6d24b826f701cce Author: Quixos Template Publisher Date: Sat Jan 1 00:00:00 2000 +0000 Export template source from 284180a644edd2ec79051630c2b01b04b059cbb8:quixos-instance/packages/web-studio-display-runtime diff --git a/.quixos-template-source.json b/.quixos-template-source.json new file mode 100644 index 0000000..ab8e7fe --- /dev/null +++ b/.quixos-template-source.json @@ -0,0 +1,4 @@ +{ + "monorepoCommit": "284180a644edd2ec79051630c2b01b04b059cbb8", + "sourcePath": "quixos-instance/packages/web-studio-display-runtime" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b0fbcc --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Browser displays + +This package owns ordinary graph transactions for browser enrollment, assignment +and reported mount state. Studio supplies the host adapter; the package contains +no browser heartbeat, DOM code or private storage API. + +A workspace entry conforms to `WebStudioDisplayDirectory`. Its conformance owns +an enrollment edge, and `enroll({nonce,label})` returns the same target for that +nonce. Each directory currently permits 128 targets and rejects further enrollment +explicitly. This is a bounded prototype, not an automatic device inventory GC. + +`WebStudioDisplayTarget.assign` accepts an optional subject/presentation pair. +The subject conforms to `WebStudioPresentable`, whose `canPresent` validates the +selected registered presentation. This role does not require Canvas or placements; +the example workspace adapters forward their existing presentation check. Named +presentations have checked subject-only module contracts; callbacks cannot be +stored in assignments. The target's subject is a graph edge, not a string ID in +JSON. Changing the edge and desired generation is one transaction. + +Assignments and observed outcomes are durable. `acknowledge` accepts a generation, +monotonic report sequence, status (`mounted`, `failed`, `empty`) and bounded error. +Old generations and reordered reports return false without modifying state. +Reported success describes a client mount; it is not a claim of trusted execution. + +The browser adapter uses a per-tab nonce and Web Lock. Duplicated tabs get distinct +nonces; reloading resumes enrollment. Enrollment is a host startup/user action, +never business-object creation from a component render. Multiple displays may +mount the same subject. Losing the connection does not clear its desired state. + +Studio's authenticated presence adapter validates target references against Camino +before issuing an opaque connection lease. Leases expire after 45 seconds; recent +connection observations remain for at most ten minutes, with a 256-record cap. +A target ID is not a credential. There is no persistent heartbeat history. + +## Headless calls + +A headless session uses the same generated descriptors and public operations as +Studio. The caller supplies normal workspace authorization when opening its +session; graph references alone grant no access. + +```ts +const directory = await session.acquire( + { enroll: interfaces.WebStudioDisplayDirectory.enroll }, + session.bootstrap.entry, +); +const enrollment = await directory.enroll.call({ + nonce: savedClientNonce, + label: "Preview display", +}).settled; +if (enrollment.status !== "accepted") throw Error(enrollment.status); +const target = await session.acquire(interfaces.WebStudioDisplayTarget, enrollment.value); +const result = await target.assign.call({ + subject: presentableSubject, + presentation: "presentation:studio:canvas", +}).settled; +``` + +`tools/local-first/studio-session.test.mjs` is the executable example, including +reference rejection and stale-report tests. `package.nix` selects the server-only +TypeScript artifact. Native fields and relationships remain queryable in browser +replicas without bundling mutation implementations there. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..5f726fc --- /dev/null +++ b/flake.lock @@ -0,0 +1,108 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "helpers": { + "flake": false, + "locked": { + "lastModified": 1791886789, + "narHash": "sha256-7YF98v0AcxD1qkAdS+nL/aDtmTvcRzld9CB9AxY0qaA=", + "ref": "refs/tags/quixos-reachability/5657043450c46fc84e1a6d38c2f8919ba1e9d10c", + "rev": "5657043450c46fc84e1a6d38c2f8919ba1e9d10c", + "revCount": 78, + "type": "git", + "url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git" + }, + "original": { + "ref": "refs/tags/quixos-reachability/5657043450c46fc84e1a6d38c2f8919ba1e9d10c", + "rev": "5657043450c46fc84e1a6d38c2f8919ba1e9d10c", + "type": "git", + "url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1783776592, + "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "protocol": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1792824236, + "narHash": "sha256-2JwSmDiHkECzzlw440v5R80tLV+uCkJ16yYLsuL0Ro8=", + "ref": "refs/tags/quixos-reachability/81a56d7b16ab248c0187411ae5284b6e620ead57", + "rev": "81a56d7b16ab248c0187411ae5284b6e620ead57", + "revCount": 236, + "type": "git", + "url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" + }, + "original": { + "ref": "refs/tags/quixos-reachability/81a56d7b16ab248c0187411ae5284b6e620ead57", + "rev": "81a56d7b16ab248c0187411ae5284b6e620ead57", + "type": "git", + "url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" + } + }, + "root": { + "inputs": { + "flake-utils": [ + "protocol", + "flake-utils" + ], + "helpers": "helpers", + "nixpkgs": [ + "protocol", + "nixpkgs" + ], + "protocol": "protocol" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..1361bbc --- /dev/null +++ b/flake.nix @@ -0,0 +1,37 @@ +{ + inputs.protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/tags/quixos-reachability/81a56d7b16ab248c0187411ae5284b6e620ead57&rev=81a56d7b16ab248c0187411ae5284b6e620ead57"; + inputs.nixpkgs.follows = "protocol/nixpkgs"; + inputs.flake-utils.follows = "protocol/flake-utils"; + inputs.helpers = { + url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git?ref=refs/tags/quixos-reachability/5657043450c46fc84e1a6d38c2f8919ba1e9d10c&rev=5657043450c46fc84e1a6d38c2f8919ba1e9d10c"; + flake = false; + }; + outputs = + { + nixpkgs, + flake-utils, + helpers, + ... + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + camino = import (helpers + "/quixos-package-helpers.nix"); + in + { + quixosPackages.checkedArtifacts = + context: + let + pkgs = camino.packageSet { inherit nixpkgs system context; }; + in + camino.mkCaminoTypeScriptPackage { + inherit pkgs context; + src = ./.; + targets = [ "server" ]; + entry = "src/portable.ts"; + }; + devShells.default = camino.mkCaminoYarnShell { inherit pkgs; }; + } + ); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..728e601 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "@quixos/web-studio-display-runtime", + "private": true, + "type": "module" +} diff --git a/package.qx b/package.qx new file mode 100644 index 0000000..6039577 --- /dev/null +++ b/package.qx @@ -0,0 +1,26 @@ +import interface PresentationMount; +import interface WebStudioDisplayTarget; +import interface WebStudioDisplayDirectory; +import interface WebStudioPresentable; +external atom DisplayTarget id "atom:org.quixos.web-studio:display-target"; +type Assignment = record {subject:interface-ref;presentation:string;presentationMount:optional>;}; +type Observed = record {generation:uint32;sequence:uint32;status:string;error:string;}; +type DisplayState = record {label:string;generation:uint32;presentation:optional;observed:optional;}; +package WebStudioDisplayRuntime id "package:org.quixos.web-studio:display-runtime" revision "package:org.quixos.web-studio:display-runtime@1" { + operation enroll id "displays:enroll" : record {nonce:string;label:string;} -> interface-ref mode call execution transaction targets [server] receiver any requires { + edge enrolled id "enrolled" : many-unique interface WebStudioDisplayTarget [resolve,connect]; + interface existing id "existing" : WebStudioDisplayTarget using [nonce.get] from input; + creation target id "target" : DisplayTarget input record {nonce:string;initial:DisplayState;}; + }; + operation assign id "displays:assign" : optional -> uint32 mode call execution transaction targets [server] receiver any requires { + state data id "data" : DisplayState [read,write]; + edge mount id "mount" : optional-one interface PresentationMount [resolve,connect,disconnect]; + edge selected id "selected" : optional-one interface WebStudioPresentable [resolve,connect,disconnect]; + interface subject id "subject" : WebStudioPresentable using [canPresent.call] from input; + }; + operation acknowledge id "displays:acknowledge" : Observed -> bool mode call execution transaction targets [server] receiver any requires {state data id "data" : DisplayState [read,write];}; + query Inputs id "displays:inputs" root WebStudioDisplayTarget document "queries/inputs.graphql" operation "Inputs" {replica;} + query View id "displays:view" root WebStudioDisplayTarget document "queries/view.graphql" operation "View" {local from Inputs;watch;} + query ListInputs id "displays:list-inputs" root workspace WebStudioDisplayTarget document "queries/list-inputs.graphql" operation "ListInputs" {replica;} + query List id "displays:list" root workspace WebStudioDisplayTarget document "queries/list.graphql" operation "List" {local from ListInputs;watch;} +} diff --git a/queries/inputs.graphql b/queries/inputs.graphql new file mode 100644 index 0000000..5d71739 --- /dev/null +++ b/queries/inputs.graphql @@ -0,0 +1 @@ +query Inputs { root { _camino {ref} subject {_camino {ref}} presentationMount {_camino {ref}} status { label generation presentation observed {generation sequence status error} } } } diff --git a/queries/list-inputs.graphql b/queries/list-inputs.graphql new file mode 100644 index 0000000..28ea91a --- /dev/null +++ b/queries/list-inputs.graphql @@ -0,0 +1 @@ +query ListInputs($after: Cursor) { root { objects(first:30,after:$after) { entries {node {_camino {ref} subject {_camino {ref}} presentationMount {_camino {ref}} status {label generation presentation observed {generation sequence status error}}}} pageInfo {hasNextPage endCursor} } } } diff --git a/queries/list.graphql b/queries/list.graphql new file mode 100644 index 0000000..92b6167 --- /dev/null +++ b/queries/list.graphql @@ -0,0 +1 @@ +query List($after: Cursor) { root { objects(first:30,after:$after) { entries {node {_camino {ref} subject {_camino {ref}} presentationMount {_camino {ref}} status {label generation presentation observed {generation sequence status error}}}} pageInfo {hasNextPage endCursor} } } } diff --git a/queries/view.graphql b/queries/view.graphql new file mode 100644 index 0000000..b3cb50a --- /dev/null +++ b/queries/view.graphql @@ -0,0 +1 @@ +query View { root { _camino {ref} subject {_camino {ref}} presentationMount {_camino {ref}} status { label generation presentation observed {generation sequence status error} } } } diff --git a/quixos.check.json b/quixos.check.json new file mode 100644 index 0000000..b7f5525 --- /dev/null +++ b/quixos.check.json @@ -0,0 +1 @@ +{ "backend": "execution" } diff --git a/quixos.lock b/quixos.lock new file mode 100644 index 0000000..8844282 --- /dev/null +++ b/quixos.lock @@ -0,0 +1,7 @@ +quixos-lock version 1 { + quixos source { repository "https://gitea.internal.quixos.org/quixos/quixos.git"; commit "284180a644edd2ec79051630c2b01b04b059cbb8"; } + interface PresentationMount source { repository "https://repos.quixos.org/quixos-studio/interface-presentation-mount.git"; commit "30566340643355d8509bf116c2c3d91f09173d98"; } + interface WebStudioDisplayTarget source { repository "https://repos.quixos.org/quixos-studio/interface-web-studio-display-target.git"; commit "1360b6175a77b8e69a5db83eaa63910d60699651"; } + interface WebStudioDisplayDirectory source { repository "https://repos.quixos.org/quixos-studio/interface-web-studio-display-directory.git"; commit "b6b70722e81f75ff2cd969118c5b8d0bba240a00"; } + interface WebStudioPresentable source { repository "https://repos.quixos.org/quixos-studio/interface-web-studio-presentable.git"; commit "f7b38219572810912fad596c7e4a33903209f71b"; } +} diff --git a/src/portable.ts b/src/portable.ts new file mode 100644 index 0000000..bd661fc --- /dev/null +++ b/src/portable.ts @@ -0,0 +1,47 @@ +import { QxReject } from "@quixos/camino-replica-core"; +import { createRegistry } from "./gen/qx.js"; +export const registry = createRegistry({ + enroll: async ({ input, ports }) => { + if (!/^[a-f0-9-]{36}$/.test(input.nonce) || input.label.length > 100) + throw new QxReject("INVALID_DISPLAY_ENROLLMENT"); + const enrolled = await ports.enrolled.resolve(); + for (const previous of enrolled) { + if ((await ports.existing["nonce.get"]({ receiver: previous.target, input: null })) === input.nonce) + return previous.target; + } + if (enrolled.length >= 128) throw new QxReject("DISPLAY_ENROLLMENT_LIMIT"); + const target = await ports.target.create( + { nonce: input.nonce, initial: { label: input.label, generation: 0, presentation: null, observed: null } }, + { key: "display" }, + ); + const ref = { + ...target, + expectation: { kind: "interface" as const, id: "interface:org.quixos.web-studio.display-target@1" as const }, + }; + await ports.enrolled.connect(ref, { key: "display" }); + return ref; + }, + assign: async ({ input, ports }) => { + if (input && !(await ports.subject["canPresent.call"]({ receiver: input.subject, input: input.presentation }))) + throw new QxReject("INVALID_PRESENTATION"); + const state = await ports.data.get(); + if (state.generation === 4294967295) throw new QxReject("DISPLAY_GENERATION_EXHAUSTED"); + const generation = state.generation + 1; + for (const previous of await ports.selected.resolve()) await ports.selected.disconnect(previous.entry); + if (input) await ports.selected.connect(input.subject, { key: "subject" }); + for (const previous of await ports.mount.resolve()) await ports.mount.disconnect(previous.entry); + if (input?.presentationMount) await ports.mount.connect(input.presentationMount, { key: "presentationMount" }); + await ports.data.set({ ...state, generation, presentation: input?.presentation ?? null, observed: null }); + return generation; + }, + acknowledge: async ({ input, ports }) => { + if (!["mounted", "failed", "empty"].includes(input.status) || input.error.length > 2000) + throw new QxReject("INVALID_DISPLAY_REPORT"); + const state = await ports.data.get(); + if (input.generation !== state.generation || (state.observed && input.sequence <= state.observed.sequence)) + return false; + if ((state.presentation === null) !== (input.status === "empty")) throw new QxReject("INVALID_DISPLAY_REPORT"); + await ports.data.set({ ...state, observed: input }); + return true; + }, +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bcac4b4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", + "noEmit": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +}