import { BrowserOAuthClient } from '@atproto/oauth-client-browser' import { Agent } from '@atproto/api' import { secp256k1 } from '@noble/curves/secp256k1.js' import * as age from 'age-encryption' import * as devTunnelsSsh from '@microsoft/dev-tunnels-ssh' import { Buffer } from 'buffer' // The compute and atirc pages' components and cubits, imported through the webrtc/ and atirc/ // symlinks; this page adds the shared session and the agent features. import './webrtc/requester-actions.js' import './webrtc/webrtc-connect.js' import './webrtc/webrtc-terminal.js' import './atirc/atirc-post-form.js' import './swc-sessions.js' import './swc-channel.js' import { WebrtcCubit } from './webrtc/webrtc-cubit.js' import { AtircCubit } from './atirc/atirc-cubit.js' import { SocialwebCubit, sharedSessions } from './socialweb-cubit.js' import { pageClientId, requestScope } from 'atlibs/oauth/index.js' import { createPluginContext } from 'atlibs/plugin-context/index.js' import { localModule } from 'atlibs/run-module/index.js' // Wiring only: user events go to the cubits, cubit state goes to the views. const T = 'com.publicdomainrelay.temp'; const ATIRC = 'did:web:irc.fedfork.com%23atirc'; const POLL_MS = 5000; const WRITES = ['compute.vm', 'market.rfp', 'market.accept', 'market.event', 'compute.events.vm.delete', 'compute.events.guest.signalWebRTC', 'compute.events.guest.publicKey']; const RPCS = ['market.submitRfp', 'market.submitAccept', 'market.submitEvent', 'requester.associateConfirm']; // Module sessions are scope subsets of the page's one OAuth session; agents needs both. const POLICY = { sessions: { requester: { label: 'compute (socialweb.computer requester)', scope: [`repo:${T}.badgeBlueKeys?action=create`, ...WRITES.map((c) => `repo:${T}.${c}?action=create&action=delete`), ...RPCS.map((m) => `rpc:${T}.${m}?aud=*`)].join(' '), }, atirc: { label: 'atirc (irc.fedfork.com)', scope: `rpc:chat.atirc.list?aud=${ATIRC} rpc:chat.atirc.post?aud=${ATIRC}` }, }, features: { agents: { requires: ['requester', 'atirc'] } }, }; const files = (dir, name) => { const base = new URL(`./${dir}/modules/`, import.meta.url); return localModule(new URL(`${name}.wasm`, base), new URL(`${name}.json`, base)); }; const requester = files('webrtc', 'requester'); const atircModule = files('atirc', 'atirc'); // On localhost the loopback client; deployed, the composite client `atlibs stage --target spa --name // socialweb-computer-ui requester atirc` wrote next to the page. const client = pageClientId(requestScope(POLICY, Object.keys(POLICY.sessions))).then((clientId) => BrowserOAuthClient.load({ clientId, handleResolver: 'https://bsky.social' })); const sessions = sharedSessions(client, POLICY); const makeAgent = (session) => new Agent(session); const compute = new WebrtcCubit({ client: Promise.resolve(sessions.client('requester')), makeAgent, secp256k1, age, makePeer: (config) => new RTCPeerConnection(config), ssh: { ...devTunnelsSsh, Buffer }, module: requester, // the attestation key (bind_key once per browser origin) lives in this secret setting // managed_records: the rkey ledger, so the requester's cleanup may delete the records it created here ctx: createPluginContext({ id: 'socialweb-computer-ui', storage: { managed_records: {} }, settingsSchema: { attestKeys: { type: 'secret' } } }), }); const atirc = new AtircCubit({ client: Promise.resolve(sessions.client('atirc')), makeAgent, module: atircModule, ctx: createPluginContext({ id: 'atirc' }) }); const cubit = new SocialwebCubit({ sessions, policy: POLICY, atirc, compute }); Object.assign(globalThis, { swcCubit: cubit, webrtcCubit: compute, atircCubit: atirc }); const show = (id, on) => (document.getElementById(id).style.display = on ? "block" : "none"); async function init() { const names = ["swc-sessions", "swc-channel", "atirc-post-form", "requester-actions", "webrtc-connect", "webrtc-terminal"]; await Promise.all(names.map((name) => customElements.whenDefined(name))); const [sessionsView, channel, post, actions, connect, terminal] = names.map((name) => document.querySelector(name)); sessionsView.setPolicy(POLICY); actions.setActions((await requester).actions); const on = (type, fn) => document.addEventListener(type, (e) => fn(e.detail ?? {})); on("login", ({ username, names }) => cubit.login(username, names)); on("set-sessions", ({ names }) => cubit.setSessions(names)); on("announce", () => cubit.announce()); on("allow-agent-requests", ({ allow }) => cubit.allowAgentRequests(allow)); on("approve", ({ seq }) => cubit.approve(seq)); on("set-channel", ({ channel }) => atirc.setChannel(channel.trim())); on("post-line", ({ text }) => atirc.post(text)); on("run-entry", ({ entry, input }) => compute.run(entry, input).catch(() => undefined)); on("find-guest", ({ receipt, tag }) => compute.find(receipt, tag)); on("create-offer", () => compute.offer()); on("wait-answer", () => compute.answer()); on("send-line", ({ line }) => compute.send(line)); on("ssh-key", () => compute.sshKey()); document.getElementById("logout-nav").onclick = (e) => { e.preventDefault(); cubit.logout(); }; // Every view gets all three states; each renders its slice. let previous; const render = () => { const state = { ui: cubit.state, atirc: atirc.state, compute: compute.state }; show("loading", state.ui.session === "loading"); show("loading-error", state.ui.session === "error"); document.getElementById("loading-error").innerText = state.ui.error ?? ""; show("logout-nav", state.ui.session === "signed-in"); document.getElementById("whoami").innerText = state.ui.did ? `${atirc.state.handle ?? compute.state.handle ?? ""} ${state.ui.did}` : ""; show("compute-off", state.ui.session === "signed-in" && !state.ui.granted.includes("requester")); show("atirc-off", state.ui.session === "signed-in" && !state.ui.granted.includes("atirc")); sessionsView.render(state, previous); channel.render(state, previous); post.render(state.atirc, previous?.atirc); actions.render(state.compute, previous?.compute); connect.render(state.compute, previous?.compute); terminal.render(state.compute, previous?.compute); previous = state; }; for (const c of [cubit, atirc, compute]) c.subscribe(render); await cubit.init(); setInterval(() => atirc.state.session === "signed-in" && atirc.refresh(), POLL_MS); } // Modules run after parsing, and the imported components' top-level awaits finish after // DOMContentLoaded, so start now. init();