fix(call-to-play): key actors by stable peer identity
Participant maps and creator authorization previously used display names, so two peers left at the default Commander name collapsed into one participant and could exercise each other's creator controls through the normal client. Carry a stable actor_id separately from actor_name. The peer overwrites actor_id on every local publish, and live event envelopes are accepted only when the known peer, source, and event actor match. The frontend keys participants and authorization by actor_id while retaining actor_name for display. This follows the trusted-LAN model and is not cryptographic authentication against a hostile peer. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed - `just frontend-test` -- passed, 21 tests - `just build` -- passed - `just peer-cli-tests S48` -- passed - `git diff --cached --check` -- passed
This commit is contained in:
@@ -1773,7 +1773,8 @@ class Runner:
|
|||||||
create = {
|
create = {
|
||||||
"id": "s48-create",
|
"id": "s48-create",
|
||||||
"call_id": "s48-call",
|
"call_id": "s48-call",
|
||||||
"actor": "Alice",
|
"actor_id": "",
|
||||||
|
"actor_name": "Alice",
|
||||||
"at": now,
|
"at": now,
|
||||||
"action": {
|
"action": {
|
||||||
"Create": {
|
"Create": {
|
||||||
@@ -1787,14 +1788,16 @@ class Runner:
|
|||||||
rsvp = {
|
rsvp = {
|
||||||
"id": "s48-rsvp",
|
"id": "s48-rsvp",
|
||||||
"call_id": "s48-call",
|
"call_id": "s48-call",
|
||||||
"actor": "Bob",
|
"actor_id": "",
|
||||||
|
"actor_name": "Bob",
|
||||||
"at": now + 1,
|
"at": now + 1,
|
||||||
"action": "Rsvp",
|
"action": "Rsvp",
|
||||||
}
|
}
|
||||||
message = {
|
message = {
|
||||||
"id": "s48-message-event",
|
"id": "s48-message-event",
|
||||||
"call_id": "s48-call",
|
"call_id": "s48-call",
|
||||||
"actor": "Bob",
|
"actor_id": "",
|
||||||
|
"actor_name": "Bob",
|
||||||
"at": now + 2,
|
"at": now + 2,
|
||||||
"action": {
|
"action": {
|
||||||
"SendMessage": {
|
"SendMessage": {
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parses_call_to_play_event_command() {
|
fn parses_call_to_play_event_command() {
|
||||||
let parsed = parse_command_line(
|
let parsed = parse_command_line(
|
||||||
r#"{"cmd":"publish-call-to-play","event":{"id":"event-1","call_id":"call-1","actor":"Alice","at":1000,"action":{"Create":{"game_id":"game-1","max_players":4,"scheduled_for":null,"deadline":61000}}}}"#,
|
r#"{"cmd":"publish-call-to-play","event":{"id":"event-1","call_id":"call-1","actor_id":"","actor_name":"Alice","at":1000,"action":{"Create":{"game_id":"game-1","max_players":4,"scheduled_for":null,"deadline":61000}}}}"#,
|
||||||
)
|
)
|
||||||
.expect("command should parse");
|
.expect("command should parse");
|
||||||
|
|
||||||
@@ -412,6 +412,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(event.id, "event-1");
|
assert_eq!(event.id, "event-1");
|
||||||
assert_eq!(event.call_id, "call-1");
|
assert_eq!(event.call_id, "call-1");
|
||||||
|
assert_eq!(event.actor_name, "Alice");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ applied to that history, sent to the UI, and broadcast to every currently known
|
|||||||
peer. An incoming live event is applied once and sent to the UI without being
|
peer. An incoming live event is applied once and sent to the UI without being
|
||||||
rebroadcast, which prevents forwarding loops.
|
rebroadcast, which prevents forwarding loops.
|
||||||
|
|
||||||
|
Actors are keyed by the peer's stable ID and carry a separate display name. The
|
||||||
|
origin peer overwrites the actor ID on local actions, and live-event envelopes
|
||||||
|
must match the known sending peer. This prevents duplicate default usernames
|
||||||
|
from merging participants and protects creator controls from other normal
|
||||||
|
clients. It is not authentication against a hostile LAN peer; the QUIC setup
|
||||||
|
uses the project's trusted-LAN identity model.
|
||||||
|
|
||||||
`Hello` and `HelloAck` include each side's event history. This lets peers that
|
`Hello` and `HelloAck` include each side's event history. This lets peers that
|
||||||
join after a call was created reconstruct the same nominations, responses,
|
join after a call was created reconstruct the same nominations, responses,
|
||||||
RSVPs, chat, and terminal actions. The launcher reducer sorts the event stream
|
RSVPs, chat, and terminal actions. The launcher reducer sorts the event stream
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ impl CallToPlayStore {
|
|||||||
pub(crate) async fn publish(
|
pub(crate) async fn publish(
|
||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
event: CallToPlayEvent,
|
mut event: CallToPlayEvent,
|
||||||
) {
|
) {
|
||||||
|
event.actor_id.clone_from(ctx.peer_id.as_ref());
|
||||||
match ctx.call_to_play.write().await.insert(event.clone()) {
|
match ctx.call_to_play.write().await.insert(event.clone()) {
|
||||||
Ok(false) => return,
|
Ok(false) => return,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -71,11 +72,15 @@ pub(crate) async fn publish(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses();
|
let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses();
|
||||||
|
let peer_id = ctx.peer_id.clone();
|
||||||
ctx.task_tracker.spawn(async move {
|
ctx.task_tracker.spawn(async move {
|
||||||
let deliveries = peer_addresses.into_iter().map(|peer_addr| {
|
let deliveries = peer_addresses.into_iter().map(|peer_addr| {
|
||||||
let event = event.clone();
|
let event = event.clone();
|
||||||
|
let peer_id = peer_id.clone();
|
||||||
async move {
|
async move {
|
||||||
if let Err(err) = send_call_to_play_events(peer_addr, vec![event]).await {
|
if let Err(err) =
|
||||||
|
send_call_to_play_events(peer_addr, peer_id.as_ref(), vec![event]).await
|
||||||
|
{
|
||||||
log::warn!("Failed to send Call to Play event to {peer_addr}: {err}");
|
log::warn!("Failed to send Call to Play event to {peer_addr}: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,7 +92,8 @@ pub(crate) async fn publish(
|
|||||||
fn validate_event(event: &CallToPlayEvent) -> Result<(), &'static str> {
|
fn validate_event(event: &CallToPlayEvent) -> Result<(), &'static str> {
|
||||||
validate_nonempty(&event.id, MAX_ID_CHARS, "invalid event id")?;
|
validate_nonempty(&event.id, MAX_ID_CHARS, "invalid event id")?;
|
||||||
validate_nonempty(&event.call_id, MAX_ID_CHARS, "invalid call id")?;
|
validate_nonempty(&event.call_id, MAX_ID_CHARS, "invalid call id")?;
|
||||||
validate_nonempty(&event.actor, MAX_USERNAME_CHARS, "invalid actor")?;
|
validate_nonempty(&event.actor_id, MAX_ID_CHARS, "invalid actor id")?;
|
||||||
|
validate_nonempty(&event.actor_name, MAX_USERNAME_CHARS, "invalid actor name")?;
|
||||||
if event.at <= 0 {
|
if event.at <= 0 {
|
||||||
return Err("invalid event timestamp");
|
return Err("invalid event timestamp");
|
||||||
}
|
}
|
||||||
@@ -154,7 +160,8 @@ mod tests {
|
|||||||
CallToPlayEvent {
|
CallToPlayEvent {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
call_id: "call-1".to_string(),
|
call_id: "call-1".to_string(),
|
||||||
actor: "Alice".to_string(),
|
actor_id: "peer-alice".to_string(),
|
||||||
|
actor_name: "Alice".to_string(),
|
||||||
at: 1_000,
|
at: 1_000,
|
||||||
action: CallToPlayAction::Create {
|
action: CallToPlayAction::Create {
|
||||||
game_id: "game-1".to_string(),
|
game_id: "game-1".to_string(),
|
||||||
@@ -212,7 +219,7 @@ mod tests {
|
|||||||
.insert(create_event("event-1"))
|
.insert(create_event("event-1"))
|
||||||
.expect("valid event should be inserted");
|
.expect("valid event should be inserted");
|
||||||
let mut invalid = create_event("invalid");
|
let mut invalid = create_event("invalid");
|
||||||
invalid.actor.clear();
|
invalid.actor_name.clear();
|
||||||
|
|
||||||
let accepted = store.insert_all(vec![
|
let accepted = store.insert_all(vec![
|
||||||
create_event("event-1"),
|
create_event("event-1"),
|
||||||
|
|||||||
@@ -175,9 +175,17 @@ pub async fn send_goodbye(peer_addr: SocketAddr, peer_id: String) -> eyre::Resul
|
|||||||
|
|
||||||
pub async fn send_call_to_play_events(
|
pub async fn send_call_to_play_events(
|
||||||
peer_addr: SocketAddr,
|
peer_addr: SocketAddr,
|
||||||
|
peer_id: &str,
|
||||||
events: Vec<CallToPlayEvent>,
|
events: Vec<CallToPlayEvent>,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
send_oneway_request(peer_addr, Request::CallToPlayEvents { events }).await
|
send_oneway_request(
|
||||||
|
peer_addr,
|
||||||
|
Request::CallToPlayEvents {
|
||||||
|
peer_id: peer_id.to_string(),
|
||||||
|
events,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Requests game file details from a peer.
|
/// Requests game file details from a peer.
|
||||||
|
|||||||
@@ -313,7 +313,8 @@ mod tests {
|
|||||||
CallToPlayEvent {
|
CallToPlayEvent {
|
||||||
id: "event-1".to_string(),
|
id: "event-1".to_string(),
|
||||||
call_id: "call-1".to_string(),
|
call_id: "call-1".to_string(),
|
||||||
actor: "Alice".to_string(),
|
actor_id: "peer-alice".to_string(),
|
||||||
|
actor_name: "Alice".to_string(),
|
||||||
at: 1_000,
|
at: 1_000,
|
||||||
action: CallToPlayAction::Create {
|
action: CallToPlayAction::Create {
|
||||||
game_id: "game".to_string(),
|
game_id: "game".to_string(),
|
||||||
|
|||||||
@@ -90,14 +90,11 @@ async fn dispatch_request(
|
|||||||
handle_library_delta(ctx, peer_id, delta).await;
|
handle_library_delta(ctx, peer_id, delta).await;
|
||||||
framed_tx
|
framed_tx
|
||||||
}
|
}
|
||||||
Request::CallToPlayEvents { events: incoming } => {
|
Request::CallToPlayEvents {
|
||||||
let accepted = ctx.call_to_play.write().await.insert_all(incoming);
|
peer_id,
|
||||||
if !accepted.is_empty() {
|
events: incoming,
|
||||||
events::send(
|
} => {
|
||||||
&ctx.tx_notify_ui,
|
handle_call_to_play_events(ctx, remote_addr, &peer_id, incoming).await;
|
||||||
crate::PeerEvent::CallToPlayEvents(accepted),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
framed_tx
|
framed_tx
|
||||||
}
|
}
|
||||||
Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await,
|
Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await,
|
||||||
@@ -124,6 +121,40 @@ async fn dispatch_request(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_call_to_play_events(
|
||||||
|
ctx: &PeerCtx,
|
||||||
|
remote_addr: Option<SocketAddr>,
|
||||||
|
peer_id: &str,
|
||||||
|
incoming: Vec<lanspread_proto::CallToPlayEvent>,
|
||||||
|
) {
|
||||||
|
let peer_id = peer_id.to_string();
|
||||||
|
let sender_matches = if let Some(remote_addr) = remote_addr {
|
||||||
|
ctx.peer_game_db
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.peer_addr(&peer_id)
|
||||||
|
.is_some_and(|listen_addr| listen_addr.ip() == remote_addr.ip())
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
if !sender_matches {
|
||||||
|
log::warn!("Ignoring Call to Play events from unverified peer {peer_id}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if incoming.iter().any(|event| event.actor_id != peer_id) {
|
||||||
|
log::warn!("Ignoring Call to Play events with an actor that does not match {peer_id}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let accepted = ctx.call_to_play.write().await.insert_all(incoming);
|
||||||
|
if !accepted.is_empty() {
|
||||||
|
events::send(
|
||||||
|
&ctx.tx_notify_ui,
|
||||||
|
crate::PeerEvent::CallToPlayEvents(accepted),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn note_peer_activity(ctx: &PeerCtx, remote_addr: Option<SocketAddr>) {
|
async fn note_peer_activity(ctx: &PeerCtx, remote_addr: Option<SocketAddr>) {
|
||||||
if let Some(addr) = remote_addr {
|
if let Some(addr) = remote_addr {
|
||||||
ctx.peer_game_db
|
ctx.peer_game_db
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ pub struct HelloAck {
|
|||||||
pub struct CallToPlayEvent {
|
pub struct CallToPlayEvent {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub call_id: String,
|
pub call_id: String,
|
||||||
pub actor: String,
|
pub actor_id: String,
|
||||||
|
pub actor_name: String,
|
||||||
pub at: i64,
|
pub at: i64,
|
||||||
pub action: CallToPlayAction,
|
pub action: CallToPlayAction,
|
||||||
}
|
}
|
||||||
@@ -111,6 +112,7 @@ pub enum Request {
|
|||||||
delta: LibraryDelta,
|
delta: LibraryDelta,
|
||||||
},
|
},
|
||||||
CallToPlayEvents {
|
CallToPlayEvents {
|
||||||
|
peer_id: String,
|
||||||
events: Vec<CallToPlayEvent>,
|
events: Vec<CallToPlayEvent>,
|
||||||
},
|
},
|
||||||
Goodbye {
|
Goodbye {
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ impl OutboundTransferEmitState {
|
|||||||
struct LanSpreadState {
|
struct LanSpreadState {
|
||||||
peer_ctrl: Arc<RwLock<Option<UnboundedSender<PeerCommand>>>>,
|
peer_ctrl: Arc<RwLock<Option<UnboundedSender<PeerCommand>>>>,
|
||||||
peer_runtime: Arc<RwLock<Option<PeerRuntimeHandle>>>,
|
peer_runtime: Arc<RwLock<Option<PeerRuntimeHandle>>>,
|
||||||
|
local_peer_id: Arc<RwLock<Option<String>>>,
|
||||||
games: Arc<RwLock<GameDB>>,
|
games: Arc<RwLock<GameDB>>,
|
||||||
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
|
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
|
||||||
games_folder: Arc<RwLock<String>>,
|
games_folder: Arc<RwLock<String>>,
|
||||||
@@ -277,14 +278,17 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn request_call_to_play_events(
|
async fn request_call_to_play_events(
|
||||||
state: tauri::State<'_, LanSpreadState>,
|
state: tauri::State<'_, LanSpreadState>,
|
||||||
) -> tauri::Result<bool> {
|
) -> tauri::Result<Option<String>> {
|
||||||
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
|
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
|
||||||
let Some(peer_ctrl) = peer_ctrl else {
|
let Some(peer_ctrl) = peer_ctrl else {
|
||||||
log::warn!("Peer system not initialized yet");
|
log::warn!("Peer system not initialized yet");
|
||||||
return Ok(false);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(peer_ctrl.send(PeerCommand::GetCallToPlayEvents).is_ok())
|
if peer_ctrl.send(PeerCommand::GetCallToPlayEvents).is_err() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(state.inner().local_peer_id.read().await.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -2205,6 +2209,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|||||||
match event {
|
match event {
|
||||||
PeerEvent::LocalPeerReady { peer_id, addr } => {
|
PeerEvent::LocalPeerReady { peer_id, addr } => {
|
||||||
log::info!("Local peer ready: {peer_id} at {addr}");
|
log::info!("Local peer ready: {peer_id} at {addr}");
|
||||||
|
*app_handle
|
||||||
|
.state::<LanSpreadState>()
|
||||||
|
.local_peer_id
|
||||||
|
.write()
|
||||||
|
.await = Some(peer_id);
|
||||||
}
|
}
|
||||||
PeerEvent::ListGames(games) => {
|
PeerEvent::ListGames(games) => {
|
||||||
log::info!("PeerEvent::ListGames received");
|
log::info!("PeerEvent::ListGames received");
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Game, Nomination } from '../../lib/types';
|
|||||||
interface Props {
|
interface Props {
|
||||||
nominations: ReadonlyArray<Nomination>;
|
nominations: ReadonlyArray<Nomination>;
|
||||||
games: ReadonlyArray<Game>;
|
games: ReadonlyArray<Game>;
|
||||||
username: string;
|
actorId: string | null;
|
||||||
actions: CallToPlayActions;
|
actions: CallToPlayActions;
|
||||||
focusId: string | null;
|
focusId: string | null;
|
||||||
transportReady: boolean;
|
transportReady: boolean;
|
||||||
@@ -24,7 +24,7 @@ interface Props {
|
|||||||
export const CallToPlayOverlay = ({
|
export const CallToPlayOverlay = ({
|
||||||
nominations,
|
nominations,
|
||||||
games,
|
games,
|
||||||
username,
|
actorId,
|
||||||
actions,
|
actions,
|
||||||
focusId,
|
focusId,
|
||||||
transportReady,
|
transportReady,
|
||||||
@@ -88,7 +88,7 @@ export const CallToPlayOverlay = ({
|
|||||||
key={nomination.id}
|
key={nomination.id}
|
||||||
nomination={nomination}
|
nomination={nomination}
|
||||||
game={game}
|
game={game}
|
||||||
username={username}
|
actorId={actorId}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
focused={nomination.id === focusId}
|
focused={nomination.id === focusId}
|
||||||
thumbnailUrl={getThumbnail(game.id)}
|
thumbnailUrl={getThumbnail(game.id)}
|
||||||
|
|||||||
@@ -33,14 +33,15 @@ const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number
|
|||||||
const entries = Object.entries(nomination.participants);
|
const entries = Object.entries(nomination.participants);
|
||||||
return (
|
return (
|
||||||
<span className="ctp-ticker-bubbles">
|
<span className="ctp-ticker-bubbles">
|
||||||
{entries.slice(0, 6).map(([name, participant]) => {
|
{entries.slice(0, 6).map(([participantId, participant]) => {
|
||||||
|
const name = participant.name;
|
||||||
const ready = isReady(participant, now);
|
const ready = isReady(participant, now);
|
||||||
const state = ready ? 'ready' : participant.status === 'in' ? 'in' : 'pending';
|
const state = ready ? 'ready' : participant.status === 'in' ? 'in' : 'pending';
|
||||||
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
|
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
|
||||||
const remaining = (participant.readyAt ?? now) - now;
|
const remaining = (participant.readyAt ?? now) - now;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={name}
|
key={participantId}
|
||||||
className="ctp-mini"
|
className="ctp-mini"
|
||||||
data-state={state}
|
data-state={state}
|
||||||
title={`${name} — ${ready ? 'ready' : state === 'in' ? 'in' : `ready ${formatCountdownShort(remaining)}`}`}
|
title={`${name} — ${ready ? 'ready' : state === 'in' ? 'in' : `ready ${formatCountdownShort(remaining)}`}`}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { Nomination } from '../../lib/types';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
nomination: Nomination;
|
nomination: Nomination;
|
||||||
username: string;
|
actorId: string | null;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onSend: (text: string) => void;
|
onSend: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CtpChat = ({ nomination, username, disabled, onSend }: Props) => {
|
export const CtpChat = ({ nomination, actorId, disabled, onSend }: Props) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [seen, setSeen] = useState(nomination.messages.length);
|
const [seen, setSeen] = useState(nomination.messages.length);
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
@@ -58,7 +58,7 @@ export const CtpChat = ({ nomination, username, disabled, onSend }: Props) => {
|
|||||||
{nomination.messages.map(message => (
|
{nomination.messages.map(message => (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`ctp-chat-msg ${message.from === username ? 'is-me' : ''}`}
|
className={`ctp-chat-msg ${message.fromId === actorId ? 'is-me' : ''}`}
|
||||||
>
|
>
|
||||||
<b style={{ color: avatarColor(message.from) }}>{message.from}</b>
|
<b style={{ color: avatarColor(message.from) }}>{message.from}</b>
|
||||||
<span className="ctp-chat-time">{formatClock(message.at)}</span>
|
<span className="ctp-chat-time">{formatClock(message.at)}</span>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { CallToPlayParticipant, Game, Nomination } from '../../lib/types';
|
|||||||
interface Props {
|
interface Props {
|
||||||
nomination: Nomination;
|
nomination: Nomination;
|
||||||
game: Game;
|
game: Game;
|
||||||
username: string;
|
actorId: string | null;
|
||||||
actions: CallToPlayActions;
|
actions: CallToPlayActions;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
thumbnailUrl?: string | null;
|
thumbnailUrl?: string | null;
|
||||||
@@ -60,7 +60,7 @@ const AvatarChip = ({
|
|||||||
export const NominationCard = ({
|
export const NominationCard = ({
|
||||||
nomination,
|
nomination,
|
||||||
game,
|
game,
|
||||||
username,
|
actorId,
|
||||||
actions,
|
actions,
|
||||||
focused,
|
focused,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
@@ -77,9 +77,9 @@ export const NominationCard = ({
|
|||||||
const entries = Object.entries(nomination.participants);
|
const entries = Object.entries(nomination.participants);
|
||||||
const readyCount = readyCountOf(nomination, now);
|
const readyCount = readyCountOf(nomination, now);
|
||||||
const inCount = inCountOf(nomination, now);
|
const inCount = inCountOf(nomination, now);
|
||||||
const myStatus = nomination.participants[username];
|
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
|
||||||
const isMe = myStatus !== undefined;
|
const isMe = myStatus !== undefined;
|
||||||
const isCreator = nomination.creator === username;
|
const isCreator = nomination.creatorId === actorId;
|
||||||
const isDone = nomination.state === 'done';
|
const isDone = nomination.state === 'done';
|
||||||
const isStarted = nomination.state === 'started';
|
const isStarted = nomination.state === 'started';
|
||||||
const phase = phaseOf(nomination, now);
|
const phase = phaseOf(nomination, now);
|
||||||
@@ -168,8 +168,13 @@ export const NominationCard = ({
|
|||||||
<div className="ctp-roster">
|
<div className="ctp-roster">
|
||||||
<div className="ctp-roster-count">{rosterLabel}</div>
|
<div className="ctp-roster-count">{rosterLabel}</div>
|
||||||
<div className="ctp-avatars">
|
<div className="ctp-avatars">
|
||||||
{entries.map(([name, participant]) => (
|
{entries.map(([participantId, participant]) => (
|
||||||
<AvatarChip key={name} name={name} participant={participant} now={now} />
|
<AvatarChip
|
||||||
|
key={participantId}
|
||||||
|
name={participant.name}
|
||||||
|
participant={participant}
|
||||||
|
now={now}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{Array.from({ length: Math.max(0, nomination.maxPlayers - entries.length) })
|
{Array.from({ length: Math.max(0, nomination.maxPlayers - entries.length) })
|
||||||
.map((_, index) => (
|
.map((_, index) => (
|
||||||
@@ -182,7 +187,7 @@ export const NominationCard = ({
|
|||||||
<CardActions
|
<CardActions
|
||||||
nomination={nomination}
|
nomination={nomination}
|
||||||
game={game}
|
game={game}
|
||||||
username={username}
|
actorId={actorId}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
onLaunch={onLaunch}
|
onLaunch={onLaunch}
|
||||||
now={now}
|
now={now}
|
||||||
@@ -191,7 +196,7 @@ export const NominationCard = ({
|
|||||||
|
|
||||||
<CtpChat
|
<CtpChat
|
||||||
nomination={nomination}
|
nomination={nomination}
|
||||||
username={username}
|
actorId={actorId}
|
||||||
disabled={isStarted}
|
disabled={isStarted}
|
||||||
onSend={text => actions.sendMessage(nomination.id, text)}
|
onSend={text => actions.sendMessage(nomination.id, text)}
|
||||||
/>
|
/>
|
||||||
@@ -225,7 +230,7 @@ export const NominationCard = ({
|
|||||||
interface CardActionsProps {
|
interface CardActionsProps {
|
||||||
nomination: Nomination;
|
nomination: Nomination;
|
||||||
game: Game;
|
game: Game;
|
||||||
username: string;
|
actorId: string | null;
|
||||||
actions: CallToPlayActions;
|
actions: CallToPlayActions;
|
||||||
onLaunch: (game: Game) => void;
|
onLaunch: (game: Game) => void;
|
||||||
now: number;
|
now: number;
|
||||||
@@ -255,14 +260,14 @@ const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
|
|||||||
const CardActions = ({
|
const CardActions = ({
|
||||||
nomination,
|
nomination,
|
||||||
game,
|
game,
|
||||||
username,
|
actorId,
|
||||||
actions,
|
actions,
|
||||||
onLaunch,
|
onLaunch,
|
||||||
now,
|
now,
|
||||||
}: CardActionsProps) => {
|
}: CardActionsProps) => {
|
||||||
const myStatus = nomination.participants[username];
|
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
|
||||||
const isMe = myStatus !== undefined;
|
const isMe = myStatus !== undefined;
|
||||||
const isCreator = nomination.creator === username;
|
const isCreator = nomination.creatorId === actorId;
|
||||||
const isDone = nomination.state === 'done';
|
const isDone = nomination.state === 'done';
|
||||||
const isStarted = nomination.state === 'started';
|
const isStarted = nomination.state === 'started';
|
||||||
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;
|
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface CallToPlayActions {
|
|||||||
export interface UseCallToPlay {
|
export interface UseCallToPlay {
|
||||||
nominations: Nomination[];
|
nominations: Nomination[];
|
||||||
actions: CallToPlayActions;
|
actions: CallToPlayActions;
|
||||||
username: string;
|
actorId: string | null;
|
||||||
transportReady: boolean;
|
transportReady: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,7 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
|||||||
return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander';
|
return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander';
|
||||||
}, [username]);
|
}, [username]);
|
||||||
const [events, setEvents] = useState<ReadonlyMap<string, CallToPlayEvent>>(() => new Map());
|
const [events, setEvents] = useState<ReadonlyMap<string, CallToPlayEvent>>(() => new Map());
|
||||||
|
const [actorId, setActorId] = useState<string | null>(null);
|
||||||
const [now, setNow] = useState(Date.now());
|
const [now, setNow] = useState(Date.now());
|
||||||
const [transportReady, setTransportReady] = useState(false);
|
const [transportReady, setTransportReady] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -60,8 +61,10 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
|||||||
|
|
||||||
const requestSnapshot = async (): Promise<boolean> => {
|
const requestSnapshot = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const ready = await invoke<boolean>('request_call_to_play_events');
|
const peerId = await invoke<string | null>('request_call_to_play_events');
|
||||||
if (cancelled) return false;
|
if (cancelled) return false;
|
||||||
|
const ready = peerId !== null;
|
||||||
|
setActorId(peerId);
|
||||||
setTransportReady(ready);
|
setTransportReady(ready);
|
||||||
if (ready && retry !== undefined) {
|
if (ready && retry !== undefined) {
|
||||||
window.clearInterval(retry);
|
window.clearInterval(retry);
|
||||||
@@ -108,7 +111,12 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
|||||||
callId: string,
|
callId: string,
|
||||||
action: CallToPlayAction,
|
action: CallToPlayAction,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const event = callToPlayEvent(callId, actor, action);
|
if (actorId === null) {
|
||||||
|
setTransportReady(false);
|
||||||
|
setError('Call to Play needs an active LAN peer. Choose a game folder first.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const event = callToPlayEvent(callId, actorId, actor, action);
|
||||||
try {
|
try {
|
||||||
const accepted = await invoke<boolean>('publish_call_to_play', { event });
|
const accepted = await invoke<boolean>('publish_call_to_play', { event });
|
||||||
if (!accepted) {
|
if (!accepted) {
|
||||||
@@ -124,7 +132,7 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
|||||||
setError('Could not send this Call to Play update.');
|
setError('Could not send this Call to Play update.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [actor]);
|
}, [actor, actorId]);
|
||||||
|
|
||||||
const actions = useMemo<CallToPlayActions>(() => ({
|
const actions = useMemo<CallToPlayActions>(() => ({
|
||||||
createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => {
|
createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => {
|
||||||
@@ -169,5 +177,5 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
|||||||
[events, now],
|
[events, now],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { nominations, actions, username: actor, transportReady, error };
|
return { nominations, actions, actorId, transportReady, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -88,13 +88,15 @@ export const reduceCallToPlayEvents = (
|
|||||||
const nomination: MutableNomination = {
|
const nomination: MutableNomination = {
|
||||||
id: create.call_id,
|
id: create.call_id,
|
||||||
gameId: payload.game_id,
|
gameId: payload.game_id,
|
||||||
creator: create.actor,
|
creatorId: create.actor_id,
|
||||||
|
creator: create.actor_name,
|
||||||
maxPlayers: payload.max_players,
|
maxPlayers: payload.max_players,
|
||||||
createdAt: create.at,
|
createdAt: create.at,
|
||||||
scheduledFor: payload.scheduled_for,
|
scheduledFor: payload.scheduled_for,
|
||||||
deadline: payload.deadline,
|
deadline: payload.deadline,
|
||||||
participants: {
|
participants: {
|
||||||
[create.actor]: {
|
[create.actor_id]: {
|
||||||
|
name: create.actor_name,
|
||||||
status: payload.scheduled_for === null ? 'ready' : 'in',
|
status: payload.scheduled_for === null ? 'ready' : 'in',
|
||||||
joinedAt: create.at,
|
joinedAt: create.at,
|
||||||
},
|
},
|
||||||
@@ -139,8 +141,9 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
|||||||
const response = respondPayload(action);
|
const response = respondPayload(action);
|
||||||
if (response) {
|
if (response) {
|
||||||
if (nomination.state === 'started') return;
|
if (nomination.state === 'started') return;
|
||||||
const existing = nomination.participants[event.actor];
|
const existing = nomination.participants[event.actor_id];
|
||||||
nomination.participants[event.actor] = {
|
nomination.participants[event.actor_id] = {
|
||||||
|
name: event.actor_name,
|
||||||
status: response.ready_at === null ? 'ready' : 'pending',
|
status: response.ready_at === null ? 'ready' : 'pending',
|
||||||
joinedAt: existing?.joinedAt ?? event.at,
|
joinedAt: existing?.joinedAt ?? event.at,
|
||||||
...(response.ready_at === null ? {} : { readyAt: response.ready_at }),
|
...(response.ready_at === null ? {} : { readyAt: response.ready_at }),
|
||||||
@@ -153,7 +156,8 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
|||||||
nomination.messageIds.add(message.message_id);
|
nomination.messageIds.add(message.message_id);
|
||||||
nomination.messages.push({
|
nomination.messages.push({
|
||||||
id: message.message_id,
|
id: message.message_id,
|
||||||
from: event.actor,
|
fromId: event.actor_id,
|
||||||
|
from: event.actor_name,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
at: event.at,
|
at: event.at,
|
||||||
});
|
});
|
||||||
@@ -162,7 +166,10 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
const extension = addTimePayload(action);
|
const extension = addTimePayload(action);
|
||||||
if (extension && event.actor === nomination.creator && nomination.state !== 'started') {
|
if (extension
|
||||||
|
&& event.actor_id === nomination.creatorId
|
||||||
|
&& nomination.state !== 'started'
|
||||||
|
) {
|
||||||
nomination.deadline = extension.deadline;
|
nomination.deadline = extension.deadline;
|
||||||
nomination.state = 'open';
|
nomination.state = 'open';
|
||||||
}
|
}
|
||||||
@@ -176,25 +183,26 @@ const applyUnitAction = (
|
|||||||
switch (action) {
|
switch (action) {
|
||||||
case 'Rsvp': {
|
case 'Rsvp': {
|
||||||
if (nomination.state === 'started') return;
|
if (nomination.state === 'started') return;
|
||||||
const existing = nomination.participants[event.actor];
|
const existing = nomination.participants[event.actor_id];
|
||||||
nomination.participants[event.actor] = {
|
nomination.participants[event.actor_id] = {
|
||||||
|
name: event.actor_name,
|
||||||
status: 'in',
|
status: 'in',
|
||||||
joinedAt: existing?.joinedAt ?? event.at,
|
joinedAt: existing?.joinedAt ?? event.at,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'Leave':
|
case 'Leave':
|
||||||
if (event.actor !== nomination.creator && nomination.state !== 'started') {
|
if (event.actor_id !== nomination.creatorId && nomination.state !== 'started') {
|
||||||
delete nomination.participants[event.actor];
|
delete nomination.participants[event.actor_id];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Cancel':
|
case 'Cancel':
|
||||||
if (event.actor === nomination.creator && nomination.state !== 'started') {
|
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
|
||||||
nomination.cancelled = true;
|
nomination.cancelled = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Start':
|
case 'Start':
|
||||||
if (event.actor === nomination.creator && nomination.state !== 'started') {
|
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
|
||||||
nomination.state = 'started';
|
nomination.state = 'started';
|
||||||
nomination.startedAt = event.at;
|
nomination.startedAt = event.at;
|
||||||
}
|
}
|
||||||
@@ -204,13 +212,15 @@ const applyUnitAction = (
|
|||||||
|
|
||||||
export const callToPlayEvent = (
|
export const callToPlayEvent = (
|
||||||
callId: string,
|
callId: string,
|
||||||
actor: string,
|
actorId: string,
|
||||||
|
actorName: string,
|
||||||
action: CallToPlayAction,
|
action: CallToPlayAction,
|
||||||
at = Date.now(),
|
at = Date.now(),
|
||||||
): CallToPlayEvent => ({
|
): CallToPlayEvent => ({
|
||||||
id: globalThis.crypto.randomUUID(),
|
id: globalThis.crypto.randomUUID(),
|
||||||
call_id: callId,
|
call_id: callId,
|
||||||
actor,
|
actor_id: actorId,
|
||||||
|
actor_name: actorName,
|
||||||
at,
|
at,
|
||||||
action,
|
action,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export type LauncherLanguage = 'en' | 'de';
|
|||||||
export type CallToPlayParticipantStatus = 'ready' | 'in' | 'pending';
|
export type CallToPlayParticipantStatus = 'ready' | 'in' | 'pending';
|
||||||
|
|
||||||
export interface CallToPlayParticipant {
|
export interface CallToPlayParticipant {
|
||||||
|
name: string;
|
||||||
status: CallToPlayParticipantStatus;
|
status: CallToPlayParticipantStatus;
|
||||||
joinedAt: number;
|
joinedAt: number;
|
||||||
readyAt?: number;
|
readyAt?: number;
|
||||||
@@ -95,6 +96,7 @@ export interface CallToPlayParticipant {
|
|||||||
|
|
||||||
export interface CallToPlayMessage {
|
export interface CallToPlayMessage {
|
||||||
id: string;
|
id: string;
|
||||||
|
fromId: string;
|
||||||
from: string;
|
from: string;
|
||||||
text: string;
|
text: string;
|
||||||
at: number;
|
at: number;
|
||||||
@@ -103,6 +105,7 @@ export interface CallToPlayMessage {
|
|||||||
export interface Nomination {
|
export interface Nomination {
|
||||||
id: string;
|
id: string;
|
||||||
gameId: string;
|
gameId: string;
|
||||||
|
creatorId: string;
|
||||||
creator: string;
|
creator: string;
|
||||||
maxPlayers: number;
|
maxPlayers: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
@@ -127,7 +130,8 @@ export type CallToPlayAction =
|
|||||||
export interface CallToPlayEvent {
|
export interface CallToPlayEvent {
|
||||||
id: string;
|
id: string;
|
||||||
call_id: string;
|
call_id: string;
|
||||||
actor: string;
|
actor_id: string;
|
||||||
|
actor_name: string;
|
||||||
at: number;
|
at: number;
|
||||||
action: CallToPlayAction;
|
action: CallToPlayAction;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export const MainWindow = () => {
|
|||||||
<CallToPlayOverlay
|
<CallToPlayOverlay
|
||||||
nominations={callToPlay.nominations}
|
nominations={callToPlay.nominations}
|
||||||
games={games.games}
|
games={games.games}
|
||||||
username={callToPlay.username}
|
actorId={callToPlay.actorId}
|
||||||
actions={callToPlay.actions}
|
actions={callToPlay.actions}
|
||||||
focusId={focusedCallId}
|
focusId={focusedCallId}
|
||||||
transportReady={callToPlay.transportReady}
|
transportReady={callToPlay.transportReady}
|
||||||
|
|||||||
@@ -21,10 +21,18 @@ const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
|||||||
|
|
||||||
const event = (
|
const event = (
|
||||||
id: string,
|
id: string,
|
||||||
actor: string,
|
actorId: string,
|
||||||
action: CallToPlayAction,
|
action: CallToPlayAction,
|
||||||
at = NOW,
|
at = NOW,
|
||||||
): CallToPlayEvent => ({ id, call_id: 'call-1', actor, at, action });
|
actorName = actorId,
|
||||||
|
): CallToPlayEvent => ({
|
||||||
|
id,
|
||||||
|
call_id: 'call-1',
|
||||||
|
actor_id: actorId,
|
||||||
|
actor_name: actorName,
|
||||||
|
at,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
|
||||||
const create = (
|
const create = (
|
||||||
scheduledFor: number | null = null,
|
scheduledFor: number | null = null,
|
||||||
@@ -94,6 +102,27 @@ Deno.test('creator-only controls cannot be forged by another participant', () =>
|
|||||||
assertEquals(nomination.deadline, NOW + 30 * 60_000, 'forged extension ignored');
|
assertEquals(nomination.deadline, NOW + 30 * 60_000, 'forged extension ignored');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test('stable peer ids keep duplicate display names distinct', () => {
|
||||||
|
const createByCommander = event('create', 'peer-a', {
|
||||||
|
Create: {
|
||||||
|
game_id: 'game-1',
|
||||||
|
max_players: 3,
|
||||||
|
scheduled_for: null,
|
||||||
|
deadline: NOW + 30 * 60_000,
|
||||||
|
},
|
||||||
|
}, NOW, 'Commander');
|
||||||
|
const events = [
|
||||||
|
createByCommander,
|
||||||
|
event('join', 'peer-b', { Respond: { ready_at: null } }, NOW + 1, 'Commander'),
|
||||||
|
event('forged-cancel', 'peer-b', 'Cancel', NOW + 2, 'Commander'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [nomination] = reduceCallToPlayEvents(events, NOW + 3);
|
||||||
|
assert(nomination, 'same-name participant must not cancel the call');
|
||||||
|
assertEquals(nomination.creatorId, 'peer-a', 'creator identity');
|
||||||
|
assertEquals(Object.keys(nomination.participants).length, 2, 'distinct peer participants');
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test('creator can extend, start, and cancel a call', () => {
|
Deno.test('creator can extend, start, and cancel a call', () => {
|
||||||
const extended = reduceCallToPlayEvents([
|
const extended = reduceCallToPlayEvents([
|
||||||
create(),
|
create(),
|
||||||
|
|||||||
@@ -528,17 +528,19 @@ card. Usernames are colored deterministically by a hash of the name.
|
|||||||
type Nomination = {
|
type Nomination = {
|
||||||
id: string;
|
id: string;
|
||||||
gameId: string;
|
gameId: string;
|
||||||
creator: string; // username of the caller
|
creatorId: string; // stable peer ID of the caller
|
||||||
|
creator: string; // display name of the caller
|
||||||
maxPlayers: number;
|
maxPlayers: number;
|
||||||
createdAt: number; // ms epoch
|
createdAt: number; // ms epoch
|
||||||
scheduledFor: number | null; // ms epoch clock time; null = play-now
|
scheduledFor: number | null; // ms epoch clock time; null = play-now
|
||||||
deadline: number; // play-now: createdAt + durationMin; scheduled: === scheduledFor
|
deadline: number; // play-now: createdAt + durationMin; scheduled: === scheduledFor
|
||||||
participants: Record<string, { // keyed by username
|
participants: Record<string, { // keyed by stable peer ID
|
||||||
|
name: string; // current display name
|
||||||
status: 'ready' | 'in' | 'pending';
|
status: 'ready' | 'in' | 'pending';
|
||||||
joinedAt: number;
|
joinedAt: number;
|
||||||
readyAt?: number; // ms epoch a 'pending' buffer elapses
|
readyAt?: number; // ms epoch a 'pending' buffer elapses
|
||||||
}>;
|
}>;
|
||||||
messages: { id: string; from: string; text: string; at: number }[];
|
messages: { id: string; fromId: string; from: string; text: string; at: number }[];
|
||||||
state: 'open' | 'done' | 'started';
|
state: 'open' | 'done' | 'started';
|
||||||
startedAt?: number;
|
startedAt?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user