Skip to content

Commit a0a6410

Browse files
committed
feat(session): implement guest access and improve session handling with race condition fixes release:patch
1 parent 8ce6691 commit a0a6410

12 files changed

Lines changed: 686 additions & 23 deletions

File tree

.claude/settings.local.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,10 @@
220220
"Bash(done)",
221221
"Bash(pnpm lint-staged:*)",
222222
"Bash(npx lint-staged)",
223-
"Bash(pnpm --filter web typecheck:*)"
223+
"Bash(pnpm --filter web typecheck:*)",
224+
"Bash(pnpm turbo run typecheck:*)",
225+
"Bash(pnpm turbo run lint:*)",
226+
"Bash(pnpm precommit:*)"
224227
],
225228
"deny": [
226229
"Bash(npm *)",

apps/desktop/src/renderer/hooks/useRecording.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,14 @@ interface UseRecordingOptions {
3232
onSpaceWarning?: (availableGb: number) => void;
3333
}
3434

35-
// Quality presets for video constraints
35+
// Quality presets for video constraints (optimized for screen recording with text)
3636
const QUALITY_PRESETS: Record<
3737
RecordingQuality,
3838
{ width: number; height: number; bitrate: number }
3939
> = {
40-
'720p': { width: 1280, height: 720, bitrate: 2_500_000 },
41-
'1080p': { width: 1920, height: 1080, bitrate: 5_000_000 },
42-
'4k': { width: 3840, height: 2160, bitrate: 15_000_000 },
40+
'720p': { width: 1280, height: 720, bitrate: 4_000_000 }, // 4 Mbps for crisp 720p
41+
'1080p': { width: 1920, height: 1080, bitrate: 8_000_000 }, // 8 Mbps for crisp 1080p
42+
'4k': { width: 3840, height: 2160, bitrate: 25_000_000 }, // 25 Mbps for 4K
4343
};
4444

4545
// Minimum space warning threshold (in bytes) - 500MB

apps/desktop/src/renderer/routes/home.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export function HomePage() {
5959
mediaStream = await navigator.mediaDevices.getDisplayMedia({
6060
video: {
6161
displaySurface: source.type === 'screen' ? 'monitor' : 'window',
62+
// Request high quality capture
63+
width: { ideal: 1920, max: 3840 },
64+
height: { ideal: 1080, max: 2160 },
65+
frameRate: { ideal: 30, max: 60 },
6266
},
6367
audio: false,
6468
});
@@ -72,12 +76,25 @@ export function HomePage() {
7276
mandatory: {
7377
chromeMediaSource: 'desktop',
7478
chromeMediaSourceId: source.id,
79+
// Request high quality capture
80+
minWidth: 1280,
81+
maxWidth: 3840,
82+
minHeight: 720,
83+
maxHeight: 2160,
84+
minFrameRate: 15,
85+
maxFrameRate: 60,
7586
},
7687
},
7788
});
7889
}
7990

8091
console.log('[Renderer] Capture started successfully');
92+
93+
// Set content hint on video track for screen sharing optimization
94+
// 'detail' tells encoder to prioritize sharpness (good for text)
95+
const videoTrack = mediaStream.getVideoTracks()[0];
96+
videoTrack.contentHint = 'detail';
97+
8198
setStream(mediaStream);
8299
} catch (err) {
83100
console.error('[Renderer] Failed to start capture:', err);
@@ -111,12 +128,21 @@ export function HomePage() {
111128
console.log('[Renderer] Starting Wayland capture with system picker');
112129

113130
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
114-
video: true,
131+
video: {
132+
// Request high quality capture
133+
width: { ideal: 1920, max: 3840 },
134+
height: { ideal: 1080, max: 2160 },
135+
frameRate: { ideal: 30, max: 60 },
136+
},
115137
audio: false,
116138
});
117139

118140
// Create a synthetic source from the stream
119141
const track = mediaStream.getVideoTracks()[0];
142+
143+
// Set content hint for screen sharing optimization
144+
// 'detail' tells encoder to prioritize sharpness (good for text)
145+
track.contentHint = 'detail';
120146
const settings = track.getSettings();
121147

122148
setSelectedSource({

apps/web/Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,10 @@ ENV PORT=8080
7070
ENV HOSTNAME="0.0.0.0"
7171

7272
# Copy built application
73-
COPY --from=builder /app/apps/web/public ./public
7473
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
7574
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
75+
# Public folder must be at apps/web/public since server.js runs from apps/web/
76+
COPY --from=builder /app/apps/web/public ./apps/web/public
7677

7778
# Switch to non-root user
7879
USER nextjs
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { createClient } from '@/lib/supabase/server';
2+
import { successResponse, errorResponse, handleApiError } from '@/lib/api';
3+
4+
interface RouteParams {
5+
params: Promise<{ sessionId: string }>;
6+
}
7+
8+
// Types for database rows (not yet in generated Supabase types)
9+
interface ParticipantRow {
10+
id: string;
11+
display_name: string;
12+
role: string;
13+
control_state: string;
14+
joined_at: string;
15+
left_at: string | null;
16+
session_id: string;
17+
}
18+
19+
interface SessionRow {
20+
id: string;
21+
join_code: string;
22+
status: string;
23+
settings: Record<string, unknown>;
24+
created_at: string;
25+
session_participants: ParticipantRow[];
26+
}
27+
28+
// GET /api/sessions/[sessionId]/guest?p=participantId - Get session for guest viewer
29+
export async function GET(request: Request, { params }: RouteParams) {
30+
try {
31+
const { sessionId } = await params;
32+
const url = new URL(request.url);
33+
const participantId = url.searchParams.get('p');
34+
35+
if (!participantId) {
36+
return errorResponse('Participant ID required', 400);
37+
}
38+
39+
const supabase = await createClient();
40+
41+
// Validate participant exists and belongs to this session
42+
const { data: participant, error: participantError } = (await supabase
43+
.from('session_participants')
44+
.select('id, display_name, role, control_state, joined_at, left_at, session_id')
45+
.eq('id', participantId)
46+
.eq('session_id', sessionId)
47+
.single()) as { data: ParticipantRow | null; error: unknown };
48+
49+
if (participantError || !participant) {
50+
return errorResponse('Invalid participant or session', 403);
51+
}
52+
53+
// Check participant hasn't left
54+
if (participant.left_at) {
55+
return errorResponse('You have left this session', 403);
56+
}
57+
58+
// Get session details
59+
const { data: session, error: sessionError } = (await supabase
60+
.from('sessions')
61+
.select(
62+
`
63+
id,
64+
join_code,
65+
status,
66+
settings,
67+
created_at,
68+
session_participants (
69+
id,
70+
display_name,
71+
role,
72+
control_state,
73+
joined_at,
74+
left_at
75+
)
76+
`
77+
)
78+
.eq('id', sessionId)
79+
.neq('status', 'ended')
80+
.single()) as { data: SessionRow | null; error: unknown };
81+
82+
if (sessionError || !session) {
83+
return errorResponse('Session not found or has ended', 404);
84+
}
85+
86+
// Filter to only active participants
87+
const activeParticipants = session.session_participants.filter((p) => !p.left_at);
88+
89+
return successResponse({
90+
session: {
91+
id: session.id,
92+
join_code: session.join_code,
93+
status: session.status,
94+
settings: session.settings,
95+
created_at: session.created_at,
96+
session_participants: activeParticipants,
97+
},
98+
participant: {
99+
id: participant.id,
100+
display_name: participant.display_name,
101+
role: participant.role,
102+
control_state: participant.control_state,
103+
joined_at: participant.joined_at,
104+
},
105+
});
106+
} catch (error) {
107+
return handleApiError(error);
108+
}
109+
}

apps/web/src/app/join/[joinCode]/page.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ describe('JoinPage', () => {
186186
if (options?.method === 'POST') {
187187
return Promise.resolve({
188188
ok: true,
189-
json: () => Promise.resolve({ data: { participant_id: 'p-123' } }),
189+
json: () => Promise.resolve({ data: { id: 'p-123', session_id: 'session-123' } }),
190190
} as Response);
191191
}
192192
return Promise.resolve({
@@ -210,8 +210,9 @@ describe('JoinPage', () => {
210210
await user.type(screen.getByLabelText('Your Name'), 'Test User');
211211
await user.click(screen.getByRole('button', { name: 'Join Session' }));
212212

213+
// Guests are redirected to the public view page with their participant ID
213214
await waitFor(() => {
214-
expect(mockPush).toHaveBeenCalledWith('/session/session-123');
215+
expect(mockPush).toHaveBeenCalledWith('/view/session-123?p=p-123');
215216
});
216217
});
217218

apps/web/src/app/join/[joinCode]/page.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,16 +109,22 @@ export default function JoinPage({ params }: { params: Promise<{ joinCode: strin
109109
body: JSON.stringify(body),
110110
});
111111

112-
const data = (await res.json()) as ApiResponse<unknown>;
112+
const data = (await res.json()) as ApiResponse<{ id: string; session_id: string }>;
113113

114114
if (!res.ok) {
115115
setError(data.error ?? 'Failed to join session');
116116
return;
117117
}
118118

119-
// Redirect to session viewer
120-
if (session?.id) {
121-
router.push(`/session/${session.id}`);
119+
// Redirect to appropriate viewer
120+
if (session?.id && data.data) {
121+
if (user) {
122+
// Authenticated users go to protected session page
123+
router.push(`/session/${session.id}`);
124+
} else {
125+
// Guests go to public view page with participant token
126+
router.push(`/view/${session.id}?p=${data.data.id}`);
127+
}
122128
}
123129
} catch {
124130
setError('Failed to join session');

0 commit comments

Comments
 (0)