chamba 0.3.0 → 0.4.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.
- package/LICENSE +21 -0
- package/README.md +47 -44
- package/bin/chamba.js +212 -0
- package/dist/commands/advanced.js +278 -0
- package/dist/commands/dev.js +619 -0
- package/dist/commands/doctor.js +29 -0
- package/dist/commands/menu.js +80 -0
- package/dist/commands/onboard.js +229 -0
- package/dist/commands/settings.js +349 -0
- package/dist/lib/agent-context.js +177 -0
- package/dist/lib/browser.js +40 -0
- package/dist/lib/chamba-yaml.js +191 -0
- package/dist/lib/constants.js +135 -0
- package/dist/lib/dockerfile-builder.js +267 -0
- package/dist/lib/env.js +78 -0
- package/dist/lib/global-config.js +66 -0
- package/dist/lib/pnpm-store.js +19 -0
- package/dist/lib/ports.js +210 -0
- package/dist/lib/safe-rm.js +26 -0
- package/dist/lib/sessions.js +34 -0
- package/dist/lib/shadows.js +174 -0
- package/dist/lib/webterm.js +490 -0
- package/dist/lib/workspace-identity.js +260 -0
- package/package.json +61 -24
- package/schema/chamba.schema.json +65 -0
- package/templates/.dockerignore +3 -0
- package/templates/Dockerfile +173 -0
- package/templates/claude-statusline.sh +120 -0
- package/templates/context/baseline.md +13 -0
- package/templates/context/context-usage.md +1 -0
- package/templates/context/git-mode-local.md +1 -0
- package/templates/context/git-mode-strict.md +1 -0
- package/templates/context/git-mode-unrestricted.md +1 -0
- package/templates/context/git-unavailable.md +1 -0
- package/templates/context/shadow-paths.md +3 -0
- package/templates/context-usage.sh +249 -0
- package/templates/git-readonly-wrapper.mjs +309 -0
- package/templates/npmrc +2 -0
- package/templates/pnpm-config.yaml +9 -0
- package/templates/runtime-constants.mjs +18 -0
- package/templates/skills/chamba-statusline/SKILL.md +79 -0
- package/templates/skills/context-usage/SKILL.md +53 -0
- package/templates/skills/web-pane/SKILL.md +62 -0
- package/templates/startup-git-mode.mjs +145 -0
- package/templates/startup.mjs +333 -0
- package/templates/webpane.sh +126 -0
- package/templates/webterm/README.md +157 -0
- package/templates/webterm/artifacts.js +583 -0
- package/templates/webterm/config.js +269 -0
- package/templates/webterm/context/claude.md +14 -0
- package/templates/webterm/conversation.js +248 -0
- package/templates/webterm/package-lock.json +884 -0
- package/templates/webterm/package.json +17 -0
- package/templates/webterm/pane.js +156 -0
- package/templates/webterm/proc.js +89 -0
- package/templates/webterm/public/app/alerts.js +472 -0
- package/templates/webterm/public/app/cards.js +123 -0
- package/templates/webterm/public/app/clipboard.js +229 -0
- package/templates/webterm/public/app/composer.js +226 -0
- package/templates/webterm/public/app/connection.js +342 -0
- package/templates/webterm/public/app/dictation.js +98 -0
- package/templates/webterm/public/app/dom.js +37 -0
- package/templates/webterm/public/app/drafts.js +244 -0
- package/templates/webterm/public/app/frames.js +166 -0
- package/templates/webterm/public/app/main.js +82 -0
- package/templates/webterm/public/app/new-session.js +188 -0
- package/templates/webterm/public/app/note.js +24 -0
- package/templates/webterm/public/app/pane-frame.js +166 -0
- package/templates/webterm/public/app/pane.js +353 -0
- package/templates/webterm/public/app/state.js +51 -0
- package/templates/webterm/public/app/status-strip.js +170 -0
- package/templates/webterm/public/app/tabs.js +475 -0
- package/templates/webterm/public/app/terminal.js +102 -0
- package/templates/webterm/public/app/theme.js +46 -0
- package/templates/webterm/public/favicon.svg +21 -0
- package/templates/webterm/public/index.html +105 -0
- package/templates/webterm/public/styles.css +1193 -0
- package/templates/webterm/server.js +1142 -0
- package/templates/webterm/sessions.js +515 -0
- package/templates/webterm/snapshot.js +135 -0
- package/templates/webterm.sh +167 -0
- package/dist/cli.js +0 -1691
- package/dist/server.js +0 -1919
- package/inject/annotate.js +0 -18
- package/skill/README.md +0 -12
- package/skill/SKILL.md +0 -93
- package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
- package/web/assets/index-B9DI4F1Z.js +0 -202
- package/web/assets/index-DK_n6CTo.css +0 -2
- package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
- package/web/index.html +0 -28
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// new-session.js - what the next session should be.
|
|
2
|
+
//
|
|
3
|
+
// Two things make a session and neither can be changed later: which agent runs, and where it runs. One server runs
|
|
4
|
+
// every agent it knows, one per session, so the bar can hold claude and codex side by side, each in its own directory.
|
|
5
|
+
//
|
|
6
|
+
// So both are asked in one panel rather than one control each. Two controls could not be combined - "codex, over
|
|
7
|
+
// there" was not sayable - and the pair of them made the bar read like a toolbar for something that is really one
|
|
8
|
+
// question. The plain button still answers it the usual way in one click; the chevron beside it is where the question
|
|
9
|
+
// gets asked in full, and neither moves the default. `webterm <agent>` in the container is what moves that.
|
|
10
|
+
//
|
|
11
|
+
// It is anchored under the button rather than shown as a card in the middle of the screen: this is the one panel that
|
|
12
|
+
// opens on the way to something routine, and a centred card would send the pointer across the window and back for a
|
|
13
|
+
// choice that is usually two clicks. It hangs off the body all the same, because the bar is rebuilt from scratch on
|
|
14
|
+
// every frame and on the age tick, which would take the open panel with it.
|
|
15
|
+
//
|
|
16
|
+
// Nothing typed here is trusted: the server resolves whatever path arrives and refuses what is not a directory in the
|
|
17
|
+
// workspace, and it refuses an agent it does not run.
|
|
18
|
+
|
|
19
|
+
import { sendFrame } from "./connection.js";
|
|
20
|
+
import { noteMsg } from "./note.js";
|
|
21
|
+
import { agents, defaultAgent, defaultCwd, KEY_QUERY } from "./state.js";
|
|
22
|
+
import { focusTerminal } from "./terminal.js";
|
|
23
|
+
|
|
24
|
+
const DIR_LIST_ID = "dirlist";
|
|
25
|
+
|
|
26
|
+
let newPopEl = null;
|
|
27
|
+
|
|
28
|
+
function closeNewPop() {
|
|
29
|
+
if (!newPopEl) return;
|
|
30
|
+
newPopEl.remove();
|
|
31
|
+
newPopEl = null;
|
|
32
|
+
document.removeEventListener("pointerdown", onNewPopOutside, true);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Put an open panel back under its button after the bar was rebuilt. A frame arrives whenever anything in the
|
|
36
|
+
// container moves, and a panel that closed itself every time one did would be unusable; a button that is gone
|
|
37
|
+
// (the session limit) takes it with it. Clamped to the window, since the bar can be scrolled far to the right.
|
|
38
|
+
export function repositionNewPop() {
|
|
39
|
+
if (!newPopEl) return;
|
|
40
|
+
const anchor = document.getElementById("newtab-more");
|
|
41
|
+
if (!anchor) {
|
|
42
|
+
closeNewPop();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const box = anchor.getBoundingClientRect();
|
|
46
|
+
const width = newPopEl.offsetWidth;
|
|
47
|
+
const left = Math.min(box.right - width, window.innerWidth - width - 8);
|
|
48
|
+
newPopEl.style.top = `${Math.round(box.bottom + 6)}px`;
|
|
49
|
+
newPopEl.style.left = `${Math.round(Math.max(8, left))}px`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function onNewPopOutside(event) {
|
|
53
|
+
// The button itself is left alone: its own click handler toggles, and closing here first would reopen it.
|
|
54
|
+
if (newPopEl?.contains(event.target) || event.target.closest?.("#newtab-more")) return;
|
|
55
|
+
closeNewPop();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Fill the picker's suggestions from the container. Fetched when the panel opens rather than kept around, so
|
|
59
|
+
// a directory created a minute ago is in the list. Failures are silent on purpose: the field still works,
|
|
60
|
+
// and a suggestion list that did not load is not worth a card of its own.
|
|
61
|
+
async function fillDirList(list) {
|
|
62
|
+
let payload;
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`/dirs${KEY_QUERY}`, { cache: "no-store" });
|
|
65
|
+
if (!res.ok) return;
|
|
66
|
+
payload = await res.json();
|
|
67
|
+
} catch {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// The panel may have been closed while this was in flight.
|
|
71
|
+
if (!list.isConnected) return;
|
|
72
|
+
const dirs = Array.isArray(payload?.dirs) ? payload.dirs : [];
|
|
73
|
+
// "/" is how the root is written here: a path everyone recognises, and the one value the server reads
|
|
74
|
+
// back as the workspace root itself.
|
|
75
|
+
for (const dir of ["/", ...dirs]) {
|
|
76
|
+
const option = document.createElement("option");
|
|
77
|
+
option.value = dir;
|
|
78
|
+
if (dir === "/") option.label = "workspace root";
|
|
79
|
+
list.append(option);
|
|
80
|
+
}
|
|
81
|
+
// The scan is capped, so say when the list is partial instead of letting it look complete.
|
|
82
|
+
if (payload?.truncated) noteMsg("the directory list is partial - deeper paths can still be typed in");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The agent half: one chip per agent, the usual one already chosen, so the panel can be opened for the directory
|
|
86
|
+
// alone and closed with Enter. A container that runs a single agent has nothing to ask and gets no row.
|
|
87
|
+
function agentRow(state) {
|
|
88
|
+
const row = document.createElement("div");
|
|
89
|
+
row.className = "agentrow";
|
|
90
|
+
row.setAttribute("role", "radiogroup");
|
|
91
|
+
row.setAttribute("aria-label", "Agent for the new session");
|
|
92
|
+
for (const name of agents) {
|
|
93
|
+
const choice = document.createElement("button");
|
|
94
|
+
choice.type = "button";
|
|
95
|
+
choice.className = "agentchoice";
|
|
96
|
+
choice.textContent = name;
|
|
97
|
+
choice.setAttribute("role", "radio");
|
|
98
|
+
choice.setAttribute("aria-checked", String(name === state.agent));
|
|
99
|
+
choice.classList.toggle("chosen", name === state.agent);
|
|
100
|
+
choice.addEventListener("click", () => {
|
|
101
|
+
state.agent = name;
|
|
102
|
+
for (const other of row.children) {
|
|
103
|
+
const on = other.textContent === name;
|
|
104
|
+
other.classList.toggle("chosen", on);
|
|
105
|
+
other.setAttribute("aria-checked", String(on));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
row.append(choice);
|
|
109
|
+
}
|
|
110
|
+
return row;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function popLabel(text) {
|
|
114
|
+
const label = document.createElement("span");
|
|
115
|
+
label.className = "poplabel";
|
|
116
|
+
label.textContent = text;
|
|
117
|
+
return label;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function toggleNewPop() {
|
|
121
|
+
if (newPopEl) {
|
|
122
|
+
closeNewPop();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const state = { agent: defaultAgent };
|
|
126
|
+
const pop = document.createElement("div");
|
|
127
|
+
pop.id = "newpop";
|
|
128
|
+
pop.setAttribute("role", "dialog");
|
|
129
|
+
pop.setAttribute("aria-label", "Start a session");
|
|
130
|
+
|
|
131
|
+
if (agents.length > 1) pop.append(popLabel("Agent"), agentRow(state));
|
|
132
|
+
|
|
133
|
+
const field = document.createElement("input");
|
|
134
|
+
field.type = "text";
|
|
135
|
+
field.className = "path";
|
|
136
|
+
field.value = defaultCwd;
|
|
137
|
+
field.placeholder = "workspace root";
|
|
138
|
+
field.spellcheck = false;
|
|
139
|
+
field.autocomplete = "off";
|
|
140
|
+
field.setAttribute("list", DIR_LIST_ID);
|
|
141
|
+
field.setAttribute("aria-label", "Directory for the new session, relative to the workspace root");
|
|
142
|
+
const list = document.createElement("datalist");
|
|
143
|
+
list.id = DIR_LIST_ID;
|
|
144
|
+
pop.append(popLabel("Directory"), field, list);
|
|
145
|
+
|
|
146
|
+
const start = () => {
|
|
147
|
+
closeNewPop();
|
|
148
|
+
sendFrame({ t: "new", cwd: field.value.trim(), agent: state.agent });
|
|
149
|
+
};
|
|
150
|
+
const row = document.createElement("div");
|
|
151
|
+
row.className = "poprow";
|
|
152
|
+
const cancel = document.createElement("button");
|
|
153
|
+
cancel.type = "button";
|
|
154
|
+
cancel.className = "btn";
|
|
155
|
+
cancel.textContent = "Cancel";
|
|
156
|
+
cancel.addEventListener("click", () => {
|
|
157
|
+
closeNewPop();
|
|
158
|
+
focusTerminal();
|
|
159
|
+
});
|
|
160
|
+
const go = document.createElement("button");
|
|
161
|
+
go.type = "button";
|
|
162
|
+
go.className = "btn primary";
|
|
163
|
+
go.textContent = "Start session";
|
|
164
|
+
go.addEventListener("click", start);
|
|
165
|
+
row.append(cancel, go);
|
|
166
|
+
pop.append(row);
|
|
167
|
+
|
|
168
|
+
field.addEventListener("keydown", (event) => {
|
|
169
|
+
if (event.key !== "Enter") return;
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
start();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
document.body.append(pop);
|
|
175
|
+
newPopEl = pop;
|
|
176
|
+
repositionNewPop();
|
|
177
|
+
field.focus();
|
|
178
|
+
field.select();
|
|
179
|
+
fillDirList(list);
|
|
180
|
+
// Capture, so a click on a tab closes this before that tab switches session.
|
|
181
|
+
document.addEventListener("pointerdown", onNewPopOutside, true);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
document.addEventListener("keydown", (event) => {
|
|
185
|
+
if (event.key !== "Escape" || !newPopEl) return;
|
|
186
|
+
closeNewPop();
|
|
187
|
+
focusTerminal();
|
|
188
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// note.js - transient status, shown as a pill floating over the bottom of the terminal.
|
|
2
|
+
//
|
|
3
|
+
// It floats rather than sitting in the layout because it comes and goes constantly - a copy says so on every
|
|
4
|
+
// drag - and a line that appears and disappears in the flow shifts everything under it each time.
|
|
5
|
+
|
|
6
|
+
import { note } from "./dom.js";
|
|
7
|
+
|
|
8
|
+
// A plain note is gone quickly; an error stays long enough to be read and acted on.
|
|
9
|
+
const NOTE_MS = 2500;
|
|
10
|
+
const NOTE_ERROR_MS = 5000;
|
|
11
|
+
|
|
12
|
+
export function noteMsg(text, isError) {
|
|
13
|
+
note.textContent = text;
|
|
14
|
+
note.classList.toggle("error", Boolean(isError));
|
|
15
|
+
if (text) {
|
|
16
|
+
setTimeout(
|
|
17
|
+
() => {
|
|
18
|
+
// Only clear the note if no newer message replaced it in the meantime.
|
|
19
|
+
if (note.textContent === text) note.textContent = "";
|
|
20
|
+
},
|
|
21
|
+
isError ? NOTE_ERROR_MS : NOTE_MS,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// pane-frame.js - how a published page is put on the screen, and how an answer comes back out of it.
|
|
2
|
+
//
|
|
3
|
+
// A page is written by an agent, so it is treated as somebody else's document rather than as part of this
|
|
4
|
+
// interface. It goes into an iframe with `sandbox="allow-scripts allow-forms"` and no same-origin token,
|
|
5
|
+
// which gives it an opaque origin: its scripts run, and there is nothing of this page for them to reach -
|
|
6
|
+
// not the DOM, not storage, not the key. The content arrives over the key-gated route with the key in a
|
|
7
|
+
// header, so the frame never holds a URL that carries it either.
|
|
8
|
+
//
|
|
9
|
+
// The one way out of the frame is a message. The shell injects a small script into the content that wires
|
|
10
|
+
// any `<form data-feedback>` to a `postMessage`, and the shell - which alone holds the key - forwards the
|
|
11
|
+
// submission to the feedback endpoint and posts the outcome back in. A published page therefore stays plain
|
|
12
|
+
// HTML: the agent writes a form, not networking code.
|
|
13
|
+
|
|
14
|
+
import { KEY_HEADER, WEB_KEY } from "./state.js";
|
|
15
|
+
|
|
16
|
+
// What the injected script and the shell call each other's messages. Namespaced because a page may be
|
|
17
|
+
// talking to something else of its own, and every message is checked against the frame it must come from.
|
|
18
|
+
const SUBMIT = "webpane:submit";
|
|
19
|
+
const RESULT = "webpane:result";
|
|
20
|
+
|
|
21
|
+
// The script that turns a plain form into a submission. It runs inside the frame, with no key and no way to
|
|
22
|
+
// reach the server: everything it knows how to do is ask its parent.
|
|
23
|
+
//
|
|
24
|
+
// The shape it collects is the feedback file's: every named control is a field, except a control named
|
|
25
|
+
// "text", which is the free-text box the file has a place of its own for.
|
|
26
|
+
const WIRING = `<script>
|
|
27
|
+
(function () {
|
|
28
|
+
function collect(form) {
|
|
29
|
+
var fields = {};
|
|
30
|
+
var text = "";
|
|
31
|
+
var data = new FormData(form);
|
|
32
|
+
var names = [];
|
|
33
|
+
data.forEach(function (value, name) {
|
|
34
|
+
if (typeof value !== "string") return;
|
|
35
|
+
if (name === "text") { text = value; return; }
|
|
36
|
+
if (names.indexOf(name) === -1) { names.push(name); fields[name] = value; return; }
|
|
37
|
+
fields[name] = fields[name] + ", " + value;
|
|
38
|
+
});
|
|
39
|
+
return { fields: fields, text: text };
|
|
40
|
+
}
|
|
41
|
+
function wire(form) {
|
|
42
|
+
if (form.dataset.webpaneWired === "1") return;
|
|
43
|
+
form.dataset.webpaneWired = "1";
|
|
44
|
+
form.addEventListener("submit", function (event) {
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
var answers = collect(form);
|
|
47
|
+
form.setAttribute("data-webpane-state", "sending");
|
|
48
|
+
parent.postMessage({ t: "${SUBMIT}", fields: answers.fields, text: answers.text }, "*");
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function wireAll() {
|
|
52
|
+
var forms = document.querySelectorAll("form[data-feedback]");
|
|
53
|
+
for (var i = 0; i < forms.length; i++) wire(forms[i]);
|
|
54
|
+
}
|
|
55
|
+
window.addEventListener("message", function (event) {
|
|
56
|
+
if (!event.data || event.data.t !== "${RESULT}") return;
|
|
57
|
+
var forms = document.querySelectorAll("form[data-feedback]");
|
|
58
|
+
for (var i = 0; i < forms.length; i++) forms[i].setAttribute("data-webpane-state", event.data.ok ? "sent" : "failed");
|
|
59
|
+
});
|
|
60
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wireAll);
|
|
61
|
+
else wireAll();
|
|
62
|
+
})();
|
|
63
|
+
</script>`;
|
|
64
|
+
|
|
65
|
+
// The base every relative URL and `document.baseURI` inside the frame resolves against.
|
|
66
|
+
//
|
|
67
|
+
// This one matters, and it is half of a pair. A srcdoc document has no URL of its own, so it falls back to
|
|
68
|
+
// the base URL of the document holding it - and `document.baseURI` is a plain string on the child document,
|
|
69
|
+
// which an opaque origin does nothing about. So a page could read it and send it anywhere, and this window's
|
|
70
|
+
// URL carries the key.
|
|
71
|
+
//
|
|
72
|
+
// The half that holds whatever the page does is in index.html: the shell declares `<base href="/">`, so the
|
|
73
|
+
// value the frame falls back to is the bare origin rather than a URL with a key on it. This half is the
|
|
74
|
+
// passive one: giving the rendered document its own base means nothing is inherited in the first place. It
|
|
75
|
+
// is the frame's own URL, which is what a document with no other base should have, and it keeps in-page
|
|
76
|
+
// `#anchor` links working - a fragment of the current URL is a scroll, and anything else is a navigation.
|
|
77
|
+
// A page can delete this element (it is in its own DOM), which is exactly why the shell's base exists.
|
|
78
|
+
const NEUTRAL_BASE = '<base href="about:srcdoc" />';
|
|
79
|
+
|
|
80
|
+
// The page as it is rendered: the base first, then its own HTML, then the wiring.
|
|
81
|
+
//
|
|
82
|
+
// The base goes after a doctype where there is one (a doctype that is not first puts the document into
|
|
83
|
+
// quirks mode) and before everything else, so it is the first base the parser meets. The wiring goes before
|
|
84
|
+
// the end of the body where there is one, so a document that ends properly stays ended, and is appended
|
|
85
|
+
// otherwise - a published file may be a fragment, and the frame renders it either way.
|
|
86
|
+
function withWiring(html) {
|
|
87
|
+
const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
|
|
88
|
+
const at = doctype ? doctype[0].length : 0;
|
|
89
|
+
const body = html.slice(at);
|
|
90
|
+
const end = body.toLowerCase().lastIndexOf("</body>");
|
|
91
|
+
const wired = end === -1 ? body + WIRING : body.slice(0, end) + WIRING + body.slice(end);
|
|
92
|
+
return html.slice(0, at) + NEUTRAL_BASE + wired;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One page's content, over the key-gated route. Throws for anything that is not a page served back. */
|
|
96
|
+
export async function fetchPage(sid, id) {
|
|
97
|
+
const url = `/pane/page?sid=${encodeURIComponent(sid)}&id=${encodeURIComponent(id)}`;
|
|
98
|
+
const response = await fetch(url, { headers: { [KEY_HEADER]: WEB_KEY }, cache: "no-store" });
|
|
99
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
100
|
+
return response.text();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The message listener of the frame currently on the screen, if any. At most one page is rendered at a time.
|
|
104
|
+
let active = null;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Put a page into `host`, replacing whatever was there, and return the frame.
|
|
108
|
+
*
|
|
109
|
+
* `onFeedback(submission)` is called when a form inside the page is submitted; it answers with a promise for
|
|
110
|
+
* whether the answers were filed, which is posted back into the frame so the page can say so.
|
|
111
|
+
*/
|
|
112
|
+
export function renderPage(host, { html, onFeedback }) {
|
|
113
|
+
dropPage(host);
|
|
114
|
+
const frame = document.createElement("iframe");
|
|
115
|
+
frame.className = "page-frame";
|
|
116
|
+
// The whole security model in one attribute: scripts and forms, and no same-origin, so the document has
|
|
117
|
+
// an opaque origin. Set before the content, since the sandbox of a frame that already has a document
|
|
118
|
+
// is not what a later attribute says it is.
|
|
119
|
+
frame.setAttribute("sandbox", "allow-scripts allow-forms");
|
|
120
|
+
// The other half of keeping this window's URL out of the frame: a srcdoc document inherits its parent's
|
|
121
|
+
// referrer, and the parent's URL carries the key - both as `document.referrer` inside the page and as the
|
|
122
|
+
// `Referer` header on anything it loads.
|
|
123
|
+
frame.setAttribute("referrerpolicy", "no-referrer");
|
|
124
|
+
frame.setAttribute("title", "Published page");
|
|
125
|
+
frame.srcdoc = withWiring(html);
|
|
126
|
+
host.replaceChildren(frame);
|
|
127
|
+
|
|
128
|
+
// Only messages from this frame, of the one kind the wiring sends. Anything else on the window - another
|
|
129
|
+
// frame, an extension, the page trying something of its own - is not a submission.
|
|
130
|
+
const listener = async (event) => {
|
|
131
|
+
if (event.source !== frame.contentWindow) return;
|
|
132
|
+
if (!event.data || event.data.t !== SUBMIT) return;
|
|
133
|
+
let ok = false;
|
|
134
|
+
try {
|
|
135
|
+
ok = await onFeedback({ fields: event.data.fields, text: event.data.text });
|
|
136
|
+
} catch {
|
|
137
|
+
ok = false;
|
|
138
|
+
}
|
|
139
|
+
// Whether it was filed, and nothing else. This is the one channel into the frame that no browser
|
|
140
|
+
// rule narrows for us, so what goes through it stays this small on purpose.
|
|
141
|
+
frame.contentWindow?.postMessage({ t: RESULT, ok }, "*");
|
|
142
|
+
};
|
|
143
|
+
window.addEventListener("message", listener);
|
|
144
|
+
// The listener belongs to the frame that is up, and goes when that frame does, so a pane the user has
|
|
145
|
+
// clicked through a dozen times is not a dozen listeners deep.
|
|
146
|
+
active = listener;
|
|
147
|
+
return frame;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Take a rendered page off the screen, listener and all. */
|
|
151
|
+
export function dropPage(host) {
|
|
152
|
+
if (active) window.removeEventListener("message", active);
|
|
153
|
+
active = null;
|
|
154
|
+
host.replaceChildren();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** File a submission from the page the pane is showing. The shell holds the key; the frame never sees it. */
|
|
158
|
+
export async function submitFeedback(sid, id, submission) {
|
|
159
|
+
const url = `/pane/feedback?sid=${encodeURIComponent(sid)}&id=${encodeURIComponent(id)}`;
|
|
160
|
+
const response = await fetch(url, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: { [KEY_HEADER]: WEB_KEY, "Content-Type": "application/json" },
|
|
163
|
+
body: JSON.stringify({ fields: submission.fields, text: submission.text }),
|
|
164
|
+
});
|
|
165
|
+
return response.ok;
|
|
166
|
+
}
|