Ctrl K
ComunidadGuías…

electron/environments.cjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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,
};