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:
@@ -91,6 +91,7 @@ impl OutboundTransferEmitState {
|
||||
struct LanSpreadState {
|
||||
peer_ctrl: Arc<RwLock<Option<UnboundedSender<PeerCommand>>>>,
|
||||
peer_runtime: Arc<RwLock<Option<PeerRuntimeHandle>>>,
|
||||
local_peer_id: Arc<RwLock<Option<String>>>,
|
||||
games: Arc<RwLock<GameDB>>,
|
||||
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
|
||||
games_folder: Arc<RwLock<String>>,
|
||||
@@ -277,14 +278,17 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
|
||||
#[tauri::command]
|
||||
async fn request_call_to_play_events(
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
) -> tauri::Result<Option<String>> {
|
||||
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
|
||||
let Some(peer_ctrl) = peer_ctrl else {
|
||||
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]
|
||||
@@ -2205,6 +2209,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
match event {
|
||||
PeerEvent::LocalPeerReady { peer_id, 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) => {
|
||||
log::info!("PeerEvent::ListGames received");
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Game, Nomination } from '../../lib/types';
|
||||
interface Props {
|
||||
nominations: ReadonlyArray<Nomination>;
|
||||
games: ReadonlyArray<Game>;
|
||||
username: string;
|
||||
actorId: string | null;
|
||||
actions: CallToPlayActions;
|
||||
focusId: string | null;
|
||||
transportReady: boolean;
|
||||
@@ -24,7 +24,7 @@ interface Props {
|
||||
export const CallToPlayOverlay = ({
|
||||
nominations,
|
||||
games,
|
||||
username,
|
||||
actorId,
|
||||
actions,
|
||||
focusId,
|
||||
transportReady,
|
||||
@@ -88,7 +88,7 @@ export const CallToPlayOverlay = ({
|
||||
key={nomination.id}
|
||||
nomination={nomination}
|
||||
game={game}
|
||||
username={username}
|
||||
actorId={actorId}
|
||||
actions={actions}
|
||||
focused={nomination.id === focusId}
|
||||
thumbnailUrl={getThumbnail(game.id)}
|
||||
|
||||
@@ -33,14 +33,15 @@ const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number
|
||||
const entries = Object.entries(nomination.participants);
|
||||
return (
|
||||
<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 state = ready ? 'ready' : participant.status === 'in' ? 'in' : 'pending';
|
||||
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
|
||||
const remaining = (participant.readyAt ?? now) - now;
|
||||
return (
|
||||
<span
|
||||
key={name}
|
||||
key={participantId}
|
||||
className="ctp-mini"
|
||||
data-state={state}
|
||||
title={`${name} — ${ready ? 'ready' : state === 'in' ? 'in' : `ready ${formatCountdownShort(remaining)}`}`}
|
||||
|
||||
@@ -6,12 +6,12 @@ import { Nomination } from '../../lib/types';
|
||||
|
||||
interface Props {
|
||||
nomination: Nomination;
|
||||
username: string;
|
||||
actorId: string | null;
|
||||
disabled: boolean;
|
||||
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 [seen, setSeen] = useState(nomination.messages.length);
|
||||
const [draft, setDraft] = useState('');
|
||||
@@ -58,7 +58,7 @@ export const CtpChat = ({ nomination, username, disabled, onSend }: Props) => {
|
||||
{nomination.messages.map(message => (
|
||||
<div
|
||||
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>
|
||||
<span className="ctp-chat-time">{formatClock(message.at)}</span>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { CallToPlayParticipant, Game, Nomination } from '../../lib/types';
|
||||
interface Props {
|
||||
nomination: Nomination;
|
||||
game: Game;
|
||||
username: string;
|
||||
actorId: string | null;
|
||||
actions: CallToPlayActions;
|
||||
focused: boolean;
|
||||
thumbnailUrl?: string | null;
|
||||
@@ -60,7 +60,7 @@ const AvatarChip = ({
|
||||
export const NominationCard = ({
|
||||
nomination,
|
||||
game,
|
||||
username,
|
||||
actorId,
|
||||
actions,
|
||||
focused,
|
||||
thumbnailUrl,
|
||||
@@ -77,9 +77,9 @@ export const NominationCard = ({
|
||||
const entries = Object.entries(nomination.participants);
|
||||
const readyCount = readyCountOf(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 isCreator = nomination.creator === username;
|
||||
const isCreator = nomination.creatorId === actorId;
|
||||
const isDone = nomination.state === 'done';
|
||||
const isStarted = nomination.state === 'started';
|
||||
const phase = phaseOf(nomination, now);
|
||||
@@ -168,8 +168,13 @@ export const NominationCard = ({
|
||||
<div className="ctp-roster">
|
||||
<div className="ctp-roster-count">{rosterLabel}</div>
|
||||
<div className="ctp-avatars">
|
||||
{entries.map(([name, participant]) => (
|
||||
<AvatarChip key={name} name={name} participant={participant} now={now} />
|
||||
{entries.map(([participantId, participant]) => (
|
||||
<AvatarChip
|
||||
key={participantId}
|
||||
name={participant.name}
|
||||
participant={participant}
|
||||
now={now}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: Math.max(0, nomination.maxPlayers - entries.length) })
|
||||
.map((_, index) => (
|
||||
@@ -182,7 +187,7 @@ export const NominationCard = ({
|
||||
<CardActions
|
||||
nomination={nomination}
|
||||
game={game}
|
||||
username={username}
|
||||
actorId={actorId}
|
||||
actions={actions}
|
||||
onLaunch={onLaunch}
|
||||
now={now}
|
||||
@@ -191,7 +196,7 @@ export const NominationCard = ({
|
||||
|
||||
<CtpChat
|
||||
nomination={nomination}
|
||||
username={username}
|
||||
actorId={actorId}
|
||||
disabled={isStarted}
|
||||
onSend={text => actions.sendMessage(nomination.id, text)}
|
||||
/>
|
||||
@@ -225,7 +230,7 @@ export const NominationCard = ({
|
||||
interface CardActionsProps {
|
||||
nomination: Nomination;
|
||||
game: Game;
|
||||
username: string;
|
||||
actorId: string | null;
|
||||
actions: CallToPlayActions;
|
||||
onLaunch: (game: Game) => void;
|
||||
now: number;
|
||||
@@ -255,14 +260,14 @@ const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
|
||||
const CardActions = ({
|
||||
nomination,
|
||||
game,
|
||||
username,
|
||||
actorId,
|
||||
actions,
|
||||
onLaunch,
|
||||
now,
|
||||
}: CardActionsProps) => {
|
||||
const myStatus = nomination.participants[username];
|
||||
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
|
||||
const isMe = myStatus !== undefined;
|
||||
const isCreator = nomination.creator === username;
|
||||
const isCreator = nomination.creatorId === actorId;
|
||||
const isDone = nomination.state === 'done';
|
||||
const isStarted = nomination.state === 'started';
|
||||
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface CallToPlayActions {
|
||||
export interface UseCallToPlay {
|
||||
nominations: Nomination[];
|
||||
actions: CallToPlayActions;
|
||||
username: string;
|
||||
actorId: string | null;
|
||||
transportReady: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
||||
return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander';
|
||||
}, [username]);
|
||||
const [events, setEvents] = useState<ReadonlyMap<string, CallToPlayEvent>>(() => new Map());
|
||||
const [actorId, setActorId] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [transportReady, setTransportReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -60,8 +61,10 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
||||
|
||||
const requestSnapshot = async (): Promise<boolean> => {
|
||||
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;
|
||||
const ready = peerId !== null;
|
||||
setActorId(peerId);
|
||||
setTransportReady(ready);
|
||||
if (ready && retry !== undefined) {
|
||||
window.clearInterval(retry);
|
||||
@@ -108,7 +111,12 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
||||
callId: string,
|
||||
action: CallToPlayAction,
|
||||
): 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 {
|
||||
const accepted = await invoke<boolean>('publish_call_to_play', { event });
|
||||
if (!accepted) {
|
||||
@@ -124,7 +132,7 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
||||
setError('Could not send this Call to Play update.');
|
||||
return false;
|
||||
}
|
||||
}, [actor]);
|
||||
}, [actor, actorId]);
|
||||
|
||||
const actions = useMemo<CallToPlayActions>(() => ({
|
||||
createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => {
|
||||
@@ -169,5 +177,5 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
||||
[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 = {
|
||||
id: create.call_id,
|
||||
gameId: payload.game_id,
|
||||
creator: create.actor,
|
||||
creatorId: create.actor_id,
|
||||
creator: create.actor_name,
|
||||
maxPlayers: payload.max_players,
|
||||
createdAt: create.at,
|
||||
scheduledFor: payload.scheduled_for,
|
||||
deadline: payload.deadline,
|
||||
participants: {
|
||||
[create.actor]: {
|
||||
[create.actor_id]: {
|
||||
name: create.actor_name,
|
||||
status: payload.scheduled_for === null ? 'ready' : 'in',
|
||||
joinedAt: create.at,
|
||||
},
|
||||
@@ -139,8 +141,9 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
||||
const response = respondPayload(action);
|
||||
if (response) {
|
||||
if (nomination.state === 'started') return;
|
||||
const existing = nomination.participants[event.actor];
|
||||
nomination.participants[event.actor] = {
|
||||
const existing = nomination.participants[event.actor_id];
|
||||
nomination.participants[event.actor_id] = {
|
||||
name: event.actor_name,
|
||||
status: response.ready_at === null ? 'ready' : 'pending',
|
||||
joinedAt: existing?.joinedAt ?? event.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.messages.push({
|
||||
id: message.message_id,
|
||||
from: event.actor,
|
||||
fromId: event.actor_id,
|
||||
from: event.actor_name,
|
||||
text: message.text,
|
||||
at: event.at,
|
||||
});
|
||||
@@ -162,7 +166,10 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
||||
}
|
||||
|
||||
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.state = 'open';
|
||||
}
|
||||
@@ -176,25 +183,26 @@ const applyUnitAction = (
|
||||
switch (action) {
|
||||
case 'Rsvp': {
|
||||
if (nomination.state === 'started') return;
|
||||
const existing = nomination.participants[event.actor];
|
||||
nomination.participants[event.actor] = {
|
||||
const existing = nomination.participants[event.actor_id];
|
||||
nomination.participants[event.actor_id] = {
|
||||
name: event.actor_name,
|
||||
status: 'in',
|
||||
joinedAt: existing?.joinedAt ?? event.at,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'Leave':
|
||||
if (event.actor !== nomination.creator && nomination.state !== 'started') {
|
||||
delete nomination.participants[event.actor];
|
||||
if (event.actor_id !== nomination.creatorId && nomination.state !== 'started') {
|
||||
delete nomination.participants[event.actor_id];
|
||||
}
|
||||
break;
|
||||
case 'Cancel':
|
||||
if (event.actor === nomination.creator && nomination.state !== 'started') {
|
||||
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
|
||||
nomination.cancelled = true;
|
||||
}
|
||||
break;
|
||||
case 'Start':
|
||||
if (event.actor === nomination.creator && nomination.state !== 'started') {
|
||||
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
|
||||
nomination.state = 'started';
|
||||
nomination.startedAt = event.at;
|
||||
}
|
||||
@@ -204,13 +212,15 @@ const applyUnitAction = (
|
||||
|
||||
export const callToPlayEvent = (
|
||||
callId: string,
|
||||
actor: string,
|
||||
actorId: string,
|
||||
actorName: string,
|
||||
action: CallToPlayAction,
|
||||
at = Date.now(),
|
||||
): CallToPlayEvent => ({
|
||||
id: globalThis.crypto.randomUUID(),
|
||||
call_id: callId,
|
||||
actor,
|
||||
actor_id: actorId,
|
||||
actor_name: actorName,
|
||||
at,
|
||||
action,
|
||||
});
|
||||
|
||||
@@ -88,6 +88,7 @@ export type LauncherLanguage = 'en' | 'de';
|
||||
export type CallToPlayParticipantStatus = 'ready' | 'in' | 'pending';
|
||||
|
||||
export interface CallToPlayParticipant {
|
||||
name: string;
|
||||
status: CallToPlayParticipantStatus;
|
||||
joinedAt: number;
|
||||
readyAt?: number;
|
||||
@@ -95,6 +96,7 @@ export interface CallToPlayParticipant {
|
||||
|
||||
export interface CallToPlayMessage {
|
||||
id: string;
|
||||
fromId: string;
|
||||
from: string;
|
||||
text: string;
|
||||
at: number;
|
||||
@@ -103,6 +105,7 @@ export interface CallToPlayMessage {
|
||||
export interface Nomination {
|
||||
id: string;
|
||||
gameId: string;
|
||||
creatorId: string;
|
||||
creator: string;
|
||||
maxPlayers: number;
|
||||
createdAt: number;
|
||||
@@ -127,7 +130,8 @@ export type CallToPlayAction =
|
||||
export interface CallToPlayEvent {
|
||||
id: string;
|
||||
call_id: string;
|
||||
actor: string;
|
||||
actor_id: string;
|
||||
actor_name: string;
|
||||
at: number;
|
||||
action: CallToPlayAction;
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ export const MainWindow = () => {
|
||||
<CallToPlayOverlay
|
||||
nominations={callToPlay.nominations}
|
||||
games={games.games}
|
||||
username={callToPlay.username}
|
||||
actorId={callToPlay.actorId}
|
||||
actions={callToPlay.actions}
|
||||
focusId={focusedCallId}
|
||||
transportReady={callToPlay.transportReady}
|
||||
|
||||
@@ -21,10 +21,18 @@ const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||
|
||||
const event = (
|
||||
id: string,
|
||||
actor: string,
|
||||
actorId: string,
|
||||
action: CallToPlayAction,
|
||||
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 = (
|
||||
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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const extended = reduceCallToPlayEvents([
|
||||
create(),
|
||||
|
||||
Reference in New Issue
Block a user