// A remote's session credential is the HttpOnly cookie /pair set, held by Chromium's cookie jar, never by this file. const { englishText } = require("./native-text.cjs"); const LOCAL_ID = "local"; const MAX_NAME = 60; /** A saved server's colour: one of these names, never a value a page could style with. The renderer maps each to its skin's tone. */ const ENTRY_COLORS = Object.freeze(["blue", "teal", "purple", "orange", "red", "yellow"]); const cleanColor = (value) => (ENTRY_COLORS.includes(value) ? value : null); /** A team's Cloud is listed by the verified session (`team`: its id and this person's role), never by a page. */ const TEAM_ROLES = Object.freeze(["owner", "admin", "member"]); const TEAM_ROLE_LABELS = Object.freeze({ owner: "native.teamRole.owner", admin: "native.teamRole.admin", member: "native.teamRole.member" }); const teamRoleLabel = (role, t = englishText.t) => t(TEAM_ROLE_LABELS[role] ?? TEAM_ROLE_LABELS.member); const cleanTeam = (value) => (value && typeof value === "object" && typeof value.id === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value.id) && TEAM_ROLES.includes(value.role) ? { id: value.id, role: value.role } : null); /** A Cloud of the person's own besides My Cloud is listed by the verified session too (`cloud`: its id and their role). */ const cleanCloud = (value) => (value && typeof value === "object" && typeof value.id === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value.id) && TEAM_ROLES.includes(value.role) ? { id: value.id, role: value.role } : null); /** A place the Admin lists and removes: a team's Cloud, or another Cloud of the person's own. */ const listedPlace = (entry) => Boolean(entry?.team || entry?.cloud); /** My Cloud first, then each Cloud and team, then saved servers. */ const placeRank = (entry, homeOrigin) => (entry.origin === homeOrigin ? 0 : listedPlace(entry) ? 1 : 2); function normalizeOrigin(input) { if (typeof input !== "string") return null; let url; try { url = new URL(input.trim()); } catch { return null; } if (url.protocol !== "https:" && url.protocol !== "http:") return null; if (url.username || url.password) return null; return url.origin; } /** Remote renderers learn only their current workspace, never the local list. */ function workspaceSummary(state) { const active = activeEnvironment(state); if (!active) return { local: true, name: "This computer" }; if (active.kind === "local") return { local: true, name: active.name, localPath: active.dataDir, missing: active.missing === true }; return { local: false, name: active.name, origin: active.origin, ...(cleanColor(active.color) ? { color: active.color } : {}), ...(active.team ? { role: teamRoleLabel(active.team.role) } : {}) }; } /** Native identity must not depend on a hosted renderer's version/title. `homeOrigin`: My Cloud once it is the person's * home: alone, it is simply the app; with a team or another place besides, the window names the place it shows. */ function workspaceWindowTitle(state, companion, homeOrigin = null, t = englishText.t) { if (companion) return t("native.title.connected", { server: companion.serverName, host: new URL(companion.endpoint).host }); const active = activeEnvironment(state); if (listedPlace(active)) return `OpenMausBot — ${active.name}`; if (active && active.kind !== "local" && active.origin !== homeOrigin) return t("native.title.hosted", { name: active.name, host: new URL(active.origin).host }); if (active && homeOrigin && active.origin === homeOrigin && otherPlaces(state, homeOrigin) > 0) return `OpenMausBot — ${active.name}`; return "OpenMausBot"; } /** Switching is a main-process action; a cloud page must not navigate itself onto the local bridge. */ function workspaceNavigationAllowed(url, state, localOrigin) { try { return new URL(url).origin === (activeEnvironment(state)?.origin ?? localOrigin); } catch { return false; } } /** Only the main window's main frame; being embedded in a saved server grants no host authority. */ function workspaceSenderAllowed(event, contents, state, localOrigin) { if (!contents || event?.sender !== contents || event?.senderFrame !== contents.mainFrame) return false; try { return workspaceNavigationAllowed(event.senderFrame.url, state, localOrigin); } catch { return false; } } /** Native menu choices, never renderer-supplied destinations or callbacks. `iconFor`: a saved colour's small dot, when main can draw one. * `places`: the one line saying which side has which bots, shown first; `copyToCloud`: the send line under the list. `foldSublabels`: where menus show no sublabels (Windows, Linux), My Cloud's status line * joins its name instead of vanishing. `locals`: the workspaces of this computer's own (local-profiles.mjs), each opened by * `onLocal(id)` (`shown`: the one on screen); `onNewLocal`: a new one, named on this computer's own page. */ function workspaceMenuTemplate(state, { onSwitch, onConnect, onForget, onAddCloud, sublabels = {}, iconFor = null, places = null, copyToCloud = null, foldSublabels = false, homeOrigin = null, locals = [], onLocal = null, onNewLocal = null, text = englishText }) { const active = activeEnvironment(state), { t } = text; const own = onLocal ? locals.map((local) => ({ id: `workspace-local-${local.id}`, label: local.label, type: "radio", checked: local.shown === true, click: () => onLocal(local.id) })) : []; const fresh = onNewLocal ? [{ id: "workspace-new-local", label: t("native.menu.newLocal"), click: onNewLocal }] : []; if (homeOrigin) return homeMenuTemplate(state, { onSwitch, onForget, sublabels, iconFor, foldSublabels, homeOrigin, own, fresh, t }); return [ ...(places ? [{ id: "workspace-places", label: places, enabled: false }, { type: "separator" }] : []), { id: "workspace-local", label: t("place.local"), type: "radio", checked: !active && !own.some((item) => item.checked), ...(iconFor && cleanColor(state.localColor) ? { icon: iconFor(state.localColor) } : {}), click: () => onSwitch(LOCAL_ID) }, ...own, ...[...state.environments].sort((a, b) => listedPlace(b) - listedPlace(a)).map((entry) => { const icon = iconFor && cleanColor(entry.color) ? iconFor(entry.color) : null; const status = Object.hasOwn(sublabels, entry.id) ? sublabels[entry.id] : entry.team ? teamRoleLabel(entry.team.role, t) : null; return { id: `workspace-${entry.id}`, label: foldSublabels && status ? `${entry.name} · ${status}` : entry.name, sublabel: status ?? (entry.kind === "local" ? entry.dataDir : new URL(entry.origin).host), ...(icon ? { icon } : {}), type: "radio", checked: entry.id === state.activeId, click: () => onSwitch(entry.id), }; }), ...(copyToCloud ? [{ id: "workspace-copy-to-cloud", label: copyToCloud.label, click: copyToCloud.click }] : []), ...(onAddCloud ? [{ id: "workspace-add-cloud", label: t("native.menu.addCloud"), sublabel: t("native.switcher.addCloudSub"), click: onAddCloud }] : []), { type: "separator" }, ...fresh, { id: "workspace-connect", label: t("native.menu.connect"), click: onConnect }, ...(active && !listedPlace(active) ? [{ id: "workspace-forget", label: t("native.switcher.forgetNamed", { name: active.name }), click: () => onForget(active.id) }] : []), ]; } /** Once My Cloud is the person's home: My Cloud, each Cloud of theirs and each team's Cloud (with this person's role), * then the other saved servers, each a whole place of its own; this computer is not one of them, and nothing here sends * or copies. A Cloud or a team is the Admin's to list and remove, so it has no Forget. */ function homeMenuTemplate(state, { onSwitch, onForget, sublabels, iconFor, foldSublabels, homeOrigin, own = [], fresh = [], t }) { const active = activeEnvironment(state); const places = state.environments.filter((entry) => entry.kind !== "local").sort((a, b) => placeRank(a, homeOrigin) - placeRank(b, homeOrigin)); const shown = own.some((item) => item.checked); return [ ...own, ...places.map((entry) => { const icon = iconFor && cleanColor(entry.color) ? iconFor(entry.color) : null; const status = Object.hasOwn(sublabels, entry.id) ? sublabels[entry.id] : entry.team ? teamRoleLabel(entry.team.role, t) : null; return { id: `workspace-${entry.id}`, label: foldSublabels && status ? `${entry.name} · ${status}` : entry.name, sublabel: status ?? new URL(entry.origin).host, ...(icon ? { icon } : {}), type: "radio", checked: entry.id === state.activeId && !shown, click: () => onSwitch(entry.id), }; }), ...(fresh.length ? [{ type: "separator" }, ...fresh] : []), ...(active && active.origin !== homeOrigin && !listedPlace(active) ? [{ type: "separator" }, { id: "workspace-forget", label: t("native.switcher.forgetNamed", { name: active.name }), click: () => onForget(active.id) }] : []), ]; } /** Places besides My Cloud the window can show once My Cloud is home: teams' Clouds and saved self-hosted servers. */ function otherPlaces(state, homeOrigin) { return state.environments.filter((entry) => entry.kind !== "local" && entry.origin !== homeOrigin).length; } /** A team's Cloud as a saved place: listed once at its address, under the team's name and this person's role (a server * saved by hand at that address becomes the team's entry; My Cloud's entry never does). The same state when nothing * changes. */ function withTeam(state, team, makeId) { const origin = normalizeOrigin(team?.origin), member = cleanTeam(team), name = cleanName(team?.name, ""); if (!origin || !member || !name) return state; const existing = state.environments.find((entry) => entry.origin === origin); if (existing?.home || (existing && existing.name === name && existing.team?.id === member.id && existing.team?.role === member.role)) return state; const next = { ...(existing ?? { id: makeId(), kind: "remote", origin }), name, team: member }; return { ...state, environments: existing ? state.environments.map((entry) => (entry === existing ? next : entry)) : [...state.environments, next] }; } /** * The teams the verified session lists, as saved places: one entry per team with a Cloud to open, under the team's name * and this person's role; a team the session no longer lists is gone, with its name in `left` so the window can say so * once (a team whose Cloud moved is not gone). Only a connected session's word changes anything (a check that failed * keeps the list as it was). When the place on screen was a team that is gone, My Cloud is active again (or this * computer, without one). * @returns {{ state: object, left: string[] }} */ function reconcileTeams(state, teams, makeId, homeOrigin = null) { if (!Array.isArray(teams)) return { state, left: [] }; const listed = new Set(), wanted = new Map(); for (const team of teams) { const member = cleanTeam(team), origin = normalizeOrigin(team?.machine?.origin); if (!member) continue; listed.add(member.id); if (origin && !wanted.has(origin)) wanted.set(origin, member.id); } const left = []; const kept = state.environments.filter((entry) => { if (!entry.team || wanted.get(entry.origin) === entry.team.id) return true; if (!listed.has(entry.team.id)) left.push(entry.name); return false; }); let next = kept.length === state.environments.length ? state : { ...state, environments: kept }; for (const team of teams) next = withTeam(next, { id: team?.id, role: team?.role, name: team?.name, origin: team?.machine?.origin }, makeId); if (next === state) return { state, left }; return { state: withActiveKept(next, homeOrigin), left }; } /** The place on screen that is gone: My Cloud is active again (or this computer, without one). */ function withActiveKept(state, homeOrigin) { const here = state.activeId === LOCAL_ID || state.environments.some((entry) => entry.id === state.activeId); const activeId = here ? state.activeId : state.environments.find((entry) => entry.origin === homeOrigin)?.id ?? LOCAL_ID; return activeId === state.activeId ? state : { ...state, activeId }; } /** Another Cloud of the person's own as a saved place: listed once at its address, under the name and colour the Admin * keeps for it (`color` undefined keeps the saved one; null is none). A server saved by hand at that address becomes the * Cloud's entry; My Cloud's entry and a team's never do. The same state when nothing changes. */ function withOwnCloud(state, cloud, makeId) { const origin = normalizeOrigin(cloud?.origin), owner = cleanCloud(cloud), name = cleanName(cloud?.name, ""); if (!origin || !owner || !name) return state; const existing = state.environments.find((entry) => entry.origin === origin); if (existing?.home || existing?.team) return state; const color = cloud.color === undefined ? cleanColor(existing?.color) : cleanColor(cloud.color); if (existing && existing.name === name && existing.cloud?.id === owner.id && existing.cloud?.role === owner.role && (cleanColor(existing.color) ?? null) === (color ?? null)) return state; const { color: _old, ...rest } = existing ?? { id: makeId(), kind: "remote", origin }; const next = { ...rest, name, ...(color ? { color } : {}), cloud: owner }; return { ...state, environments: existing ? state.environments.map((entry) => (entry === existing ? next : entry)) : [...state.environments, next] }; } /** The person's other own Clouds the verified session lists (cloud-list.mjs ownCloudPlaces), as saved places: one the * session no longer lists is gone. Null (an Admin without the list, or no verified word) changes nothing. */ function reconcileOwnClouds(state, clouds, makeId, homeOrigin = null) { if (!Array.isArray(clouds)) return state; const wanted = new Map(); for (const cloud of clouds) { const owner = cleanCloud(cloud), origin = normalizeOrigin(cloud?.origin); if (owner && origin && !wanted.has(origin)) wanted.set(origin, cloud); } const kept = state.environments.filter((entry) => !entry.cloud || wanted.get(entry.origin)?.id === entry.cloud.id); let next = kept.length === state.environments.length ? state : { ...state, environments: kept }; for (const cloud of wanted.values()) next = withOwnCloud(next, cloud, makeId); return next === state ? state : withActiveKept(next, homeOrigin); } function botList(names, text, shown = 2) { const clean = names.filter((name) => typeof name === "string" && name.trim()).map((name) => name.trim().replace(/\s+/g, " ").slice(0, 40)); if (clean.length <= shown + 1) return text.listFormat(clean); return text.t("native.switcher.andMore", { names: clean.slice(0, shown).join(", "), count: clean.length - shown }); } /** Bots, chats and skills live on each side by design; this says which side has which, from where the person is now. */ function placesLine({ cloudName = "My Cloud", onCloud, localBots, text = englishText }) { const names = botList(localBots, text), cloud = cloudName; if (onCloud) return names ? text.t("native.places.cloudWith", { cloud, names }) : text.t("native.places.cloudNone", { cloud }); return names ? text.t("native.places.hereWith", { cloud, names }) : text.t("native.places.hereNone", { cloud }); } /** One line under My Cloud: what of this computer's is not on it yet, with Send (Send to My Cloud, cloud-send.mjs), or * the way to the side-by-side page when nothing waits. */ function sendLine({ cloudName = "My Cloud", waiting }) { const total = waiting?.total ?? 0, bots = waiting?.bots ?? 0; if (!total) return null; const what = bots ? `${bots === 1 ? "1 bot" : `${bots} bots`}${total > bots ? ` and ${total - bots} more` : ""}` : total === 1 ? "1 change" : `${total} changes`; return `${what} not on ${cloudName} · Send`; } /** The switcher's extra options once the person has My Cloud: the places line, the send line, and the Cloud's status * beside its name where menus show no sublabels. Bot names come from this computer's own server; when it does not * answer at once the line is left out, never guessed. `home`: the bots moved and home is My Cloud, which the line says * only then, whichever place the window shows. `send`: what waits (main's last comparison) and its actions. */ async function placesMenuOptions({ cloud, home = false, homeOrigin = null, platform, port, send = null, text = englishText, localBots = () => fetch(`http://127.0.0.1:${port}/api/bots`, { signal: AbortSignal.timeout(700) }).then((response) => response.json()) }) { const foldSublabels = platform !== "darwin"; if (homeOrigin) return { foldSublabels, homeOrigin }; if (!cloud) return { foldSublabels }; let names = null; try { const listed = await localBots(); names = Array.isArray(listed?.bots) ? listed.bots.map((bot) => bot?.name) : null; } catch {} const waiting = sendLine({ cloudName: cloud.name, waiting: send?.waiting }); return { foldSublabels, ...(send ? { copyToCloud: waiting ? { label: waiting, click: send.onSend } : { label: `This computer and ${cloud.name}…`, click: send.onOpen } } : {}), ...(names ? { places: placesLine({ cloudName: cloud.name, onCloud: home === true, localBots: names, text }) } : {}), }; } function cleanName(value, fallback) { const name = typeof value === "string" ? value.trim().replace(/\s+/g, " ").slice(0, MAX_NAME) : ""; return name || fallback; } function nameFromOrigin(origin) { try { return new URL(origin).host; } catch { return origin; } } /** Absolute or nothing, by pattern (POSIX, drive, UNC): a path module would resolve against this app's cwd and platform. */ function cleanDataDir(value) { if (typeof value !== "string") return null; const dir = value.trim(); return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(dir) ? dir : null; } function dirBasename(dir) { const parts = pathKey(dir).replace(/\/+$/, "").split("/"); return parts[parts.length - 1] || dir; } /** Windows-shaped paths compare slash-normalized and case-insensitively, as Windows does; POSIX as written. */ function pathKey(dir) { let d = String(dir).replace(/\\/g, "/"); if (/^[A-Za-z]:\//.test(d) || d.startsWith("//")) d = d.toLowerCase(); return d; } /** Comparisons run on trailing-`/` paths so `/a/b` never matches `/a/bc`. */ function asDirPrefix(dir) { const d = pathKey(dir); return d.endsWith("/") ? d : `${d}/`; } function cleanId(entry) { return typeof entry?.id === "string" && /^[\w-]{1,64}$/.test(entry.id) && entry.id !== LOCAL_ID ? entry.id : null; } function parseEnvironments(raw) { let value; try { value = typeof raw === "string" ? JSON.parse(raw) : raw; } catch { return { environments: [], activeId: LOCAL_ID }; } const version = value?.version ?? 1; if (version !== 1 && version !== 2) return { environments: [], activeId: LOCAL_ID }; const list = Array.isArray(value?.environments) ? value.environments : []; const seen = new Set(); const environments = []; for (const entry of list) { const id = cleanId(entry); if (!id || seen.has(id)) continue; const dataDir = cleanDataDir(entry?.dataDir); if (entry?.kind === "local") { // The same normalized key withLocalEnvironment uses, so one folder never gets two owners. const dirKey = dataDir ? `dir:${asDirPrefix(dataDir)}` : null; if (!dataDir || seen.has(dirKey)) continue; seen.add(id); seen.add(dirKey); const color = cleanColor(entry?.color); environments.push({ id, kind: "local", name: cleanName(entry.name, dirBasename(dataDir)), dataDir, ...(color ? { color } : {}) }); continue; } const origin = normalizeOrigin(entry?.origin); if (!origin || (entry?.kind !== undefined && entry.kind !== "remote") || seen.has(origin)) continue; seen.add(id); seen.add(origin); const color = cleanColor(entry?.color), team = entry?.home === true ? null : cleanTeam(entry?.team); const cloud = entry?.home === true || team ? null : cleanCloud(entry?.cloud); environments.push({ id, kind: "remote", name: cleanName(entry?.name, nameFromOrigin(origin)), origin, ...(entry?.microphone === true ? { microphone: true } : {}), ...(color ? { color } : {}), ...(entry?.home === true ? { home: true } : {}), ...(team ? { team } : {}), ...(cloud ? { cloud } : {}) }); } const activeId = typeof value?.activeId === "string" && environments.some((e) => e.id === value.activeId) ? value.activeId : LOCAL_ID; const localColor = cleanColor(value?.localColor); return { environments, activeId, ...(localColor ? { localColor } : {}) }; } /** `localColor`: This computer's own colour, which has no entry in the list; absent for none. */ function serializeEnvironments(state) { const localColor = cleanColor(state.localColor); return JSON.stringify({ version: 2, environments: state.environments, activeId: state.activeId, ...(localColor ? { localColor } : {}) }, null, 2) + "\n"; } function withEnvironment(state, input, makeId) { const origin = normalizeOrigin(input?.origin); if (!origin) return state; const existing = state.environments.find((e) => e.origin === origin); if (existing) { const name = cleanName(input?.name, existing.name); const environments = state.environments.map((e) => (e === existing ? { ...e, kind: "remote", name } : e)); return { ...state, environments }; } const id = makeId(); const environments = [...state.environments, { id, kind: "remote", name: cleanName(input?.name, nameFromOrigin(origin)), origin }]; return { ...state, environments }; } /** One place's colour as the person chose it, or none (`color` null): a saved place's, or This computer's (LOCAL_ID). * A colour this app doesn't draw changes nothing. The same state when it is that colour already. */ function withPlaceColor(state, id, color) { const existing = savedPlace(state, id); if (!existing || (color !== null && !cleanColor(color)) || (cleanColor(existing.color) ?? null) === color) return state; if (id === LOCAL_ID) { const { localColor: _old, ...rest } = state; return color ? { ...rest, localColor: color } : rest; } const { color: _old, ...rest } = existing; const next = color ? { ...rest, color } : rest; return { ...state, environments: state.environments.map((entry) => (entry === existing ? next : entry)) }; } /** The place `id` names (the one on screen by default): a saved entry, or This computer as one, named and coloured. */ function savedPlace(state, id = state.activeId) { if (id === LOCAL_ID) return { id: LOCAL_ID, kind: "local", name: "This computer", ...(cleanColor(state.localColor) ? { color: state.localColor } : {}) }; return state.environments.find((entry) => entry.id === id) ?? null; } /** The colours the Admin keeps for My Cloud (`home`) and team Clouds (`teamId`), on their saved places: once a colour * was chosen there it is the one everywhere (none for slate); before that, what this app saved stays. */ function withListedColors(state, looks, homeOrigin = null) { let next = state; for (const look of Array.isArray(looks) ? looks : []) { const entry = next.environments.find((candidate) => (look?.home === true ? candidate.kind !== "local" && (candidate.home === true || (homeOrigin !== null && candidate.origin === homeOrigin)) : typeof look?.teamId === "string" && candidate.team?.id === look.teamId)); if (entry) next = withPlaceColor(next, entry.id, cleanColor(look.color)); } return next; } /** A saved server's name and colour, as its owner chose them (the Cloud's own setup); an invalid colour is left as it was. */ function withEnvironmentLook(state, origin, { name, color } = {}) { const key = normalizeOrigin(origin); const existing = key ? state.environments.find((e) => e.origin === key) : null; if (!existing) return state; const next = { ...existing, name: cleanName(name, existing.name), ...(cleanColor(color) ? { color } : {}) }; if (next.name === existing.name && next.color === existing.color) return state; return { ...state, environments: state.environments.map((e) => (e === existing ? next : e)) }; } function withLocalEnvironment(state, input, makeId, defaultDir) { const name = typeof input?.name === "string" ? input.name.trim().replace(/\s+/g, " ") : ""; if (!name || name.length > MAX_NAME) return { ok: false, error: "name" }; const dataDir = cleanDataDir(input?.dataDir); if (!dataDir) return { ok: false, error: "path" }; const candidate = asDirPrefix(dataDir); const locals = state.environments.filter((e) => e.kind === "local"); if (locals.some((e) => asDirPrefix(e.dataDir) === candidate)) return { ok: false, error: "duplicate" }; const insideKnown = (dir) => candidate.startsWith(asDirPrefix(dir)); const containsKnown = (dir) => asDirPrefix(dir).startsWith(candidate); if (locals.some((e) => insideKnown(e.dataDir) || containsKnown(e.dataDir))) return { ok: false, error: "nested" }; if (typeof defaultDir === "string" && defaultDir && insideKnown(defaultDir)) return { ok: false, error: "nested" }; const environments = [...state.environments, { id: makeId(), kind: "local", name, dataDir }]; return { ok: true, state: { ...state, environments } }; } function activeLocalDataDir(state, _defaultDir) { const active = activeEnvironment(state); return active?.kind === "local" && typeof active.dataDir === "string" ? active.dataDir : null; } function startupEnvironmentDir(state, defaultDir) { return activeLocalDataDir(state, defaultDir) ?? defaultDir; } /** A named environment whose folder is missing is never leased or created: boot on the default dir and flag it. */ function bootEnvironmentDir(state, defaultDir) { const active = activeEnvironment(state); if (active?.kind === "local" && typeof active.dataDir === "string" && active.missing === true) { return { dataDir: defaultDir, fellBack: true, missingDir: active.dataDir }; } return { dataDir: startupEnvironmentDir(state, defaultDir), fellBack: false, missingDir: null }; } function withoutEnvironment(state, id) { const environments = state.environments.filter((e) => e.id !== id); return { ...state, environments, activeId: state.activeId === id ? LOCAL_ID : state.activeId }; } function withMicrophone(state, id) { if (!state.environments.some((e) => e.id === id)) return state; return { ...state, environments: state.environments.map((e) => (e.id === id ? { ...e, microphone: true } : e)) }; } function withActive(state, id) { if (id !== LOCAL_ID && !state.environments.some((e) => e.id === id)) return state; return { ...state, activeId: id }; } function activeEnvironment(state) { return state.environments.find((e) => e.id === state.activeId) ?? null; } /** Null for "This computer" and named local environments: branch on this, not activeEnvironment, for "a remote is on screen". */ function activeRemote(state) { const active = activeEnvironment(state); return active && active.kind !== "local" ? active : null; } function allowedOrigins(state, localOrigin) { return new Set([localOrigin, ...state.environments.map((e) => e.origin).filter(Boolean)]); } module.exports = { ENTRY_COLORS, TEAM_ROLES, teamRoleLabel, listedPlace, reconcileTeams, reconcileOwnClouds, withTeam, withOwnCloud, LOCAL_ID, activeEnvironment, activeLocalDataDir, activeRemote, allowedOrigins, bootEnvironmentDir, normalizeOrigin, otherPlaces, parseEnvironments, savedPlace, serializeEnvironments, startupEnvironmentDir, withActive, withEnvironment, withEnvironmentLook, withListedColors, withPlaceColor, withLocalEnvironment, withMicrophone, withoutEnvironment, workspaceMenuTemplate, placesLine, placesMenuOptions, sendLine, workspaceNavigationAllowed, workspaceSenderAllowed, workspaceSummary, workspaceWindowTitle, };