diff --git a/README.md b/README.md index bf0732e..77ee315 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,21 @@ choses : - **Radio par profil** : une touche dédiée émet vers tous les joueurs ayant le même profil que vous, peu importe leur canal et leur position. +### Diffusion globale (rôle broadcaster) +Un joueur à qui l'admin a accordé le rôle **broadcaster** dispose d'une +touche PTT supplémentaire qui diffuse sa voix sur **tous les canaux +radio simultanément**, peu importe le canal des destinataires. Utile +pour les modérateurs et organisateurs d'événements RP qui doivent +adresser tout le serveur sans imposer un canal commun. + +L'admin gère la liste via la console admin (commandes +`grant_broadcaster ` / `revoke_broadcaster ` / +`list_broadcasters`). La capability est embarquée dans le ticket +d'authentification audio : la révocation devient effective au prochain +ticket (≤ 2 min en pratique). Un client connecté à un serveur trop +ancien (sans `server_caps: ["broadcast_all"]` dans le welcome) voit +simplement la touche sans effet — aucune trame n'est émise. + ### Mode RP (Roleplay) Quand activé, le filtre radio est appliqué sur la voip de proximité **uniquement si les deux joueurs portent leur casque dans Star @@ -138,6 +153,9 @@ Dans les paramètres du client, définissez : - **Touche radio (PTT)** : à maintenir pour parler sur votre canal radio - **Touche profile radio** : à maintenir pour parler à tous les joueurs de votre profil +- **Touche diffusion globale (PTT)** : à maintenir pour diffuser à tous + les canaux (réservée aux broadcasters — sans le rôle, la touche reste + silencieuse côté serveur) - **Cycle channel key** : pour changer rapidement de canal sans alt-tab N'importe quelle touche ou combinaison de 2 touches clavier ou bouton diff --git a/client/circusvoip_client.py b/client/circusvoip_client.py index 913deee..bf8c7d0 100644 --- a/client/circusvoip_client.py +++ b/client/circusvoip_client.py @@ -1062,8 +1062,8 @@ def _load_cfg() -> dict: "gamelog_path", # Mode RP "rp_mode", - # Hotkeys (8 raccourcis) - "radio_key", "profile_radio_key", + # Hotkeys (9 raccourcis) + "radio_key", "profile_radio_key", "broadcast_all_key", "mute_mic_key", "mute_prox_key", "mute_radio_key", "mute_all_key", "proximity_short_key", "cycle_channel_key", }) @@ -1275,6 +1275,11 @@ class State: last_radio_seen_ts: dict = {} profile_radio_key = None profile_radio_active = False + # [BROADCAST_ALL] PTT diffusion globale + capabilities serveur + broadcast_all_key = None + broadcast_all_active = False + server_supports_broadcast_all = False + is_broadcaster = False cycle_channel_key = None # Overlays overlays_show = False @@ -1432,6 +1437,24 @@ async def _ws_client(self, server_ip: str, name: str, token: str): except Exception: pass + # [BROADCASTER_AUTH] Si on a un token broadcaster sauvegarde + # pour ce serveur, on le presente. Sinon champ vide : le + # serveur ne donne can_broadcast=True que si nom + token + # correspondent. Le token a ete pushed par broadcaster_token_granted + # (cf _handle_message) lors d'un grant precedent. Per-server : + # indexe par "host:port" de sorte qu'un meme client puisse + # avoir des roles differents sur plusieurs serveurs. + bcast_token = "" + if _CORE_AVAILABLE: + try: + bcast_token = _core._get_broadcaster_token(server_ip, SERVER_PORT) + except Exception: + bcast_token = "" + # On garde aussi en memoire la cle serveur pour les push + # ulterieurs (granted/revoked), evite de re-deviner ip/port. + self._server_key_ip = server_ip + self._server_key_port = SERVER_PORT + # Envoi du join. channel=None car 2a ne gere pas les canaux # (sera ajoute en 2c). await ws.send(json.dumps({ @@ -1439,6 +1462,7 @@ async def _ws_client(self, server_ip: str, name: str, token: str): "name": name, "token": token, "channel": None, + "broadcaster_token": bcast_token, })) # Bug fix 56 : marquer connected=True UNIQUEMENT apres @@ -1506,6 +1530,46 @@ def _handle_message(self, data: dict, my_name: str): if reason == "invalid_token": self.sig_invalid_token.emit() self._stop_requested = True + elif reason in ("name_in_use", "broadcaster_token_invalid"): + # Echec d'auth specifique : on log et on coupe. Pas de retry + # auto (l'utilisateur doit changer son setup : nom different, + # ou demander un re-grant a l'admin). + self._stop_requested = True + return + + if msg_type == "broadcaster_token_granted": + # L'admin a accorde le role broadcaster a ce client. Le token clair + # est dans data["token"]. On le sauve indexe par le serveur courant + # pour qu'il soit represente automatiquement aux prochains join. + token = data.get("token", "") or "" + if token and _CORE_AVAILABLE: + try: + ip = getattr(self, "_server_key_ip", "") + port = getattr(self, "_server_key_port", SERVER_PORT) + _core._set_broadcaster_token(ip, port, token) + state.is_broadcaster = True + self.sig_log.emit( + "[NET] Role broadcaster accorde : token sauvegarde. " + "Reconnecte-toi pour activer la touche." + ) + except Exception as e: + self.sig_log.emit(f"[NET] broadcaster grant : sauvegarde KO : {e}") + return + + if msg_type == "broadcaster_revoked": + # L'admin a revoque le role. On efface le token local pour eviter + # un join refuse a la prochaine reconnexion (le nom redevient + # libre cote serveur). La revocation est aussi appliquee au + # ticket actuel a la prochaine emission par le serveur. + if _CORE_AVAILABLE: + try: + ip = getattr(self, "_server_key_ip", "") + port = getattr(self, "_server_key_port", SERVER_PORT) + _core._set_broadcaster_token(ip, port, "") + state.is_broadcaster = False + self.sig_log.emit("[NET] Role broadcaster revoque.") + except Exception as e: + self.sig_log.emit(f"[NET] broadcaster revoke : nettoyage KO : {e}") return if msg_type == "welcome": @@ -1542,6 +1606,13 @@ def _handle_message(self, data: dict, my_name: str): # reconnecte, on obtient un nouveau ticket et l'ancien est # ecrase (l'ancien ne vaut plus rien cote serveur). state.audio_ticket = data.get("audio_ticket", "") or "" + # [BROADCAST_ALL] Capabilities serveur + role broadcaster. + # Le client n'active sa touche PTT diffusion globale que si + # le serveur l'annonce dans server_caps ET que l'admin a + # accorde le role a ce joueur. Sinon : touche grisee. + server_caps = data.get("server_caps") or [] + state.server_supports_broadcast_all = "broadcast_all" in server_caps + state.is_broadcaster = bool(data.get("is_broadcaster", False)) except Exception as e: if _CORE_AVAILABLE: try: @@ -4873,9 +4944,10 @@ def _make_key_row(parent_layout, label_txt: str, kind_id: str): parent_layout.addLayout(h) return val_lbl - self.lbl_radio_key = _make_key_row(v_radio, "Radio canal (PTT) :", "radio") - self.lbl_profile_key = _make_key_row(v_radio, "Radio profil (PTT) :", "profile") - self.lbl_mute_mic_key = _make_key_row(v_radio, "Mute micro :", "mute_mic") + self.lbl_radio_key = _make_key_row(v_radio, "Radio canal (PTT) :", "radio") + self.lbl_profile_key = _make_key_row(v_radio, "Radio profil (PTT) :", "profile") + self.lbl_broadcast_all_key = _make_key_row(v_radio, "Diffusion globale (PTT) :", "broadcast_all") + self.lbl_mute_mic_key = _make_key_row(v_radio, "Mute micro :", "mute_mic") self.lbl_mute_prox_key = _make_key_row(v_radio, "Mute audio proximite :", "mute_prox") self.lbl_mute_radio_key = _make_key_row(v_radio, "Mute audio radio :", "mute_radio") self.lbl_mute_all_key = _make_key_row(v_radio, "Mute tout :", "mute_all") @@ -5525,14 +5597,15 @@ def _refresh_radio_key_labels(self): return # Liste de tuples (attr_label, attr_state) rows = [ - ("lbl_radio_key", "radio_key"), - ("lbl_profile_key", "profile_radio_key"), - ("lbl_mute_mic_key", "mute_mic_key"), - ("lbl_mute_prox_key", "mute_prox_key"), - ("lbl_mute_radio_key", "mute_radio_key"), - ("lbl_mute_all_key", "mute_all_key"), - ("lbl_prox_short_key", "proximity_short_key"), - ("lbl_cycle_ch_key", "cycle_channel_key"), + ("lbl_radio_key", "radio_key"), + ("lbl_profile_key", "profile_radio_key"), + ("lbl_broadcast_all_key", "broadcast_all_key"), + ("lbl_mute_mic_key", "mute_mic_key"), + ("lbl_mute_prox_key", "mute_prox_key"), + ("lbl_mute_radio_key", "mute_radio_key"), + ("lbl_mute_all_key", "mute_all_key"), + ("lbl_prox_short_key", "proximity_short_key"), + ("lbl_cycle_ch_key", "cycle_channel_key"), ] for lbl_attr, state_attr in rows: lbl = getattr(self, lbl_attr, None) @@ -5727,13 +5800,14 @@ def _capture_key(self, kind: str): return # kind -> (label dialog, attribut state, cle config) kinds = { - "radio": ("Radio canal (PTT)", "radio_key", "radio_key"), - "profile": ("Radio profil (PTT)", "profile_radio_key", "profile_radio_key"), - "mute_mic": ("Mute micro (toggle)", "mute_mic_key", "mute_mic_key"), - "mute_prox": ("Mute audio proximite", "mute_prox_key", "mute_prox_key"), - "mute_radio": ("Mute audio radio", "mute_radio_key", "mute_radio_key"), - "mute_all": ("Mute tout", "mute_all_key", "mute_all_key"), - "prox_short": ("Proximite 30m / 5m", "proximity_short_key", "proximity_short_key"), + "radio": ("Radio canal (PTT)", "radio_key", "radio_key"), + "profile": ("Radio profil (PTT)", "profile_radio_key", "profile_radio_key"), + "broadcast_all": ("Diffusion globale (PTT)", "broadcast_all_key", "broadcast_all_key"), + "mute_mic": ("Mute micro (toggle)", "mute_mic_key", "mute_mic_key"), + "mute_prox": ("Mute audio proximite", "mute_prox_key", "mute_prox_key"), + "mute_radio": ("Mute audio radio", "mute_radio_key", "mute_radio_key"), + "mute_all": ("Mute tout", "mute_all_key", "mute_all_key"), + "prox_short": ("Proximite 30m / 5m", "proximity_short_key", "proximity_short_key"), "cycle_channel": ("Cycle canal radio", "cycle_channel_key", "cycle_channel_key"), } if kind not in kinds: @@ -7504,6 +7578,7 @@ def _canon(k): return k # fallback : laisser la valeur brute state.radio_key = _canon(core_cfg.get("radio_key")) state.profile_radio_key = _canon(core_cfg.get("profile_radio_key")) + state.broadcast_all_key = _canon(core_cfg.get("broadcast_all_key")) state.mute_mic_key = _canon(core_cfg.get("mute_mic_key")) state.mute_prox_key = _canon(core_cfg.get("mute_prox_key")) state.mute_radio_key = _canon(core_cfg.get("mute_radio_key")) @@ -7987,6 +8062,7 @@ def closeEvent(self, event): "zone_source", "radio_key", "profile_radio_key", + "broadcast_all_key", "mute_mic_key", "mute_prox_key", "mute_radio_key", diff --git a/client/circusvoip_core.py b/client/circusvoip_core.py index 461a82c..f96810d 100644 --- a/client/circusvoip_core.py +++ b/client/circusvoip_core.py @@ -414,6 +414,43 @@ def _save_client_cfg(cfg): CLIENT_CONFIG_FILE.write_text(json.dumps(cfg, indent=2)) +# ----- Broadcaster tokens : per-server, stockes dans le config client ----- +# Format : {"broadcaster_tokens": {"host:port": ""}} +# Le token est emis par le serveur via push broadcaster_token_granted apres +# que l'admin a accorde le role. Le client le sauve, le presente au join, +# et l'efface sur push broadcaster_revoked. Multi-serveur : un token distinct +# par serveur, isole par sa clef "host:port". + +def _server_key(ip: str, port) -> str: + """Clef d'index pour broadcaster_tokens. Stable a travers les reconnect.""" + return f"{(ip or '').strip().lower()}:{port}" + + +def _get_broadcaster_token(ip: str, port) -> str: + """Retourne le token broadcaster sauvegarde pour (ip, port), ou '' si absent.""" + cfg = _load_client_cfg() + tokens = cfg.get("broadcaster_tokens") or {} + if not isinstance(tokens, dict): + return "" + return tokens.get(_server_key(ip, port), "") or "" + + +def _set_broadcaster_token(ip: str, port, token: str): + """Sauvegarde le token broadcaster pour (ip, port). Token vide / None + supprime l'entree.""" + cfg = _load_client_cfg() + tokens = cfg.get("broadcaster_tokens") or {} + if not isinstance(tokens, dict): + tokens = {} + key = _server_key(ip, port) + if token: + tokens[key] = token + else: + tokens.pop(key, None) + cfg["broadcaster_tokens"] = tokens + _save_client_cfg(cfg) + + # ====================================================================== # Etat global partage # ====================================================================== @@ -519,6 +556,26 @@ class State: # Touche du PTT profil (similaire a radio_key) profile_radio_key = None profile_radio_active = False + # Touche du PTT diffusion globale (broadcaster). Quand maintenue, la + # voix est emise avec flag audio 0x03, relayee par le serveur audio + # a TOUS les clients quels que soient leurs canaux. Reservee aux + # joueurs ayant le role broadcaster (cf welcome.is_broadcaster). + broadcast_all_key = None + broadcast_all_active = False + # Etat negocie au welcome : + # server_supports_broadcast_all : True si le serveur expose 'broadcast_all' + # dans welcome.server_caps. Permet de griser la touche cote UI + # sur les anciens serveurs. + # is_broadcaster : True si l'admin a accorde le role au joueur courant + # ET que le token a ete verifie au join. + server_supports_broadcast_all = False + is_broadcaster = False + # Token broadcaster a presenter au join (recu via push admin + # broadcaster_token_granted, sauve dans le config). Per-server : indexe + # par "host:port". Charge au connect (cf NetWorker) et envoye dans le + # message join. Sans le bon token, un nom present dans la liste des + # broadcasters serveur est REFUSE au join. + broadcaster_token_for_current_server = "" # Touche pour cycler les canaux (descente, boucle en haut) cycle_channel_key = None # Overlays floating windows @@ -878,6 +935,9 @@ def __init__(self): # Idem pour le PTT profil (independant du release_gen radio classique) self._profile_release_gen = 0 self._profile_release_lock = threading.Lock() + # Idem pour le PTT diffusion globale (broadcaster, flag 0x03) + self._broadcast_release_gen = 0 + self._broadcast_release_lock = threading.Lock() def _on_radio_pressed(self): """Appele quand la touche/bouton radio est enfonce. @@ -1069,6 +1129,80 @@ def _delayed_off(): except Exception: pass + # ---- PTT diffusion globale (broadcaster, flag 0x03) ---- + # Au press : active broadcast_all_active + radio_active (pour que les + # trames audio sortent avec effet radio cote receveur). + # Au release : timer differe avec linger comme la radio classique. + # Pas de switch de canal, pas de callback UI metier : juste un beep et + # un flag d'etat. Le serveur audio enforce la capability ; le client + # n'envoie rien si server_supports_broadcast_all=False ou is_broadcaster=False + # (cf _on_audio_captured). + + def _on_broadcast_pressed_impl(self): + """Press de la touche PTT diffusion globale.""" + with self._broadcast_release_lock: + self._broadcast_release_gen += 1 + state.broadcast_all_active = True + # Activer radio_active : le rendu local (effet radio sur les autres) + # depend de ce flag dans la chaine audio. Eviter qu'une diffusion + # globale arrive en proximity et perde l'effet radio. + state.radio_active = True + try: + _dbg_log( + f"[BROADCAST PTT] press " + f"(supported={state.server_supports_broadcast_all}, " + f"role={state.is_broadcaster})" + ) + except Exception: + pass + if state.audio_io is not None: + try: + state.audio_io.set_gate_force_open(True) + state.audio_io.play_local_beep("press") + except Exception: + pass + + def _on_broadcast_released_impl(self): + """Release de la touche PTT diffusion globale, avec linger pour + ne pas couper net en cas de re-press immediat.""" + with self._broadcast_release_lock: + self._broadcast_release_gen += 1 + my_gen = self._broadcast_release_gen + try: + _dbg_log("[BROADCAST PTT] release") + except Exception: + pass + if state.audio_io is not None: + try: + state.audio_io.set_gate_force_open(False) + state.audio_io.force_gate_close() + state.audio_io.play_local_beep("release") + except Exception: + pass + n_dropped = _flush_audio_send_queue() + if n_dropped > 0: + try: + _dbg_log(f"[BROADCAST PTT] release flush: {n_dropped} trames jetees") + except Exception: + pass + + def _delayed_off(): + time.sleep(RADIO_RELEASE_LINGER_MS / 1000.0) + with self._broadcast_release_lock: + if my_gen != self._broadcast_release_gen: + return + state.broadcast_all_active = False + # Couper radio_active SAUF si une autre PTT est encore tenue + # (PTT radio classique ou PTT profil). Meme logique que pour + # le release du PTT profil. + rk = state.radio_key or "" + pk = state.profile_radio_key or "" + held_radio = rk and _combo_matches_pressed(rk, self._currently_pressed) + held_profile = pk and _combo_matches_pressed(pk, self._currently_pressed) + if not (held_radio or held_profile): + state.radio_active = False + threading.Thread(target=_delayed_off, daemon=True).start() + def _normalize_key(self, key): """Transforme un event pynput clavier en chaine comparable. Delegue a la fonction module-level _normalize_pynput_key pour que la @@ -1153,6 +1287,11 @@ def _check_ptt_press(self, key_str): if pk and _combo_matches_pressed(pk, self._currently_pressed, key_str): if not state.profile_radio_active: self._on_profile_radio_pressed_impl() + # PTT diffusion globale (broadcaster) + bk = state.broadcast_all_key or "" + if bk and _combo_matches_pressed(bk, self._currently_pressed, key_str): + if not state.broadcast_all_active: + self._on_broadcast_pressed_impl() def _check_ptt_release(self, key_str): """Verifie si une combo PTT vient d'etre rompue par le release @@ -1181,6 +1320,11 @@ def _check_ptt_release(self, key_str): if pk and state.profile_radio_active and \ not _combo_matches_pressed(pk, self._currently_pressed): self._on_profile_radio_released_impl() + # PTT diffusion globale : si actif et combo plus satisfaite -> release + bk = state.broadcast_all_key or "" + if bk and state.broadcast_all_active and \ + not _combo_matches_pressed(bk, self._currently_pressed): + self._on_broadcast_released_impl() def _on_press(self, key): n = self._normalize_key(key) @@ -1804,13 +1948,18 @@ async def _audio_ws_loop(ui): # 0x00 = proximity (sans PTT) # 0x01 = radio classique (PTT canal) # 0x02 = radio profil (PTT profil) + # 0x03 = diffusion globale (broadcaster, tous canaux) flag = payload[0] - is_radio_canal = (flag == 1) - is_radio_profil = (flag == 2) - is_radio = is_radio_canal or is_radio_profil # alias pour audio_io + is_radio_canal = (flag == 1) + is_radio_profil = (flag == 2) + is_broadcast_all = (flag == 3) + # Pour le rendu local (effet radio, set_user_volume), une + # diffusion globale est traitee comme une radio. Le seul + # ecart : pas de filtrage par canal/profil cote receveur. + is_radio = is_radio_canal or is_radio_profil or is_broadcast_all frame = payload[1:] - # Mute radio : coupe les 2 modes radio + # Mute radio : coupe les 3 modes radio (canal/profil/broadcast) if is_radio and state.mute_radio: continue # Mute proximity : coupe seulement les trames proximity @@ -1818,7 +1967,15 @@ async def _audio_ws_loop(ui): continue # FILTRAGE selon le mode : - if is_radio_canal: + if is_broadcast_all: + # Diffusion globale : pas de filtrage cote receveur. + # Le serveur audio a deja verifie que l'emetteur a la + # capability can_broadcast (cf circusvoip_audio_server.py + # autour de FLAG_BROADCAST_ALL). On note quand meme + # le timestamp pour dedup proximity (l'emetteur envoie + # aussi une trame 0x00 a cote pour les joueurs proches). + state.last_radio_seen_ts[sender] = time.monotonic() + elif is_radio_canal: # Filtre par CANAL : meme canal sinon on jette sender_ch = state.player_channels.get(sender) if state.my_channel != sender_ch: @@ -1963,16 +2120,21 @@ def _on_audio_captured(frame_np): On se contente de deposer la trame dans la queue ; l'envoi WS est fait par le task _audio_sender dans sa propre boucle asyncio. - 3 modes de transmission selon les PTT actifs : + 4 modes de transmission selon les PTT actifs (priorite descendante) : + - PTT diffusion globale (state.broadcast_all_active) : flag 0x03 + Reserve aux broadcasters. Si le serveur ne supporte pas la feature + (server_supports_broadcast_all=False) ou si le joueur n'a pas le + role (is_broadcaster=False), on ne tente pas : la trame serait + droppee par le serveur audio mais on evite le bruit reseau. - PTT profil (state.profile_radio_active) : flag 0x02 (radio profil) - PTT radio (state.radio_active sans profil) : flag 0x01 (radio canal) - Aucun : flag 0x00 (proximity) - Quand on est en PTT radio OU PTT profil, on envoie en plus une trame - proximity (0x00) en parallele SI un joueur est a portee. Ca permet aux - joueurs a cote (mais pas sur le canal/profil) d'entendre ma voix en - proximite. Le receveur depulique via state.last_radio_seen_ts (50ms). - Optimisation : pas de 2e flux si personne a portee. + Quand on est en PTT radio / profil / diffusion globale, on envoie en + plus une trame proximity (0x00) en parallele SI un joueur est a portee. + Ca permet aux joueurs a cote (mais pas sur le canal/profil) d'entendre + ma voix en proximite. Le receveur depulique via state.last_radio_seen_ts + (50ms). Optimisation : pas de 2e flux si personne a portee. """ if not state.audio_connected: return @@ -1989,7 +2151,14 @@ def _put(data): pass _audio_send_queue.put_nowait(data) - if state.profile_radio_active: + if (state.broadcast_all_active + and state.server_supports_broadcast_all + and state.is_broadcaster): + # PTT diffusion globale : flag 0x03 + (eventuellement) proximity + _put(b"\x03" + frame_bytes) + if _has_player_in_range(): + _put(b"\x00" + frame_bytes) + elif state.profile_radio_active: # PTT profil : flag 0x02 + (eventuellement) proximity _put(b"\x02" + frame_bytes) if _has_player_in_range(): diff --git a/server/circusvoip_admin.py b/server/circusvoip_admin.py index 699aa26..94b2fee 100644 --- a/server/circusvoip_admin.py +++ b/server/circusvoip_admin.py @@ -95,6 +95,7 @@ class State: # Etat repliquant celui du serveur (recu via admin_welcome + push events) channels = [] # list[str] profiles = [] # list[str] + broadcasters = [] # list[str] - role PTT diffusion globale (flag 0x03) players = {} # {name: {pos, channel, profile, helmet_on, prox_short}} anonymous_mode = False server_token = "" # token joueur (pour l'afficher dans l'UI admin) @@ -171,6 +172,7 @@ def _handle_message(ui, data: dict): # Etat initial state.channels = list(data.get("channels", [])) state.profiles = list(data.get("profiles", [])) + state.broadcasters = list(data.get("broadcasters", [])) state.anonymous_mode = bool(data.get("anonymous_mode", False)) state.server_token = data.get("server_token", "") state.players = {} @@ -276,6 +278,10 @@ def _handle_message(ui, data: dict): state.profiles = list(data.get("profiles", [])) ui.refresh_profiles() + elif msg_type == "broadcasters_list": + state.broadcasters = list(data.get("broadcasters", [])) + ui.refresh_broadcasters() + elif msg_type == "anonymous_mode": state.anonymous_mode = bool(data.get("active", False)) ui.refresh_anonymous() @@ -490,6 +496,43 @@ def _on_p_wheel(event): btn_add_prof.pack(fill="x", pady=(2, 8)) btn_add_prof.bind("", lambda e: self._add_profile()) + # Broadcasters : joueurs autorises a parler sur tous les canaux + # simultanement (PTT diffusion globale, flag audio 0x03). Liste + # tenue par l'admin via grant_broadcaster / revoke_broadcaster. + # + # Le serveur exige que le joueur soit connecte au moment du grant + # (le token est pushed via sa WS). On expose donc un dropdown des + # joueurs connectes (non deja broadcasters) directement dans le + # panneau, plutot qu'un dialog modal : moins de friction, et l'admin + # voit immediatement qui peut etre promu. + self._section(right, "BROADCASTERS") + self._broadcasters_frame = tk.Frame(right, bg=BG_PANEL) + self._broadcasters_frame.pack(fill="x", pady=2) + import tkinter.ttk as ttk + self._add_bc_frame = tk.Frame(right, bg=BG_PANEL) + self._add_bc_frame.pack(fill="x", pady=(4, 8)) + self._add_bc_var = tk.StringVar(value="") + self._add_bc_combo = ttk.Combobox( + self._add_bc_frame, + textvariable=self._add_bc_var, + values=[], + state="disabled", + width=20, + ) + self._add_bc_combo.pack(side="left", fill="x", expand=True, padx=(0, 4)) + self._add_bc_btn = tk.Label( + self._add_bc_frame, text="Accorder", + bg=BORDER, fg=BLUE, + font=("Courier", 9, "bold"), + padx=8, pady=4, cursor="hand2", + ) + self._add_bc_btn.pack(side="right") + self._add_bc_btn.bind( + "", lambda e: self._grant_selected_broadcaster() + ) + # Initialise vide ; sera peuple par _refresh_add_broadcaster_dropdown() + # appele depuis refresh_players / refresh_broadcasters. + # Token serveur en bas (info admin) self._section(right, "TOKEN JOUEUR") self._lbl_server_token = tk.Label(right, text="(non connecte)", @@ -605,6 +648,7 @@ def refresh_all(self): self.refresh_players() self.refresh_channels() self.refresh_profiles() + self.refresh_broadcasters() self.refresh_anonymous() self._lbl_server_token.config(text=state.server_token or "(non recu)") @@ -680,6 +724,38 @@ def _do(): self._refresh_all_player_profile_select() self._safe_after(_do) + def refresh_broadcasters(self): + """Reconstruit le panneau BROADCASTERS a partir de state.broadcasters. + Mirroring exact de refresh_channels/refresh_profiles : meme structure + de rang BG_ROW + bouton ✕ pour retirer.""" + def _do(): + for w in self._broadcasters_frame.winfo_children(): + w.destroy() + if not state.broadcasters: + tk.Label(self._broadcasters_frame, + text="(aucun broadcaster)\nLes broadcasters peuvent\n" + "parler sur tous les canaux\nsimultanement (PTT global).", + bg=BG_PANEL, fg=MUTED, font=("Courier", 8), + anchor="w", justify="left", padx=4).pack(fill="x") + return + for name in state.broadcasters: + row = tk.Frame(self._broadcasters_frame, bg=BG_ROW, + pady=2, padx=6) + row.pack(fill="x", pady=1) + tk.Label(row, text=name, bg=BG_ROW, fg=BLUE, + font=("Courier", 9, "bold"), anchor="w" + ).pack(side="left", fill="x", expand=True) + btn_del = tk.Label(row, text="✕", bg=BG_ROW, fg=RED, + font=("Courier", 9, "bold"), + cursor="hand2", padx=4) + btn_del.pack(side="left") + btn_del.bind("", + lambda e, n=name: self._remove_broadcaster(n)) + self._safe_after(_do) + # Sync du dropdown : un broadcaster en plus / en moins change les + # candidats au grant. + self._refresh_add_broadcaster_dropdown() + def refresh_players(self): def _do(): # Synchroniser : ajouter les nouveaux, retirer les partis @@ -697,6 +773,8 @@ def _do(): self._build_player_row(name) self._update_player_row(name) self._safe_after(_do) + # Un join/leave change les candidats au grant -> resync. + self._refresh_add_broadcaster_dropdown() def _build_player_row(self, name: str): row = tk.Frame(self._players_frame, bg=BG_ROW, pady=4, padx=8) @@ -876,6 +954,51 @@ def _remove_profile(self, name: str): parent=self.root): self._send_cmd({"cmd": "remove_profile", "name": name}) + def _refresh_add_broadcaster_dropdown(self): + """Met a jour les valeurs du dropdown "Accorder broadcaster" : + joueurs connectes qui ne sont pas deja broadcasters. Appele depuis + refresh_players et refresh_broadcasters pour rester en sync avec + l'etat. Si la liste est vide, on disable le widget.""" + if not hasattr(self, "_add_bc_combo"): + return + def _do(): + already = set(state.broadcasters) + connected = sorted(n for n in state.players.keys() + if n and n not in already) + try: + self._add_bc_combo["values"] = connected + if connected: + self._add_bc_combo["state"] = "readonly" + if self._add_bc_var.get() not in connected: + self._add_bc_var.set(connected[0]) + else: + self._add_bc_combo["state"] = "disabled" + self._add_bc_var.set("") + except Exception: + pass + self._safe_after(_do) + + def _grant_selected_broadcaster(self): + """Clic sur le bouton 'Accorder'. Le dropdown ne propose que des + joueurs connectes non deja broadcasters, donc on n'a rien a + re-valider cote client : on envoie la commande au serveur, qui + push le token via la WS du joueur cible.""" + name = (self._add_bc_var.get() or "").strip() + if not name: + return + self._send_cmd({"cmd": "grant_broadcaster", "name": name}) + + def _remove_broadcaster(self, name: str): + """Retire le role broadcaster. Effectif au prochain ticket du joueur + cible (~2 min). Pour revoquer immediatement, kick le joueur en plus.""" + if messagebox.askyesno( + "Retirer broadcaster", + f"Retirer le role broadcaster a '{name}' ?\n" + f"Effectif a sa prochaine reconnexion (TTL ticket ≤ 2 min).", + parent=self.root, + ): + self._send_cmd({"cmd": "revoke_broadcaster", "name": name}) + def _toggle_anonymous(self): self._send_cmd({"cmd": "set_anonymous_mode", "active": not state.anonymous_mode}) diff --git a/server/circusvoip_audio_server.py b/server/circusvoip_audio_server.py index fca6bcb..8ddd6ff 100644 --- a/server/circusvoip_audio_server.py +++ b/server/circusvoip_audio_server.py @@ -102,6 +102,14 @@ class State: clients = {} # websocket -> name + # [BROADCAST_ALL] Capabilities par-client extraites du ticket positions. + # Dict separe (plutot que de transformer clients en {ws: {name,caps}}) + # pour ne pas casser les lecteurs existants qui parcourent clients comme + # un simple ws -> name. + client_caps = {} # websocket -> {"can_broadcast": bool} + # [BROADCAST_ALL] Dernier timestamp ou un refus 0x03 a ete loggue, par ws. + # Sert a limiter le spam de logs si un client malveillant tient la touche. + _last_refusal_log = {} running = False bytes_total = 0 frames_total = 0 @@ -109,6 +117,13 @@ class State: state = State() +# [BROADCAST_ALL] Flag audio reserve a la PTT diffusion globale (tous canaux). +# Le serveur impose can_broadcast=True (lu dans le ticket) pour relayer +# une trame portant ce flag. Sinon la trame est jetee silencieusement (avec +# un log rate-limite pour aider au debug sans flooder). +FLAG_BROADCAST_ALL = 0x03 +_BROADCAST_REFUSAL_LOG_INTERVAL_SEC = 60.0 + # --------------------------------------------- # Serveur WebSocket # --------------------------------------------- @@ -149,6 +164,18 @@ async def handler(ws, ui): # ponctuel ne doit pas couper un joueur legitime. if not _audio_rate.allow(ws): continue + # [BROADCAST_ALL] Drop des trames flag 0x03 si l'emetteur + # n'a pas la capability. Sans ce filtre, n'importe quel + # client pourrait fabriquer une trame 0x03 et etre entendu + # sur tous les canaux radio simultanement (cf filtrage + # cote receveur dans circusvoip_core.py qui accepte 0x03 + # sans verifier le canal). On enforce ici parce que c'est + # le seul endroit ou on a a la fois la trame ET l'identite + # authentifiee de l'emetteur (via le ticket). + if (len(msg) >= 1 and msg[0] == FLAG_BROADCAST_ALL + and not state.client_caps.get(ws, {}).get("can_broadcast")): + _maybe_log_broadcast_refusal(ws, name, peer_ip, ui) + continue # Trame audio : relayer a tous les autres clients state.bytes_total += len(msg) state.frames_total += 1 @@ -195,8 +222,11 @@ async def handler(ws, ui): # [P4 - auth partagee] Exiger un ticket emis par le # serveur positions. Empeche un client d'arriver sur # l'audio sans etre passe par le serveur principal. + # verify_full() retourne aussi les capabilities embarquees + # dans le ticket (cf. AuthRegistry.issue(can_broadcast=)). ticket = data.get("audio_ticket", "") - ticket_name = _auth_registry.verify(ticket) + ticket_entry = _auth_registry.verify_full(ticket) + ticket_name = (ticket_entry or {}).get("name") if ticket_name is None: banned_now = _auth_lockout.record_failure(peer_ip) ui.log(f"REFUSE audio : ticket invalide ou expire " @@ -241,6 +271,14 @@ async def handler(ws, ui): # client annonce. Ferme l'usurpation de pseudo cote audio. name = ticket_name state.clients[ws] = name + # [BROADCAST_ALL] Stocke la capability au moment du join. + # Pas de re-evaluation pendant la session : si l'admin + # revoke pendant qu'un broadcaster est connecte, la + # revocation s'applique au prochain ticket (apres son + # prochain join au serveur positions). + state.client_caps[ws] = { + "can_broadcast": bool(ticket_entry.get("can_broadcast")), + } ui.log(f"JOIN audio : {name} ({len(state.clients)} client(s))") ui.refresh_clients() @@ -254,11 +292,27 @@ async def handler(ws, ui): finally: # [P5] Libere le bucket de rate limiting de ce client. _audio_rate.forget(ws) + # [BROADCAST_ALL] Libere la capability stockee + l'etat du log + # rate-limit pour ce ws. Sans pop, accumulation lente en memoire. + state.client_caps.pop(ws, None) + state._last_refusal_log.pop(ws, None) if ws in state.clients: n = state.clients.pop(ws) ui.log(f"LEAVE audio : {n} ({len(state.clients)} client(s))") ui.refresh_clients() + +def _maybe_log_broadcast_refusal(ws, name: str, peer_ip: str, ui): + """Loggue le refus d'une trame 0x03 (PTT diffusion globale) d'un client + non-broadcaster, en limitant a 1 log par minute par ws pour ne pas + flooder si un client tient la touche en continu (50 trames/s).""" + now = time.time() + last = state._last_refusal_log.get(ws, 0.0) + if now - last < _BROADCAST_REFUSAL_LOG_INTERVAL_SEC: + return + state._last_refusal_log[ws] = now + ui.log(f"REFUSE broadcast_all : {name} (ip {peer_ip}) - pas de role broadcaster") + async def _broadcast_binary(data: bytes, exclude=None): """Envoie une trame audio a tous les clients sauf l'emetteur.""" dead = [] diff --git a/server/circusvoip_security.py b/server/circusvoip_security.py index e9f0b7b..662e925 100644 --- a/server/circusvoip_security.py +++ b/server/circusvoip_security.py @@ -181,13 +181,19 @@ def __init__(self, path: Path, ttl_sec: float = 120.0): # ---- cote serveur positions : emission ---- - def issue(self, name: str, ticket: str): + def issue(self, name: str, ticket: str, **extra): """Enregistre un ticket valide pour `name`. Appele par le serveur - positions juste avant d'envoyer le welcome au client.""" + positions juste avant d'envoyer le welcome au client. + + Les kwargs supplementaires (ex: `can_broadcast=True`) sont stockes + tels quels dans l'entree du ticket et lisibles cote serveur audio + via verify_full(). Permet de propager des capabilities par-joueur + sans schema fixe.""" data = self._read_all() data[ticket] = { "name": name, "expires": time.time() + self.ttl_sec, + **extra, } self._prune(data) self._write_all(data) @@ -207,6 +213,14 @@ def revoke(self, ticket: str): def verify(self, ticket: str): """Verifie un ticket presente au serveur audio. Retourne le nom associe si le ticket est valide et non expire, sinon None.""" + entry = self.verify_full(ticket) + return None if entry is None else entry.get("name") + + def verify_full(self, ticket: str): + """Comme verify() mais retourne l'entree complete (nom, expires, + + tout champ supplementaire ajoute via issue(**extra)) ou None si + invalide/expire. Utilise quand le serveur audio doit lire des + capabilities par-joueur (ex: can_broadcast).""" if not ticket or not isinstance(ticket, str): return None data = self._read_all() @@ -215,7 +229,7 @@ def verify(self, ticket: str): return None if time.time() >= entry.get("expires", 0): return None - return entry.get("name") + return entry # ---- interne ---- diff --git a/server/circusvoip_server.py b/server/circusvoip_server.py index f7fd912..2a7fecd 100644 --- a/server/circusvoip_server.py +++ b/server/circusvoip_server.py @@ -19,6 +19,7 @@ """ import asyncio +import hashlib import json import secrets import socket @@ -325,6 +326,40 @@ def _debug_log_pos(name: str, pos: dict, ts_capture: float = None): _PROFILES_FILE = _BASE_DIR / "circusvoip_profiles.json" _profiles: list = [] +# Broadcasters : joueurs autorises a parler simultanement sur TOUS les canaux +# radio (PTT diffusion globale, flag audio 0x03). L'admin gere la liste via +# grant_broadcaster / revoke_broadcaster. La capability est propagee au serveur +# audio via le ticket (cf. AuthRegistry.issue(can_broadcast=)). +# +# Modele d'auth (cf. feat/broadcaster-token-auth) : +# _broadcasters : dict {name: sha256_hex_du_token} +# Le token clair est genere au grant, hashe ici, et pushed une seule fois au +# client cible via sa WebSocket. Le client le sauvegarde dans son config et +# le presente au join (champ "broadcaster_token"). Le serveur compare en +# temps constant. Sans le bon token, le nom est REFUSE au join (anti-impersonation). +_BROADCASTERS_FILE = _BASE_DIR / "circusvoip_broadcasters.json" +_broadcasters: dict = {} + + +def _hash_broadcaster_token(token: str) -> str: + """Hash SHA-256 hexa du token broadcaster. + On ne stocke jamais le token en clair cote serveur ; on ne peut donc pas + le re-emettre apres le grant initial (re-grant requis pour le renvoyer).""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def _verify_broadcaster_token(name: str, token: str) -> bool: + """Verifie en temps constant que `token` correspond au hash stocke pour `name`. + Renvoie False si le nom n'est pas dans _broadcasters, si le token est vide, + ou si le hash ne correspond pas.""" + if not name or not token or not isinstance(token, str): + return False + stored = _broadcasters.get(name) + if not stored: + return False + candidate = _hash_broadcaster_token(token) + return secrets.compare_digest(candidate, stored) + def _load_channels() -> list: """Charge la liste des canaux depuis le fichier JSON (liste de strings). @@ -416,9 +451,57 @@ def _save_profiles(): print(f"[PROFILES] Echec sauvegarde {_PROFILES_FILE.name} : {e}") +def _load_broadcasters() -> dict: + """Charge le mapping {name: hash} depuis circusvoip_broadcasters.json. + Retourne {} si absent (la capability est opt-in). + + Migration : l'ancien format etait une liste de noms (pas de token). Si on + detecte ce format, on log un warning et on retourne un dict vide : l'admin + doit re-grant chaque broadcaster pour generer leurs tokens. On ne migre + PAS en silence (cela donnerait l'illusion d'un setup sûr alors qu'aucun + token n'aurait ete distribue aux joueurs concernes).""" + try: + if _BROADCASTERS_FILE.exists(): + with open(_BROADCASTERS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + # Ancien format detecte : pas de tokens, re-grant requis. + old_names = [n for n in data if isinstance(n, str) and n.strip()] + if old_names: + print( + f"[BROADCASTERS] Ancien format detecte dans " + f"{_BROADCASTERS_FILE.name} ({len(old_names)} entree(s) : " + f"{old_names}). Les broadcasters DOIVENT etre re-grant " + f"pour generer leurs tokens. Liste videe." + ) + return {} + if isinstance(data, dict): + # Format actuel : {name: sha256_hex}. + clean = {} + for name, h in data.items(): + if (isinstance(name, str) and name.strip() + and isinstance(h, str) and len(h) == 64): + clean[name.strip()] = h + return clean + except Exception as e: + print(f"[BROADCASTERS] Echec chargement {_BROADCASTERS_FILE.name} : {e}") + return {} + + +def _save_broadcasters(): + """Persiste le mapping {name: hash} dans circusvoip_broadcasters.json. + Le hash uniquement est persiste, jamais le token en clair.""" + try: + with open(_BROADCASTERS_FILE, "w", encoding="utf-8") as f: + json.dump(_broadcasters, f, ensure_ascii=False, indent=2, sort_keys=True) + except Exception as e: + print(f"[BROADCASTERS] Echec sauvegarde {_BROADCASTERS_FILE.name} : {e}") + + # Initialisation : charger au demarrage du module _channels = _load_channels() _profiles = _load_profiles() +_broadcasters = _load_broadcasters() if _profiles and not _PROFILES_FILE.exists(): _save_profiles() @@ -542,6 +625,7 @@ async def _admin_session(ws): "type": "admin_welcome", "channels": list(_channels), "profiles": list(_profiles), + "broadcasters": sorted(_broadcasters), "players": players_state, "anonymous_mode": _anonymous_mode, # "server_token" volontairement retire pour ne pas l'exposer @@ -600,6 +684,23 @@ async def _admin_handle_cmd(ws, cmd: str, data: dict) -> tuple: ok = assign_profile(data.get("player", ""), data.get("profile")) return (ok, "" if ok else "joueur introuvable ou profil invalide") + if cmd == "grant_broadcaster": + ok, reason = await grant_broadcaster(data.get("name", "")) + return (ok, reason) + if cmd == "revoke_broadcaster": + ok, reason = await revoke_broadcaster(data.get("name", "")) + return (ok, reason) + if cmd == "list_broadcasters": + try: + await ws.send(json.dumps({ + "type": "admin_response", + "cmd": "list_broadcasters", + "ok": True, + "broadcasters": list_broadcasters(), + })) + except Exception: + pass + return (True, "") if cmd == "set_anonymous_mode": target = bool(data.get("active", False)) global _anonymous_mode @@ -746,6 +847,51 @@ async def handler(ws): name = data.get("name", f"Player_{len(clients)+1}") + # [UNIQUE NAME] Refuse si un client deja connecte porte le + # meme nom. Sans cela, n'importe qui peut se faire passer + # pour un autre joueur dans le chat / les canaux. Combine + # avec la verification du broadcaster_token, cela ferme + # aussi le scenario "deconnecte, reconnecte sous le nom + # d'un broadcaster connu pour usurper le role". + if _find_client_ws_by_name(name) is not None: + _log(f"REFUSE : nom deja utilise '{name}' (ip {peer_ip})", ORANGE) + try: + await ws.send(json.dumps({ + "type": "error", + "reason": "name_in_use", + "message": "Ce nom est deja utilise sur ce serveur", + })) + except Exception: + pass + await ws.close(code=1008, reason="name_in_use") + return + + # [BROADCASTER AUTH] Si le nom figure dans _broadcasters, le + # client DOIT presenter le broadcaster_token correct. Cela + # protege a la fois : + # - l'octroi du role (sans token, can_broadcast=False) + # - l'identite (un non-broadcaster ne peut pas usurper le + # nom d'un broadcaster connu) + # Distinction stricte entre les deux cas n'apporte rien : + # dans les deux cas le join est refuse, le client clarifie + # son setup et ressaie. + client_bcast_token = data.get("broadcaster_token", "") + if name in _broadcasters: + if not _verify_broadcaster_token(name, client_bcast_token): + _log(f"REFUSE : broadcaster_token invalide pour '{name}' " + f"(ip {peer_ip})", RED) + try: + await ws.send(json.dumps({ + "type": "error", + "reason": "broadcaster_token_invalid", + "message": "Ce nom est reserve a un broadcaster ; " + "broadcaster_token manquant ou invalide", + })) + except Exception: + pass + await ws.close(code=1008, reason="broadcaster_token_invalid") + return + # [P4 - auth partagee] Genere un ticket court pour ce # joueur. Le client le recevra dans le welcome et devra le # presenter au serveur audio. Sans ce ticket, le serveur @@ -769,7 +915,15 @@ async def handler(ws): "audio_ticket": audio_ticket, } # Enregistre le ticket dans le fichier partage avec l'audio. - _auth_registry.issue(name, audio_ticket) + # can_broadcast n'est True que si le nom est broadcaster + # ET que le token a ete verifie ci-dessus. Si le role est + # revoque pendant que le joueur est connecte, la revocation + # ne s'applique qu'au prochain ticket (TTL <= 120s). + _auth_registry.issue( + name, + audio_ticket, + can_broadcast=(name in _broadcasters), + ) _log(f"JOIN : {name} ({len(clients)} connecté(s))", GREEN) if _ui: _ui.add_player(name) @@ -797,6 +951,15 @@ async def handler(ws): "my_profile": None, # [P4] Ticket a renvoyer au serveur audio lors du join audio. "audio_ticket": audio_ticket, + # Capabilities serveur. Permet au client de detecter + # qu'il parle a un serveur recent et d'activer/desactiver + # les fonctionnalites correspondantes (ex: griser la + # touche PTT diffusion globale sur les vieux serveurs). + "server_caps": ["broadcast_all"], + # Indique si CE joueur a actuellement le role broadcaster. + # Le client peut ainsi activer/desactiver son UI sans avoir + # a interroger un admin. + "is_broadcaster": (name in _broadcasters), })) # Annoncer aux autres l'arrivee du nouveau await _broadcast(ws, json.dumps({ @@ -1230,6 +1393,98 @@ async def _do(): return True +def _broadcast_broadcasters_list_threadsafe(): + """Pousse la liste des broadcasters aux admins (et seulement aux admins). + Contrairement aux canaux/profils, la liste des broadcasters est une + info admin : pas la peine de l'exposer a tous les clients.""" + if _loop and _server_running: + async def _do(): + await _broadcast_admins(json.dumps({ + "type": "broadcasters_list", + "broadcasters": sorted(_broadcasters), + })) + try: + asyncio.run_coroutine_threadsafe(_do(), _loop) + except Exception: + pass + + +def _find_client_ws_by_name(player_name: str): + """Cherche dans `clients` la WebSocket associee au nom donne. + Renvoie None si aucune connexion active ne porte ce nom.""" + for ws_, info in clients.items(): + if info.get("name") == player_name: + return ws_ + return None + + +async def grant_broadcaster(player_name: str) -> tuple: + """Accorde le role broadcaster a `player_name` et lui push son token + via sa WebSocket. Renvoie (ok, reason). + + Le joueur DOIT etre connecte au moment du grant. C'est le seul canal + par lequel le token est transmis (jamais affiche en clair, jamais + persiste cote serveur autrement que sous forme de hash). Si le joueur + se deconnecte avant d'avoir sauvegarde le token cote client, le grant + doit etre refait.""" + player_name = (player_name or "").strip() + if not player_name: + return (False, "nom_vide") + target_ws = _find_client_ws_by_name(player_name) + if target_ws is None: + return (False, "player_must_be_connected") + # Token clair : 32 hex chars (16 octets d'entropie). Stocke uniquement + # son hash cote serveur ; le clair part vers le client une seule fois. + token = secrets.token_hex(16) + _broadcasters[player_name] = _hash_broadcaster_token(token) + _save_broadcasters() + # Push au client cible. On ne capture pas l'echec : si la WS est cassee + # entre temps, le grant reste valide cote serveur mais le client n'aura + # pas le token (re-grant requis). L'admin verra le succes dans la + # reponse mais le joueur ne pourra pas broadcast tant qu'il n'a pas + # le token sauvegarde et re-presente au join. + try: + await target_ws.send(json.dumps({ + "type": "broadcaster_token_granted", + "token": token, + })) + except Exception as e: + _log(f"Broadcaster grant : echec push WS a {player_name} : {e}", ORANGE) + _broadcast_broadcasters_list_threadsafe() + _log(f"Broadcaster accorde : {player_name} (token pushed)", GREEN) + return (True, "") + + +async def revoke_broadcaster(player_name: str) -> tuple: + """Retire le role broadcaster a `player_name`. Si le joueur est connecte, + lui push aussi un message broadcaster_revoked pour qu'il efface son + token cote client. La capability cessera d'etre accordee au prochain + ticket (TTL <= 120s). Idempotent.""" + player_name = (player_name or "").strip() + if not player_name: + return (False, "nom_vide") + if player_name in _broadcasters: + del _broadcasters[player_name] + _save_broadcasters() + target_ws = _find_client_ws_by_name(player_name) + if target_ws is not None: + try: + await target_ws.send(json.dumps({ + "type": "broadcaster_revoked", + })) + except Exception: + pass + _broadcast_broadcasters_list_threadsafe() + _log(f"Broadcaster revoque : {player_name}", ORANGE) + return (True, "") + + +def list_broadcasters() -> list: + """Retourne la liste triee des noms de broadcasters actuels (hashes + masques).""" + return sorted(_broadcasters.keys()) + + def assign_profile(player_name: str, profile_name) -> bool: """Assigne un profil a un joueur connecte (admin uniquement).""" if profile_name is not None and profile_name not in _profiles: