Commit f0219a66 authored by Kourser's avatar Kourser
Browse files

Une salle ne compte pas trois fois la même personne

La sortie d'une salle ne dépendait que d'une promesse du navigateur : le
bouton « Quitter ». Fermer l'onglet, recharger, changer d'écran — personne
ne quittait, la ligne de participation restait ouverte à jamais, et le
rail affichait « Kourser · Kourser · Kourser » pour une seule personne.

Le serveur média le savait pourtant déjà : l'identité qu'on lui donne est
l'identifiant de compte, et une seconde connexion sous la même identité
chasse la première. Notre table l'ignorait. Elle referme désormais les
participations restées ouvertes à l'entrée suivante — le seul moment où
l'on sait de source sûre que la précédente est finie. Cela répare aussi
les lignes déjà orphelines, à la prochaine venue.

Le verrou sur la salle n'est pas décoratif, et je ne l'avais pas mis. La
première version lisait les participations ouvertes hors transaction :
deux entrées lancées ensemble — un double-clic, un effet React rejoué, une
reprise réseau — voyaient toutes deux « personne » et en laissaient deux.
Le contrôle séquentiel était vert et le défaut restait entier à l'écran.
Il a fallu ouvrir une vraie visio pour le voir. La garantie qui manquait
lance maintenant les deux entrées en parallèle.

Côté navigateur, trois filets au lieu d'un : le serveur, qui ne dépend de
rien ; « pagehide » avec sendBeacon, qui survit à la fermeture de l'onglet
là où un démontage ne s'exécute pas ; et le démontage, pour la navigation
interne.

Le panneau gagne par ailleurs ce qu'on lui demandait. Une troisième taille
— il occupe l'écran, avec une marge qui rappelle que le rail attend
derrière : un appel déguisé en application plein écran fait oublier la
main courante. Le choix du micro et de la caméra, sans quitter l'appel, et
qui dit pourquoi la liste est vide quand le navigateur n'a pas
l'autorisation. Le partage d'écran, dont la piste arrive comme une tuile
de plus plutôt qu'à la place du visage : on veut voir le document et la
personne qui le commente.

Éprouvé sur une vraie visio locale : quatre cycles recharger-rejoindre, un
seul présent. 971 garanties.

Ce que je n'ai pas pu éprouver : la liste des périphériques réellement
peuplée, et le partage d'écran en action — le navigateur d'essai refuse la
capture.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent b6a244aa
Loading
Loading
Loading
Loading
Loading
+43 −2
Changes for apps/api/src/domaine/reunions.ts: 43 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -200,11 +200,52 @@ export async function rejoindre(
    nomAffiche, animateur,
  });

  await sql.begin((tx) => enregistrer(tx, {
  await sql.begin(async (tx) => {
    // ── Un même compte ne siège qu'une fois ───────────────────────────────
    //
    // Le serveur média le sait déjà : l'identité qu'on lui donne est
    // l'identifiant de compte, et une seconde connexion sous la même identité
    // chasse la première. Notre table l'ignorait.
    //
    // Chaque aller-retour y laissait donc une ligne ouverte de plus, et le rail
    // affichait le même nom trois fois pour une seule personne — parce que la
    // sortie ne dépendait que d'une promesse du navigateur : le bouton
    // « Quitter ». Fermer l'onglet, recharger, changer d'écran, et personne ne
    // quittait jamais.
    //
    // Le verrou sur la salle n'est pas décoratif. Sans lui, deux entrées
    // simultanées du même compte — un double-clic, un effet React rejoué, une
    // reprise réseau — lisent toutes deux « aucune participation ouverte »
    // avant que l'autre n'écrive la sienne, et en laissent deux. Éprouvé :
    // c'est exactement ce qui restait après une première version de ce
    // correctif qui lisait hors transaction.
    await tx`select 1 from salle
              where crise_id = ${criseId} and id = ${salleId} for update`;

    if (ctx.acteurId) {
      const ouvertes = await tx<{ id: string; rejoint_at: Date }[]>`
        select id, rejoint_at from participation
         where crise_id = ${criseId} and salle_id = ${salleId}
           and compte_id = ${ctx.acteurId} and quitte_at is null`;
      for (const o of ouvertes) {
        await enregistrer(tx, {
          criseId, type: "reunion.quittee",
          charge: {
            salle_id: salleId, participation_id: o.id,
            duree_secondes: Math.max(
              0, Math.round((Date.now() - o.rejoint_at.getTime()) / 1000)),
          },
          ctx,
        });
      }
    }

    await enregistrer(tx, {
      criseId, celluleId: salle.cellule_id, type: "reunion.rejointe",
      charge: { salle_id: salleId, participation_id: participationId, nom_affiche: nomAffiche },
      ctx,
  }));
    });
  });

  await publier({
    criseId, seq: null, genre: "reunion.rejointe",
+53 −0
Changes for apps/api/src/verification/media.ts: 53 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -64,6 +64,59 @@ export async function verifierMedia(
  r.verifie("EF-802", "la présence est visible depuis la salle",
    (await reunions.presents(sql, crise.criseId, salle)).length === 1);

  // ── Un compte ne siège qu'une fois ───────────────────────────────────────
  //
  // La sortie ne dépendait que du bouton « Quitter » du navigateur. Fermer
  // l'onglet, recharger, changer d'écran : personne ne quittait, la ligne
  // restait ouverte, et le rail affichait trois fois le même nom pour une
  // seule personne. Observé en salle.
  //
  // On rejoint donc à nouveau sans avoir quitté, exactement comme le fait un
  // rechargement de page, et l'on compte.
  const reentree = await reunions.rejoindre(
    sql, crise.criseId, salle, "Soaz Guillou", true, ctx);
  const apresReentree = await reunions.presents(sql, crise.criseId, salle);
  r.verifie("EF-802", "revenir sans avoir quitté ne dédouble pas la présence",
    apresReentree.length === 1, `${apresReentree.length} présents`);
  r.verifie("EF-802", "et c'est la venue la plus récente qui compte",
    apresReentree[0]?.id === reentree.participationId);

  // Et la simultanéité, qui est le cas réel.
  //
  // Une première version de ce correctif lisait les participations ouvertes
  // hors transaction : deux entrées lancées ensemble — un double-clic, un
  // effet React rejoué, une reprise réseau — voyaient toutes deux « personne »
  // et en laissaient deux. Le contrôle séquentiel ci-dessus était vert, et le
  // défaut restait entier à l'écran. C'est celui-ci qui l'a montré.
  await Promise.all([
    reunions.rejoindre(sql, crise.criseId, salle, "Soaz Guillou", true, ctx),
    reunions.rejoindre(sql, crise.criseId, salle, "Soaz Guillou", true, ctx),
  ]);
  const apresSimultane = await reunions.presents(sql, crise.criseId, salle);
  r.verifie("EF-802", "deux entrées simultanées n'en laissent qu'une",
    apresSimultane.length === 1, `${apresSimultane.length} présents`);

  // Deux personnes restent deux personnes : la déduplication porte sur le
  // compte, pas sur la salle.
  const autre = await comptes.inscrire(sql, {
    email: `visio+${randomUUID()}@exemple.test`, nom: "Ronan Le Dref",
    motDePasse: "cap-de-la-chevre-77", urlPublique: URL_TEST,
  });
  await sql`insert into membre (organisation_id, compte_id, role)
            values (${organisationId}, ${autre.compteId}, 'membre')`;
  const figurant = await reunions.rejoindre(sql, crise.criseId, salle, "Ronan Le Dref", false,
    { acteurId: autre.compteId, origine: "humaine" });
  r.verifie("EF-802", "deux comptes distincts restent deux présents",
    (await reunions.presents(sql, crise.criseId, salle)).length === 2);

  // Il ressort : la suite éprouve l'enregistrement, qui retient qui était là.
  // Laisser un figurant en salle fausserait ce compte — et c'est justement la
  // sorte de laisser-aller que ce lot corrige.
  await reunions.quitter(sql, crise.criseId, figurant.participationId,
    { acteurId: autre.compteId, origine: "humaine" });
  r.verifie("EF-802", "et quitter retire bien de la liste",
    (await reunions.presents(sql, crise.criseId, salle)).length === 1);

  // ── Enregistrement ───────────────────────────────────────────────────────
  r.titre("Enregistrement");
  const enr = await reunions.demarrerEnregistrement(sql, crise.criseId, salle, ctx);
+155 −21
Changes for apps/web/src/coque/PanneauVisio.tsx: 155 added lines, 21 removed lines.
Original line number Diff line number Diff line
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
  Room, RoomEvent, Track, type RemoteTrack, type RemoteParticipant,
} from "livekit-client";
@@ -27,11 +27,15 @@ export interface Appel {
}

interface Tuile {
  identite: string;
  /** Distincte par participant ET par source : un écran partagé est une tuile. */
  cle: string;
  nom: string;
  piste: RemoteTrack | null;
  ecran: boolean;
}

type Taille = "reduit" | "flottant" | "agrandi";

function PanneauVisio({
  criseId, appel, raccrocher,
}: { criseId: string; appel: Appel; raccrocher: () => void }) {
@@ -47,21 +51,35 @@ function PanneauVisio({
  /** Vrai quand l'appel avait été rejoint puis s'est rompu. */
  const [perdu, definirPerdu] = useState(false);
  const [camera, definirCamera] = useState(false);
  const [reduit, definirReduit] = useState(false);
  const [taille, definirTaille] = useState<Taille>("flottant");
  const [enregistre, definirEnregistre] = useState(false);
  const [participationId, definirParticipationId] = useState<string | null>(null);
  /** Le même identifiant, lisible depuis un nettoyage ou un « pagehide ». */
  const participation = useRef<string | null>(null);
  const [ecran, definirEcran] = useState(false);
  const [reglages, definirReglages] = useState(false);
  const [appareils, definirAppareils] = useState<MediaDeviceInfo[]>([]);
  const [choisis, definirChoisis] = useState<{ audioinput?: string; videoinput?: string }>({});
  const conteneurs = useRef(new Map<string, HTMLDivElement | null>());

  useEffect(() => {
    let vivant = true;

    const rafraichir = () => {
      const liste: Tuile[] = [...salle.remoteParticipants.values()].map(
        (p: RemoteParticipant) => ({
          identite: p.identity,
          nom: p.name || p.identity,
      const liste: Tuile[] = [];
      for (const p of salle.remoteParticipants.values() as Iterable<RemoteParticipant>) {
        const nom = p.name || p.identity;
        liste.push({
          cle: `${p.identity}:camera`, nom, ecran: false,
          piste: p.getTrackPublication(Track.Source.Camera)?.track ?? null,
        }));
        });
        // Un écran partagé ne remplace pas le visage de qui le partage : on
        // veut voir le document ET la personne qui le commente.
        const ecran = p.getTrackPublication(Track.Source.ScreenShare)?.track;
        if (ecran) {
          liste.push({ cle: `${p.identity}:ecran`, nom: `${nom} · écran`, piste: ecran, ecran: true });
        }
      }
      definirTuiles(liste);
    };

@@ -69,6 +87,8 @@ function PanneauVisio({
      .on(RoomEvent.ParticipantDisconnected, rafraichir)
      .on(RoomEvent.TrackSubscribed, rafraichir)
      .on(RoomEvent.TrackUnsubscribed, rafraichir)
      .on(RoomEvent.TrackPublished, rafraichir)
      .on(RoomEvent.TrackUnpublished, rafraichir)
      // Perdre un appel qu'on avait rejoint n'est pas la même chose que ne
      // jamais l'avoir joint. Le premier cas met en cause le chemin média —
      // UDP filtré, réseau qui bascule ; le second, l'accès au serveur. Les
@@ -89,6 +109,7 @@ function PanneauVisio({
        }>(`/api/crises/${criseId}/salles/${appel.salleId}/entree`);
        if (!vivant) return;
        definirParticipationId(entree.participationId);
        participation.current = entree.participationId;
        definirEnregistre(entree.enregistrementEnCours !== null);
        await salle.connect(entree.jeton.url, entree.jeton.jeton);
        if (!vivant) return;
@@ -141,24 +162,80 @@ function PanneauVisio({
    };
  }, [criseId, appel.salleId, salle]);

  /**
   * Quitter pour de bon, quel que soit le chemin de sortie.
   *
   * La sortie ne dépendait que du bouton « Quitter ». Fermer l'onglet,
   * recharger, changer d'écran : le panneau disparaissait sans que personne
   * ne quitte, la participation restait ouverte en base, et le nom s'empilait
   * dans le rail à chaque retour — trois « Kourser » pour une seule personne.
   *
   * Trois filets désormais, du plus fiable au moins. Le serveur referme les
   * participations restées ouvertes à l'entrée suivante : celui-là ne dépend
   * de rien. « pagehide » survit à la fermeture de l'onglet, là où un effet de
   * démontage ne s'exécute pas. Et le démontage couvre la navigation interne.
   */
  const sortir = useCallback(() => {
    const id = participation.current;
    if (!id) return;
    participation.current = null;
    const url = `/api/crises/${criseId}/participations/${id}/sortie`;
    // Un corps JSON explicite : « sendBeacon » sans données envoie un POST que
    // le serveur n'a aucune raison de savoir lire.
    const corps = new Blob(["{}"], { type: "application/json" });
    if (!navigator.sendBeacon?.(url, corps)) {
      void api.poster(url).catch(() => undefined);
    }
  }, [criseId]);

  useEffect(() => {
    window.addEventListener("pagehide", sortir);
    return () => {
      window.removeEventListener("pagehide", sortir);
      sortir();
    };
  }, [sortir]);

  useEffect(() => {
    for (const tuile of tuiles) {
      const cible = conteneurs.current.get(tuile.identite);
      const cible = conteneurs.current.get(tuile.cle);
      if (cible && tuile.piste) tuile.piste.attach(cible.querySelector("video")!);
    }
  }, [tuiles]);

  async function quitter() {
    await salle.disconnect();
    if (participationId) {
      await api.poster(`/api/crises/${criseId}/participations/${participationId}/sortie`)
        .catch(() => undefined);
    }
    sortir();
    raccrocher();
  }

  /**
   * Les périphériques, à la demande.
   *
   * On n'énumère qu'à l'ouverture du panneau : avant l'autorisation du
   * navigateur, la liste existe mais ses libellés sont vides — « micro » et
   * « micro » ne choisissent rien.
   */
  async function ouvrirReglages() {
    definirReglages(!reglages);
    if (reglages) return;
    try {
      definirAppareils(await Room.getLocalDevices());
    } catch {
      definirAppareils([]);
    }
  }

  async function basculer(genre: "audioinput" | "videoinput", deviceId: string) {
    await salle.switchActiveDevice(genre, deviceId);
    definirChoisis((c) => ({ ...c, [genre]: deviceId }));
  }

  const parGenre = (genre: MediaDeviceKind) =>
    appareils.filter((a) => a.kind === genre && a.deviceId);

  return (
    <div className={`visio${reduit ? " reduit" : ""}`}>
    <div className={`visio ${taille}`}>
      {geneMicro && etat === "connecte" && (
        <div className="gene">{geneMicro}</div>
      )}
@@ -172,10 +249,21 @@ function PanneauVisio({
      <div className="tete">
        <span>{appel.libelle}</span>
        <span className="pousse ligne" style={{ gap: 4 }}>
          <button className="discret petit" onClick={() => definirReduit(!reduit)}
            aria-label={reduit ? "Agrandir" : "Réduire"}>
            {reduit ? "▢" : "—"}
          </button>
          {taille !== "reduit" && (
            <button className="discret petit" onClick={() => definirTaille("reduit")}
              aria-label="Réduire" title="Réduire">—</button>
          )}
          {taille !== "agrandi" ? (
            <button className="discret petit" onClick={() => definirTaille("agrandi")}
              aria-label="Agrandir" title="Occuper l’écran">⤢</button>
          ) : (
            <button className="discret petit" onClick={() => definirTaille("flottant")}
              aria-label="Revenir au panneau flottant" title="Revenir au panneau flottant">⤡</button>
          )}
          {taille === "reduit" && (
            <button className="discret petit" onClick={() => definirTaille("flottant")}
              aria-label="Rétablir" title="Rétablir">▢</button>
          )}
          <button className="discret petit" onClick={() => void quitter()}
            aria-label="Quitter l’appel">✕</button>
        </span>
@@ -219,8 +307,8 @@ function PanneauVisio({
              <span className="nom">Vous{micro ? "" : " · micro coupé"}</span>
            </div>
            {tuiles.map((t) => (
              <div key={t.identite} className="tuile"
                ref={(el) => { conteneurs.current.set(t.identite, el); }}>
              <div key={t.cle} className={`tuile${t.ecran ? " ecran" : ""}`}
                ref={(el) => { conteneurs.current.set(t.cle, el); }}>
                {t.piste ? <video autoPlay playsInline /> : (
                  <span className="initiales">{initiales(t.nom)}</span>
                )}
@@ -244,10 +332,56 @@ function PanneauVisio({
            }}>
              {camera ? "Caméra" : "Caméra coupée"}
            </button>
            {/* Partager son écran vaut souvent mieux que le décrire : une carte,
                un tableau de bord, une main courante qu'on relit ensemble. */}
            <button className={ecran ? "accent" : ""} onClick={() => {
              void salle.localParticipant.setScreenShareEnabled(!ecran)
                .then(() => definirEcran(!ecran))
                .catch(() => definirGeneMicro(
                  "Le partage d’écran a été refusé par le navigateur."));
            }}>
              {ecran ? "Écran partagé" : "Partager l’écran"}
            </button>
            <button className={reglages ? "accent" : ""} onClick={() => void ouvrirReglages()}
              aria-expanded={reglages} title="Choisir le micro et la caméra">⚙</button>
            <span className="pousse discret" style={{ fontSize: 11.5 }}>
              {etat === "connexion" ? "connexion…" : `${tuiles.length + 1} en ligne`}
              {etat === "connexion" ? "connexion…" : `${salle.remoteParticipants.size + 1} en ligne`}
            </span>
          </div>

          {reglages && (
            <div className="appareils">
              {([["audioinput", "Micro"], ["videoinput", "Caméra"]] as const).map(
                ([genre, libelle]) => {
                  const liste = parGenre(genre);
                  return (
                    <div className="champ" key={genre} style={{ marginBottom: 8 }}>
                      <label htmlFor={`app-${genre}`}>{libelle}</label>
                      {liste.length === 0 ? (
                        <div className="aide">
                          Aucun périphérique lu. Autorisez le micro et la caméra, puis
                          rouvrez ce panneau : sans autorisation, le navigateur en cache
                          jusqu’aux noms.
                        </div>
                      ) : (
                        <select id={`app-${genre}`} value={choisis[genre] ?? ""}
                          onChange={(e) => void basculer(genre, e.target.value)}>
                          <option value="" disabled>Choisir…</option>
                          {liste.map((a) => (
                            <option key={a.deviceId} value={a.deviceId}>
                              {a.label || `${libelle} sans nom`}
                            </option>
                          ))}
                        </select>
                      )}
                    </div>
                  );
                })}
              <div className="aide" style={{ marginTop: 2 }}>
                Le changement prend effet tout de suite, sans quitter l’appel.
              </div>
            </div>
          )}
        </>
      )}
    </div>
+24 −0
Changes for apps/web/src/styles.css: 24 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -317,6 +317,30 @@ label { display: block; font-size: 12px; font-weight: 600; color: var(--encre-2)
  display: flex; flex-direction: column;
}
.visio.reduit { width: 232px; }

/* Occuper l'écran, sans le prendre tout entier : la marge rappelle qu'on est
   toujours dans la crise, et que le rail attend derrière. Un appel qui se
   déguise en application plein écran fait oublier la main courante. */
.visio.agrandi {
  inset: 16px; right: 16px; bottom: 16px; width: auto;
  max-height: none; border-radius: 8px;
}
.visio.agrandi .tuiles {
  flex: 1; min-height: 0; align-content: start;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  overflow-y: auto;
}
.visio.agrandi .tuile { aspect-ratio: 16 / 9; }
.visio.agrandi .initiales { width: 64px; height: 64px; font-size: 20px; }

/* Un écran partagé se lit en entier : le rogner pour remplir la tuile coupe
   précisément la colonne qu'on voulait montrer. */
.visio .tuile.ecran { background: #000; }
.visio .tuile.ecran video { object-fit: contain; }

.visio .appareils {
  padding: 10px; border-top: 1px solid var(--ligne); background: var(--surface-2);
}
.visio .tete {
  display: flex; align-items: center; gap: 8px; padding: 8px 10px;
  background: var(--surface-2); border-bottom: 1px solid var(--ligne);