// App Studio — the member-facing manager for member apps (docs/member-apps.md). // // Two ways an app reaches this instance: ask a bot to build one, or write the // code yourself and deploy it here. This surface lists the apps you've made, // and lets you paste a single-file app module, Check it (evaluated in the // sandbox to read back its manifest), and Deploy it — the SAME encryption + // opaque-origin sandbox every member app runs in, so your code never touches // keys, the page DOM, or the network. // // It's a TRUSTED compiled-in app (it holds the token + instance key to // encrypt/deploy), so the privileged work lives in ../sandbox.js; App Studio // is just the UI over it. The authoring rules are at GET /api/member-app-guide. import { defineApp, namespace, appByName } from "../apps.js"; import { ICON_PUZZLE } from "../icons.js"; import { ENABLE_MEMBER_APPS } from "../shellstate.js"; import { appOpenPlan } from "../ui.js"; import { listDeployedApps, listAppVersions, deployMemberApp, retireMemberApp, extractManifest, openMemberApp, memberAppGroups, openMemberAppInGroup, openMemberAppForSpace, memberHostEmbedded, manifestIsLegacy, } from "../sandbox.js"; // One declaration of the app's prefix: N.ev("x") → the "appstudio.x" wire type, // N.cls("x") → the "appstudio-x" CSS class (reached as ctx.ev / ctx.cls in render). const N = namespace("appstudio"); // Static in-code SVG (safe for innerHTML — no user content). const ICON_DOWNLOAD = ''; const ICON_BOT = ''; // Download an app's source as a .js file (App Studio runs in the trusted page). function downloadSource(source, name) { const safe = (name || "app").replace(/[^a-z0-9._-]/gi, "") || "app"; const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" })); const a = document.createElement("a"); a.href = url; a.download = safe + ".js"; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } const STARTER = `import { defineApp, namespace } from "../apps.js"; const N = namespace("hello"); // N.ev("tap") → "hello.tap", N.cls("btn") → "hello-btn" export const app = defineApp({ name: "hello", // unique lowercase id — rename for your app title: "Hello", // shown in the Apps list glyph: "👋", // one emoji symbol: "puzzlepiece", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md) initialState: () => ({ count: 0 }), events: { [N.ev("tap")]: (s) => ({ count: s.count + 1 }), }, render(state, el, ctx) { const btn = document.createElement("button"); btn.textContent = "Say hello"; btn.className = ctx.cls("btn"); btn.onclick = () => ctx.emit({ type: ctx.ev("tap") }); el.appendChild(btn); if (state.count > 0) { const p = document.createElement("p"); p.textContent = "hello 👋 (×" + state.count + ")"; el.appendChild(p); } }, }); `; // Module-level editor state: the framework clears our pane on each render, so // keep the draft + edit target here so a re-render (after deploy/remove) is // seamless. `editing` is the id of the app being updated, or "" for a new one. let draft = ""; let editing = ""; let editingName = ""; let busy = false; // Version-history view: the app whose history is open (""), its title, and the // fetched history (null until loaded). let historyFor = ""; let historyTitle = ""; let historyData = null; function el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; } // ---- the place-aware "Open" (appOpenPlan in ui.js) ------------------------- // Where this app can live. The live registry has the normalized answer // (registerMemberApp folds the legacy boolean flags into places); fall back // to the stored manifest, normalized the same way, for an app that didn't // register this session (e.g. its name collided with another's). function placesOf(m) { const reg = appByName(m.name); if (reg && Array.isArray(reg.places) && reg.places.length) return reg.places; return m.places || ["you", ...(m.groupCapable ? ["chat"] : []), ...(m.spaceCapable ? ["space"] : [])]; } // The one open chooser — a transient strip under the tapped row (never a // floating overlay). Any other Open tap or its Cancel closes it; a re-render // wipes the pane, so render() just drops the stale handle. let chooser = null; function closeChooser() { if (chooser) { chooser.remove(); chooser = null; } } function chooserFor(row, label) { closeChooser(); const box = el("div", "chooser"); box.setAttribute("role", "group"); box.setAttribute("aria-label", label); row.after(box); chooser = box; return box; } function chooserCancel(box) { const c = el("button", "cancel", "Cancel"); c.onclick = closeChooser; box.appendChild(c); } function chooserNote(row, text) { const box = chooserFor(row, "Open"); box.appendChild(el("span", "chooser-note", text)); chooserCancel(box); box.querySelector("button").focus(); } // "Add to which group chat?" — pick a group, install the shared copy there // (the same groupapps.add every Apps sheet emits), and land in that chat. function showGroupPicker(m, row) { const title = m.title || m.name; const groups = memberAppGroups(); const box = chooserFor(row, `Add ${title} to a group chat`); if (!groups.length) { box.appendChild(el("span", "chooser-note", "This app lives in group chats, and you're not in any yet — start one from + New chat, then add the app there.")); chooserCancel(box); box.querySelector("button").focus(); return; } box.appendChild(el("span", "chooser-label", "Add to which group chat?")); for (const g of groups) { const b = el("button", null, g.title); // group names are user content — textContent via el() b.onclick = async () => { b.disabled = true; if (await openMemberAppInGroup(m.name, g.id)) { closeChooser(); return; } b.disabled = false; box.appendChild(el("span", "chooser-note err", "Couldn't add it there — try again.")); }; box.appendChild(b); } chooserCancel(box); box.querySelector("button").focus(); } // "Open it where?" — for an app that supports group chats AND another place. // Options name places, in the Apps sheets' vocabulary; the group option leads // to the picker above. function showHowChooser(m, row) { const places = placesOf(m); const box = chooserFor(row, `Where to open ${m.title || m.name}`); box.appendChild(el("span", "chooser-label", "Open it where?")); if (places.includes("you")) { const you = el("button", null, "Just for you"); you.onclick = () => { closeChooser(); openMemberApp(m.name); }; box.appendChild(you); } if (places.includes("space")) { const sp = el("button", null, "For the whole space"); sp.onclick = async () => { sp.disabled = true; if (await openMemberAppForSpace(m.name)) { closeChooser(); return; } sp.disabled = false; box.appendChild(el("span", "chooser-note err", "Couldn't add it for the whole space — that may be admins-only here.")); }; box.appendChild(sp); } const grp = el("button", null, "In a group chat…"); grp.onclick = () => showGroupPicker(m, row); box.appendChild(grp); chooserCancel(box); box.querySelector("button").focus(); } // The Open button: route by the app's places. No group place → open straight // into where it lives (personal thread, else its space home); group-only → // ask which group; group + another place → ask where. async function openApp(m, row) { closeChooser(); const places = placesOf(m); const plan = appOpenPlan(places); // A native embed is one thread's pane: only the personal open can hand off // to the native shell today, so take it whenever the app supports it // (asking would offer options this pane can't do), and point group/space // shapes at the native Apps sheet instead of opening inside this pane. if (memberHostEmbedded()) { if (places.includes("you")) return openMemberApp(m.name); chooserNote(row, places.includes("chat") ? "This app lives in group chats — open one and add it from that chat's Apps menu." : "This app is for the whole space — add it from a chat's Apps menu."); return; } if (plan.ask === "group") return showGroupPicker(m, row); if (plan.ask === "how") return showHowChooser(m, row); if (plan.direct === "space") { if (!(await openMemberAppForSpace(m.name))) { chooserNote(row, "Couldn't add it for the whole space — that may be admins-only here."); } return; } openMemberApp(m.name); } export const app = defineApp({ name: "appstudio", title: "App Studio", blurb: "Make and manage your own apps", glyph: "🧩", logo: ICON_PUZZLE, places: ["you"], placeNote: "App Studio manages the apps YOU deploy — the apps it builds are what get shared.", initialState: () => ({}), events: {}, snapshot: false, // a live view of the server, not an event-sourced app styles: ` .studio { padding: 16px; max-width: 720px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; } .studio h2 { margin: 0; font-size: 18px; } .studio h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } .studio .sub { margin: 4px 0 0; color: var(--muted); font-size: 13px; line-height: 1.5; } /* The row wraps: glyph + title hold the first line; the action cluster drops to its own line and wraps among itself when the pane is too narrow to keep it inline — so nothing overflows off-screen and the title is never crushed letter-by-letter. On a wide pane the meta flex-grows and pushes the actions to the right, keeping the familiar single-line look. */ .studio .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 12px 0; border-bottom: 1px solid var(--border); } .studio .row .g { font-size: 22px; width: 28px; text-align: center; flex: 0 0 auto; align-self: flex-start; } /* A solid basis keeps the title readable: on a narrow pane the actions wrap below rather than squeezing meta until "Streaks" stacks into "Strea/ks". */ .studio .row .meta { flex: 1 1 200px; min-width: 0; } .studio .row .meta .t { font-weight: 600; overflow-wrap: break-word; } .studio .row .meta .by { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 2px; flex-wrap: wrap; } .studio .row .actions { display: flex; flex-wrap: wrap; gap: 8px; } .studio .bot-badge { display: inline-flex; align-items: center; color: var(--muted); } .studio .row button { cursor: var(--ui-cursor, pointer); background: none; border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 12px; color: var(--ink); font-size: 13px; } .studio .row button.danger { color: var(--danger); border-color: var(--danger); } .studio .row button.icon-btn { display: inline-flex; align-items: center; padding: 5px 9px; } .studio .link-btn { align-self: flex-start; background: none; border: none; color: var(--link); cursor: var(--ui-cursor, pointer); font-size: 13px; padding: 0; } .studio .empty { color: var(--muted); font-size: 13px; padding: 8px 0; } /* Shared input surface (background/border/radius/colour) comes from .eleven-input in style.css; this adds the code-editor specifics + vertical breathing room. */ .studio textarea { width: 100%; box-sizing: border-box; min-height: 220px; margin: 12px 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.5; padding: 10px; resize: vertical; } .studio .bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .studio .bar button { cursor: var(--ui-cursor, pointer); border-radius: var(--radius); padding: 7px 14px; font-size: 13px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); } .studio .bar button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } .studio .bar button[disabled] { opacity: .5; cursor: default; } .studio .note { font-size: 13px; line-height: 1.5; margin: 10px 0 0; padding: 8px 10px; border-radius: var(--radius); background: var(--bubble-them); } /* The member-apps-off callout: the one notice that explains the whole page's reduced state, so it gets the note surface + full-ink lead, not muted sub. */ .studio .gate-note { display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; border-radius: var(--radius); background: var(--bubble-them); } .studio .gate-note .gg { font-size: 20px; line-height: 1.3; } .studio .gate-note .gt { font-weight: 600; font-size: 14px; line-height: 1.4; } .studio .note.err { color: var(--danger); } .studio .note.ok { color: var(--accent); } .studio .spacer { flex: 1 1 auto; } /* The Open chooser: an inline strip under the tapped row (occupies layout space, never floats). Wraps like the rows' action cluster on narrow panes. */ .studio .chooser { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 10px 12px; margin: 8px 0; border-radius: var(--radius); background: var(--bubble-them); } .studio .chooser .chooser-label { font-size: 13px; font-weight: 600; } .studio .chooser .chooser-note { font-size: 13px; line-height: 1.5; } .studio .chooser .chooser-note.err { color: var(--danger); flex: 1 1 100%; } .studio .chooser button { cursor: var(--ui-cursor, pointer); background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 12px; color: var(--ink); font-size: 13px; } .studio .chooser button[disabled] { opacity: .5; cursor: default; } .studio .chooser button.cancel { background: none; border-color: transparent; color: var(--muted); } `, render(_state, root, ctx) { root.textContent = ""; chooser = null; // the wipe above just removed any open chooser's DOM const wrap = el("div", "studio"); root.appendChild(wrap); wrap.appendChild(el("h2", null, "App Studio 🧩")); // The member-apps gate (an admin setting; the server enforces it on every // deploy). Off hides the whole authoring surface — no editor to type into // only to be refused — while apps already deployed stay listed and usable. // The why gets a callout, not the muted intro style: it explains the whole // page's reduced state, so it has to be the first thing a visitor spots. if (ENABLE_MEMBER_APPS) { wrap.appendChild( el("p", "sub", "Make your own apps: paste a single-file app module below, Check it, and Deploy. It runs sandboxed like any app — it can't reach your messages, keys, or the network. Prefer to just describe one? Ask a bot to build it for you."), ); } else { const gate = el("div", "gate-note"); const gg = el("span", "gg", "🔒"); // decorative — the text carries the meaning gg.setAttribute("aria-hidden", "true"); gate.appendChild(gg); const txt = el("div"); txt.appendChild(el("div", "gt", "Making your own apps is turned off for this space")); txt.appendChild(el("p", "sub", "An admin can turn it back on in Admin → Features. Apps already deployed keep working.")); gate.appendChild(txt); wrap.appendChild(gate); } // ---- Version history (replaces the list/editor while open) -------------- if (historyFor) { const back = el("button", "link-btn", "← Back to apps"); back.onclick = () => { historyFor = ""; historyData = null; rerender(); }; wrap.appendChild(back); const panel = el("div"); panel.appendChild(el("h3", null, `History — ${historyTitle}`)); panel.appendChild(el("p", "sub", "Every deploy is kept. Restore an earlier version and it deploys as a new one, so nothing is lost.")); wrap.appendChild(panel); if (!historyData) { panel.appendChild(el("div", "empty", "Loading…")); listAppVersions(historyFor).then((res) => { historyData = res; rerender(); }); } else if (historyData.error) { panel.appendChild(el("div", "note err", historyData.error)); } else if (!historyData.versions.length) { panel.appendChild(el("div", "empty", "No history yet.")); } else { for (const v of historyData.versions) { const row = el("div", "row"); const meta = el("div", "meta"); meta.appendChild(el("div", "t", `Version ${v.version}${v.current ? " · current" : ""}`)); const sub = new Date(v.at * 1000).toLocaleString() + (v.byMe ? " · by you" : "") + (v.based_on ? ` · based on version ${v.based_on}` : ""); meta.appendChild(el("div", "by", sub)); row.appendChild(meta); const actions = el("div", "actions"); if (v.source) { const dl = el("button", "icon-btn"); dl.innerHTML = ICON_DOWNLOAD; dl.title = "Download this version"; dl.onclick = () => downloadSource(v.source, `${historyTitle}-v${v.version}`); actions.appendChild(dl); } if (ENABLE_MEMBER_APPS && !v.current && v.source) { const revert = el("button", "primary", "Revert to this"); revert.onclick = async () => { if (!confirm(`Revert to version ${v.version}? It deploys as a new version (marked "based on version ${v.version}") — nothing is lost.`)) return; revert.disabled = true; const r = await deployMemberApp(v.source, { id: historyFor, basedOn: v.version }); if (r.error) { revert.disabled = false; row.after(el("div", "note err", r.error)); return; } // apps_changed reloads to apply; drop back to the list meanwhile. historyFor = ""; historyData = null; rerender(); }; actions.appendChild(revert); } row.appendChild(actions); panel.appendChild(row); } } return; } // ---- Your apps ---------------------------------------------------------- const listSection = el("div"); listSection.appendChild(el("h3", null, "Apps on this space")); const listBody = el("div"); listSection.appendChild(listBody); wrap.appendChild(listSection); listBody.appendChild(el("div", "empty", "Loading…")); const refreshList = async () => { const res = await listDeployedApps(); listBody.textContent = ""; if (res.error) { listBody.appendChild(el("div", "note err", res.error)); return; } if (!res.apps.length) { listBody.appendChild( el("div", "empty", ENABLE_MEMBER_APPS ? "No apps yet — deploy one below, or ask a bot to build you one." : "No apps yet."), ); return; } for (const a of res.apps) { const m = a.manifest || {}; const row = el("div", "row"); row.appendChild(el("span", "g", m.glyph || "▦")); const meta = el("div", "meta"); meta.appendChild(el("div", "t", m.title || m.name || a.id)); // Ownership and authorship are independent: you can own an app a bot wrote // for you. Show "you + " (with a bot icon) for those, "by you" for // hand-written, "by " for someone else's. const by = el("div", "by"); if (a.mine && a.byBot) { by.appendChild(document.createTextNode("you + ")); const bot = el("span", "bot-badge"); bot.innerHTML = ICON_BOT; bot.title = "AI-generated — this bot wrote it, you own it"; by.appendChild(bot); by.appendChild(document.createTextNode(" " + (m.builtBy || "a bot"))); } else if (a.mine) { by.appendChild(document.createTextNode("by you")); } else { by.appendChild(document.createTextNode("by " + (m.by || "another member"))); } if (a.mine) { const extra = // Version + id let the member cross-check a bot's build report // ("✨ … version 3 is live … (app id: app-…)") against what's // actually deployed — the numbers here are the server's, not the // bot's. ` · version ${a.version || 1}` + (m.chat ? " · chat view" : "") + ((m.places ? m.places.includes("chat") : m.groupCapable) ? " · group-capable" : "") + ((m.commands || []).length ? " · " + m.commands.map((c) => c.name).join(" ") : "") + // The legacy-format flag (docs/member-apps.md § Legacy formats): // the app still loads fine, but its stored manifest predates the // places vocabulary — the Upgrade button below modernizes it. (manifestIsLegacy(m) ? " · older format" : "") + ` · ${a.id}`; by.appendChild(document.createTextNode(extra)); } meta.appendChild(by); row.appendChild(meta); // Buttons live in their own cluster so they wrap as a unit below the // title on a narrow pane instead of spilling off the right edge. const actions = el("div", "actions"); if (m.name) { const open = el("button", null, "Open"); open.onclick = () => openApp(m, row); actions.appendChild(open); } if (a.mine && a.source) { // A legacy-format app (manifest predates the places vocabulary) // gets a one-tap upgrade: redeploy the SAME source through the // current pipeline — the re-minted manifest speaks places, the // code is untouched, and History keeps the old version. Gated with // the editor (deploys 403 while member apps are off). if (ENABLE_MEMBER_APPS && manifestIsLegacy(m)) { const up = el("button", "primary", "Upgrade"); up.title = "Redeploys the same code with a current-format manifest — nothing about the app changes, and History keeps the old version."; up.onclick = async () => { up.disabled = true; const r = await deployMemberApp(a.source, { id: a.id }); if (r.error) { up.disabled = false; row.after(el("div", "note err", r.error)); return; } // apps_changed reloads to apply, same as a revert deploy. up.textContent = "Upgraded ✓"; }; actions.appendChild(up); } // Edit code leads to the (gated) editor — with member apps off it // hides with it; History, Download, and Remove stay open. if (ENABLE_MEMBER_APPS) { const edit = el("button", null, "Edit code"); edit.onclick = () => { draft = a.source; editing = a.id; editingName = m.name || ""; rerender(); }; actions.appendChild(edit); } const hist = el("button", null, "History"); hist.onclick = () => { historyFor = a.id; historyTitle = m.title || m.name || a.id; historyData = null; rerender(); }; actions.appendChild(hist); const dl = el("button", "icon-btn"); dl.innerHTML = ICON_DOWNLOAD; dl.title = "Download source"; dl.onclick = () => downloadSource(a.source, m.name || a.id); actions.appendChild(dl); const del = el("button", "danger", "Remove"); del.onclick = async () => { if (!confirm(`Remove “${m.title || m.name}”? This deletes the app for everyone.`)) return; del.disabled = true; const r = await retireMemberApp(a.id); if (r.error) { del.disabled = false; const e = el("div", "note err", r.error); row.after(e); return; } refreshList(); }; actions.appendChild(del); } row.appendChild(actions); listBody.appendChild(row); } }; // With member apps off there's no authoring surface at all — the header // note above says why, and the list (Open / History / Download / Remove) // is the whole page. Deploys would 403 server-side regardless. if (!ENABLE_MEMBER_APPS) { refreshList(); return; } // ---- Add / edit an app -------------------------------------------------- const editor = el("div"); editor.appendChild(el("h3", null, editing ? `Edit “${editingName}”` : "Write a new app")); const guide = el("p", "sub"); guide.appendChild(document.createTextNode("One self-contained ES module — see the ")); const link = el("a", null, "authoring guide"); link.href = "/api/member-app-guide"; link.target = "_blank"; link.rel = "noreferrer"; guide.appendChild(link); guide.appendChild(document.createTextNode(".")); editor.appendChild(guide); const ta = el("textarea", "eleven-input"); ta.spellcheck = false; ta.value = draft || (editing ? "" : STARTER); ta.placeholder = "Paste your app's JavaScript here…"; ta.oninput = () => { draft = ta.value; }; editor.appendChild(ta); const note = el("div", "note"); note.style.display = "none"; const showNote = (text, kind) => { note.className = "note" + (kind ? " " + kind : ""); note.textContent = text; note.style.display = ""; }; const bar = el("div", "bar"); const checkBtn = el("button", null, "Check"); const deployBtn = el("button", "primary", editing ? "Update" : "Deploy"); bar.appendChild(checkBtn); bar.appendChild(deployBtn); if (editing) { const cancel = el("button", null, "New app instead"); cancel.onclick = () => { draft = ""; editing = ""; editingName = ""; rerender(); }; bar.appendChild(cancel); } editor.appendChild(bar); editor.appendChild(note); wrap.appendChild(editor); const setBusy = (on) => { busy = on; checkBtn.disabled = on; deployBtn.disabled = on; }; checkBtn.onclick = async () => { if (busy) return; setBusy(true); showNote("Checking…"); const res = await extractManifest(ta.value); setBusy(false); if (res.error) { showNote(res.error, "err"); return; } const m = res.manifest; const bits = [`${m.glyph || "▦"} ${m.title || m.name} (${m.name})`, `${(m.types || []).length} event type(s)`]; if ((m.commands || []).length) bits.push("commands: " + m.commands.map((c) => c.name).join(" ")); if (m.chat) bits.push("companion chat view"); if (m.places ? m.places.includes("chat") : m.groupCapable) bits.push("can be added to groups"); showNote("Looks good — " + bits.join(" · "), "ok"); }; deployBtn.onclick = async () => { if (busy) return; setBusy(true); showNote(editing ? "Updating…" : "Deploying…"); const res = await deployMemberApp(ta.value, editing ? { id: editing } : {}); if (res.error) { setBusy(false); showNote(res.error, "err"); return; } if (res.updated) { showNote("Updated ✓ — reloading to apply the new code…", "ok"); // The apps_changed broadcast reloads to pick up the new code. return; } setBusy(false); draft = ""; editing = ""; editingName = ""; showNote("Deployed ✓ — it's in your Apps list now.", "ok"); refreshList(); }; // Re-render the whole surface (after edit/deploy/remove state changes). function rerender() { app.render(_state, root, ctx); } refreshList(); }, });