pluto-rtc 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -0
- package/dist/Connection.d.ts +35 -0
- package/dist/Connection.js +146 -0
- package/dist/ConnectionManager.d.ts +38 -0
- package/dist/ConnectionManager.js +78 -0
- package/dist/api/MediaTransport.d.ts +7 -0
- package/dist/api/MediaTransport.js +262 -0
- package/dist/api/PlutoPeerConnection.d.ts +38 -0
- package/dist/api/PlutoPeerConnection.js +242 -0
- package/dist/api/PlutoWebSocket.d.ts +24 -0
- package/dist/api/PlutoWebSocket.js +112 -0
- package/dist/api/PlutoWebTransport.d.ts +28 -0
- package/dist/api/PlutoWebTransport.js +88 -0
- package/dist/core/Client.d.ts +70 -0
- package/dist/core/Client.js +326 -0
- package/dist/core/Connection.d.ts +66 -0
- package/dist/core/Connection.js +392 -0
- package/dist/core/Room.d.ts +29 -0
- package/dist/core/Room.js +297 -0
- package/dist/core/Signaling.d.ts +37 -0
- package/dist/core/Signaling.js +199 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/react/index.d.ts +9 -0
- package/dist/react/index.js +34 -0
- package/package.json +47 -0
- package/wasm/pkg/README.md +218 -0
- package/wasm/pkg/iroh_wasm.d.ts +148 -0
- package/wasm/pkg/iroh_wasm.js +1382 -0
- package/wasm/pkg/iroh_wasm_bg.wasm +0 -0
- package/wasm/pkg/iroh_wasm_bg.wasm.d.ts +58 -0
- package/wasm/pkg/package.json +15 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { doc, runTransaction, serverTimestamp, collection, getDocs, updateDoc } from 'firebase/firestore';
|
|
2
|
+
export class RoomManager {
|
|
3
|
+
constructor(client, signaling) {
|
|
4
|
+
this.heartbeatInterval = null;
|
|
5
|
+
this.client = client;
|
|
6
|
+
this.signaling = signaling;
|
|
7
|
+
}
|
|
8
|
+
get db() {
|
|
9
|
+
return this.signaling.db;
|
|
10
|
+
}
|
|
11
|
+
get tag() {
|
|
12
|
+
// We access tag via signaling options, but we don't have it exposed directly on Signaling instance publicly
|
|
13
|
+
// A bit of a hack, but let's assume options are available or we pass it
|
|
14
|
+
// For now, let's fix Signaling to expose tag or access it differently.
|
|
15
|
+
// Actually, let's just re-use the tag from the signaling instance if we expose it or passed it.
|
|
16
|
+
// I will update Signaling.ts to expose tag as public readonly or getter.
|
|
17
|
+
// For now I'll cast to any as a temporary measure or fix Signaling in next step.
|
|
18
|
+
return this.signaling.tag;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Creates a new room (or joins the special 'demo' room if specified)
|
|
22
|
+
*/
|
|
23
|
+
async createRoom() {
|
|
24
|
+
if (!this.signaling.currentUser)
|
|
25
|
+
throw new Error("Must be signed in");
|
|
26
|
+
const myTicket = await this.client.getTicket();
|
|
27
|
+
const userId = this.signaling.currentUser.uid;
|
|
28
|
+
const myNodeId = await this.client.getNodeId();
|
|
29
|
+
// Generate 6-char random ID
|
|
30
|
+
// Try up to 3 times to account for collisions
|
|
31
|
+
let roomId = '';
|
|
32
|
+
let created = false;
|
|
33
|
+
for (let i = 0; i < 3; i++) {
|
|
34
|
+
roomId = Math.random().toString(36).substring(2, 8).toUpperCase();
|
|
35
|
+
const roomRef = doc(this.db, `custom/${this.tag}/rooms`, roomId);
|
|
36
|
+
const memberRef = doc(this.db, `custom/${this.tag}/rooms`, roomId, 'members', myNodeId);
|
|
37
|
+
try {
|
|
38
|
+
await runTransaction(this.db, async (transaction) => {
|
|
39
|
+
const roomDoc = await transaction.get(roomRef);
|
|
40
|
+
if (roomDoc.exists()) {
|
|
41
|
+
throw "Room already exists"; // Collision, try again
|
|
42
|
+
}
|
|
43
|
+
// Create room parent
|
|
44
|
+
transaction.set(roomRef, {
|
|
45
|
+
hostId: userId,
|
|
46
|
+
memberCount: 1,
|
|
47
|
+
maxMembers: 10,
|
|
48
|
+
status: 'open',
|
|
49
|
+
createdAt: serverTimestamp()
|
|
50
|
+
});
|
|
51
|
+
// Add self as member
|
|
52
|
+
transaction.set(memberRef, {
|
|
53
|
+
userId: userId, // Store userId for auth verification
|
|
54
|
+
ticket: myTicket,
|
|
55
|
+
joinedAt: serverTimestamp()
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
console.log(`Room created: ${roomId}`);
|
|
59
|
+
created = true;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
if (e === "Room already exists") {
|
|
64
|
+
console.log(`Collision for roomId ${roomId}, retrying...`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
throw e; // Real error
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!created) {
|
|
71
|
+
throw new Error("Failed to create room after multiple attempts (ID collisions).");
|
|
72
|
+
}
|
|
73
|
+
return roomId;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Joins a room by ID.
|
|
77
|
+
*/
|
|
78
|
+
async joinRoom(roomId) {
|
|
79
|
+
if (!this.signaling.currentUser)
|
|
80
|
+
throw new Error("Must be signed in");
|
|
81
|
+
const myTicket = await this.client.getTicket();
|
|
82
|
+
const userId = this.signaling.currentUser.uid;
|
|
83
|
+
const myNodeId = await this.client.getNodeId();
|
|
84
|
+
const normalizedId = roomId.toUpperCase();
|
|
85
|
+
const targetRoomId = roomId === 'demo' ? 'demo' : roomId;
|
|
86
|
+
const roomRef = doc(this.db, `custom/${this.tag}/rooms`, targetRoomId);
|
|
87
|
+
const memberRef = doc(this.db, `custom/${this.tag}/rooms`, targetRoomId, 'members', myNodeId);
|
|
88
|
+
let createdDemo = false;
|
|
89
|
+
try {
|
|
90
|
+
await runTransaction(this.db, async (transaction) => {
|
|
91
|
+
const roomDoc = await transaction.get(roomRef);
|
|
92
|
+
if (!roomDoc.exists()) {
|
|
93
|
+
if (targetRoomId === 'demo') {
|
|
94
|
+
// Create demo room on the fly
|
|
95
|
+
transaction.set(roomRef, {
|
|
96
|
+
hostId: 'system',
|
|
97
|
+
memberCount: 1,
|
|
98
|
+
maxMembers: 100, // Explicit 100 limit for demo
|
|
99
|
+
status: 'open',
|
|
100
|
+
createdAt: serverTimestamp(),
|
|
101
|
+
isDemo: true
|
|
102
|
+
});
|
|
103
|
+
createdDemo = true;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
throw "Room does not exist!";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const data = roomDoc.data();
|
|
111
|
+
const currentCount = data.memberCount || 0;
|
|
112
|
+
const maxMembers = data.maxMembers || 10;
|
|
113
|
+
if (currentCount >= maxMembers) {
|
|
114
|
+
// Check if I am ALREADY a member
|
|
115
|
+
const memberDoc = await transaction.get(memberRef);
|
|
116
|
+
if (memberDoc.exists()) {
|
|
117
|
+
// Already joined, just update ticket
|
|
118
|
+
transaction.update(memberRef, { ticket: myTicket });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
throw "Room is full!";
|
|
122
|
+
}
|
|
123
|
+
transaction.update(roomRef, { memberCount: currentCount + 1 });
|
|
124
|
+
}
|
|
125
|
+
// Add/Update member
|
|
126
|
+
if (!createdDemo) {
|
|
127
|
+
transaction.set(memberRef, {
|
|
128
|
+
userId: userId,
|
|
129
|
+
ticket: myTicket,
|
|
130
|
+
joinedAt: serverTimestamp()
|
|
131
|
+
}, { merge: true });
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
transaction.set(memberRef, {
|
|
135
|
+
userId: userId,
|
|
136
|
+
ticket: myTicket,
|
|
137
|
+
joinedAt: serverTimestamp()
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
console.log(`Joined room: ${targetRoomId}`);
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
console.error("Join Transaction failed: ", e);
|
|
145
|
+
throw e;
|
|
146
|
+
}
|
|
147
|
+
// BOOTSTRAP: Connect to existing members
|
|
148
|
+
const membersRef = collection(this.db, `custom/${this.tag}/rooms`, targetRoomId, 'members');
|
|
149
|
+
const membersSnap = await getDocs(membersRef);
|
|
150
|
+
const connections = [];
|
|
151
|
+
for (const doc of membersSnap.docs) {
|
|
152
|
+
if (doc.id === myNodeId)
|
|
153
|
+
continue; // Skip self
|
|
154
|
+
const data = doc.data();
|
|
155
|
+
// Check expiration
|
|
156
|
+
let isExpired = false;
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
if (data.expiresAt) {
|
|
159
|
+
const exp = data.expiresAt.seconds ? data.expiresAt.seconds * 1000 : new Date(data.expiresAt).getTime();
|
|
160
|
+
if (exp < now)
|
|
161
|
+
isExpired = true;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const lastSeen = data.lastSeenAt?.toMillis() || data.joinedAt?.toMillis() || 0;
|
|
165
|
+
if (now - lastSeen > 5 * 60 * 1000)
|
|
166
|
+
isExpired = true;
|
|
167
|
+
}
|
|
168
|
+
if (isExpired)
|
|
169
|
+
continue;
|
|
170
|
+
const ticket = data.ticket;
|
|
171
|
+
if (ticket) {
|
|
172
|
+
try {
|
|
173
|
+
console.log(`Bootstrapping: Connecting to peer ${doc.id}...`);
|
|
174
|
+
const conn = await this.client.connect(ticket);
|
|
175
|
+
conn.send({ type: 'chat', text: `Use ${userId} joined the room.` });
|
|
176
|
+
connections.push(conn);
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
console.warn(`Failed to connect to peer ${doc.id}:`, e);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Start Heartbeat
|
|
184
|
+
this.startHeartbeat(targetRoomId, myNodeId);
|
|
185
|
+
// Prune Stale Members (Lazy cleanup)
|
|
186
|
+
this.pruneStaleMembers(targetRoomId).catch(console.error);
|
|
187
|
+
return connections;
|
|
188
|
+
}
|
|
189
|
+
async leaveRoom(roomId) {
|
|
190
|
+
if (!this.signaling.currentUser)
|
|
191
|
+
return;
|
|
192
|
+
const myNodeId = await this.client.getNodeId();
|
|
193
|
+
if (!myNodeId)
|
|
194
|
+
return;
|
|
195
|
+
console.log(`Leaving room ${roomId}...`);
|
|
196
|
+
this.stopHeartbeat();
|
|
197
|
+
const targetRoomId = roomId === 'demo' ? 'demo' : roomId;
|
|
198
|
+
const roomRef = doc(this.db, `custom/${this.tag}/rooms`, targetRoomId);
|
|
199
|
+
const memberRef = doc(this.db, `custom/${this.tag}/rooms`, targetRoomId, 'members', myNodeId);
|
|
200
|
+
try {
|
|
201
|
+
await runTransaction(this.db, async (transaction) => {
|
|
202
|
+
const roomDoc = await transaction.get(roomRef);
|
|
203
|
+
const memberDoc = await transaction.get(memberRef);
|
|
204
|
+
if (!memberDoc.exists()) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
transaction.delete(memberRef);
|
|
208
|
+
if (roomDoc.exists()) {
|
|
209
|
+
const currentCount = roomDoc.data().memberCount || 0;
|
|
210
|
+
if (currentCount > 0) {
|
|
211
|
+
transaction.update(roomRef, { memberCount: currentCount - 1 });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
console.log(`Left room: ${targetRoomId}`);
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
console.warn("Leave room failed:", e);
|
|
219
|
+
throw e;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// --- Heartbeats & Cleanup ---
|
|
223
|
+
// --- Heartbeats & Cleanup ---
|
|
224
|
+
startHeartbeat(roomId, memberId) {
|
|
225
|
+
if (this.heartbeatInterval)
|
|
226
|
+
clearInterval(this.heartbeatInterval);
|
|
227
|
+
const memberRef = doc(this.db, `custom/${this.tag}/rooms`, roomId, 'members', memberId);
|
|
228
|
+
const update = () => {
|
|
229
|
+
// Expires in 2 minutes
|
|
230
|
+
const expiresAt = new Date(Date.now() + 2 * 60 * 1000);
|
|
231
|
+
updateDoc(memberRef, {
|
|
232
|
+
lastSeenAt: serverTimestamp(),
|
|
233
|
+
expiresAt: expiresAt
|
|
234
|
+
}).catch(e => {
|
|
235
|
+
console.warn("Heartbeat failed:", e);
|
|
236
|
+
if (e.message && e.message.includes('No document to update')) {
|
|
237
|
+
console.log("Member document not found, stopping heartbeat.");
|
|
238
|
+
clearInterval(this.heartbeatInterval);
|
|
239
|
+
this.heartbeatInterval = null;
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
update(); // Initial update
|
|
244
|
+
this.heartbeatInterval = setInterval(update, 60000); // 1 minute
|
|
245
|
+
}
|
|
246
|
+
stopHeartbeat() {
|
|
247
|
+
if (this.heartbeatInterval) {
|
|
248
|
+
clearInterval(this.heartbeatInterval);
|
|
249
|
+
this.heartbeatInterval = null;
|
|
250
|
+
console.log("Heartbeat stopped.");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async pruneStaleMembers(roomId) {
|
|
254
|
+
const membersRef = collection(this.db, `custom/${this.tag}/rooms`, roomId, 'members');
|
|
255
|
+
const now = Date.now();
|
|
256
|
+
const snapshot = await getDocs(membersRef);
|
|
257
|
+
const prunePromises = snapshot.docs.map(async (docSnap) => {
|
|
258
|
+
const data = docSnap.data();
|
|
259
|
+
let isExpired = false;
|
|
260
|
+
if (data.expiresAt) {
|
|
261
|
+
// Handle both Firestore Timestamp and JS Date/number
|
|
262
|
+
const exp = data.expiresAt.seconds ? data.expiresAt.seconds * 1000 : new Date(data.expiresAt).getTime();
|
|
263
|
+
if (exp < now)
|
|
264
|
+
isExpired = true;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
// Fallback for legacy members without expiresAt
|
|
268
|
+
const lastSeen = data.lastSeenAt?.toMillis() || data.joinedAt?.toMillis() || 0;
|
|
269
|
+
if (now - lastSeen > 5 * 60 * 1000)
|
|
270
|
+
isExpired = true;
|
|
271
|
+
}
|
|
272
|
+
if (isExpired) {
|
|
273
|
+
console.log(`Pruning stale member ${docSnap.id}`);
|
|
274
|
+
try {
|
|
275
|
+
const roomRef = doc(this.db, `custom/${this.tag}/rooms`, roomId);
|
|
276
|
+
await runTransaction(this.db, async (transaction) => {
|
|
277
|
+
const roomDoc = await transaction.get(roomRef);
|
|
278
|
+
if (!roomDoc.exists())
|
|
279
|
+
return;
|
|
280
|
+
const memberDoc = await transaction.get(docSnap.ref);
|
|
281
|
+
if (!memberDoc.exists())
|
|
282
|
+
return;
|
|
283
|
+
transaction.delete(docSnap.ref);
|
|
284
|
+
const currentCount = roomDoc.data().memberCount || 0;
|
|
285
|
+
if (currentCount > 0) {
|
|
286
|
+
transaction.update(roomRef, { memberCount: currentCount - 1 });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
catch (e) {
|
|
291
|
+
console.warn(`Failed to prune member ${docSnap.id}:`, e);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
await Promise.all(prunePromises);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { FirebaseApp } from 'firebase/app';
|
|
2
|
+
import { Auth, User } from 'firebase/auth';
|
|
3
|
+
import { Firestore } from 'firebase/firestore';
|
|
4
|
+
export interface SignalingOptions {
|
|
5
|
+
tag: string;
|
|
6
|
+
storagePrefix?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare class Signaling {
|
|
9
|
+
app: FirebaseApp;
|
|
10
|
+
auth: Auth;
|
|
11
|
+
db: Firestore;
|
|
12
|
+
tag: string;
|
|
13
|
+
private options;
|
|
14
|
+
private authListeners;
|
|
15
|
+
constructor(options: SignalingOptions);
|
|
16
|
+
get currentUser(): User | null;
|
|
17
|
+
onAuthChange(callback: (user: User | null) => void): void;
|
|
18
|
+
signInAnonymously(): Promise<void>;
|
|
19
|
+
signInWithPluto(): void;
|
|
20
|
+
signOut(): Promise<void>;
|
|
21
|
+
private checkForSSOToken;
|
|
22
|
+
updatePresence(localNodeId: string, ticketStr: string, isOnline?: boolean, ttlMs?: number): Promise<void>;
|
|
23
|
+
setOffline(localNodeId: string): Promise<void>;
|
|
24
|
+
cleanupStaleDevices(): Promise<void>;
|
|
25
|
+
searchDevices(excludeNodeId?: string): Promise<{
|
|
26
|
+
deviceId: string;
|
|
27
|
+
deviceName: string;
|
|
28
|
+
online: boolean;
|
|
29
|
+
ticket: string;
|
|
30
|
+
}[]>;
|
|
31
|
+
onDevicesChange(callback: (devices: {
|
|
32
|
+
deviceId: string;
|
|
33
|
+
deviceName: string;
|
|
34
|
+
online: boolean;
|
|
35
|
+
ticket: string;
|
|
36
|
+
}[]) => void, excludeNodeId?: string): () => void;
|
|
37
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { initializeApp } from 'firebase/app';
|
|
2
|
+
import { getAuth, onAuthStateChanged, signInWithCustomToken, signInAnonymously } from 'firebase/auth';
|
|
3
|
+
import { getFirestore, doc, setDoc, updateDoc, serverTimestamp, deleteDoc, collection, query, where, getDocs, onSnapshot, Timestamp } from 'firebase/firestore';
|
|
4
|
+
// Internal Firebase Config
|
|
5
|
+
const FIREBASE_CONFIG = {
|
|
6
|
+
apiKey: "AIzaSyAxyDE1xSYNAk5Ohe8VvKWi3xHOxB4cNV8",
|
|
7
|
+
authDomain: "plutonium.hargreaves.dev",
|
|
8
|
+
projectId: "files-bb895",
|
|
9
|
+
storageBucket: "files-bb895.firebasestorage.app",
|
|
10
|
+
messagingSenderId: "734446020343",
|
|
11
|
+
appId: "1:734446020343:web:da52ff2b8170058fb6d5f2",
|
|
12
|
+
measurementId: "G-FM0GQ9QF30"
|
|
13
|
+
};
|
|
14
|
+
export class Signaling {
|
|
15
|
+
constructor(options) {
|
|
16
|
+
// Auth listeners
|
|
17
|
+
this.authListeners = [];
|
|
18
|
+
if (!options.tag)
|
|
19
|
+
throw new Error("Signaling requires a 'tag' option.");
|
|
20
|
+
this.tag = options.tag;
|
|
21
|
+
this.options = options;
|
|
22
|
+
this.app = initializeApp(FIREBASE_CONFIG);
|
|
23
|
+
this.auth = getAuth(this.app);
|
|
24
|
+
// Enable persistence
|
|
25
|
+
import('firebase/auth').then(({ setPersistence, browserLocalPersistence }) => {
|
|
26
|
+
setPersistence(this.auth, browserLocalPersistence).catch(console.error);
|
|
27
|
+
});
|
|
28
|
+
this.db = getFirestore(this.app);
|
|
29
|
+
// Check for SSO token immediately
|
|
30
|
+
this.checkForSSOToken();
|
|
31
|
+
// Propagate auth state changes
|
|
32
|
+
onAuthStateChanged(this.auth, (user) => {
|
|
33
|
+
this.authListeners.forEach(cb => cb(user));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
get currentUser() {
|
|
37
|
+
return this.auth.currentUser;
|
|
38
|
+
}
|
|
39
|
+
onAuthChange(callback) {
|
|
40
|
+
this.authListeners.push(callback);
|
|
41
|
+
// Fire immediately with current state if known
|
|
42
|
+
if (this.auth.currentUser !== undefined) {
|
|
43
|
+
callback(this.auth.currentUser);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async signInAnonymously() {
|
|
47
|
+
await signInAnonymously(this.auth);
|
|
48
|
+
}
|
|
49
|
+
signInWithPluto() {
|
|
50
|
+
const plutoAuthUrl = 'https://plutonium.hargreaves.dev/sso/authorize';
|
|
51
|
+
const redirectUri = window.location.href;
|
|
52
|
+
const url = new URL(plutoAuthUrl);
|
|
53
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
54
|
+
window.location.href = url.toString();
|
|
55
|
+
}
|
|
56
|
+
async signOut() {
|
|
57
|
+
await this.auth.signOut();
|
|
58
|
+
await this.signInAnonymously();
|
|
59
|
+
}
|
|
60
|
+
async checkForSSOToken() {
|
|
61
|
+
if (typeof window === 'undefined')
|
|
62
|
+
return;
|
|
63
|
+
const hash = window.location.hash;
|
|
64
|
+
if (hash && hash.includes('token=')) {
|
|
65
|
+
const params = new URLSearchParams(hash.substring(1));
|
|
66
|
+
const token = params.get('token');
|
|
67
|
+
if (token) {
|
|
68
|
+
console.log("Found SSO token, signing in...");
|
|
69
|
+
try {
|
|
70
|
+
await signInWithCustomToken(this.auth, token);
|
|
71
|
+
console.log("SSO Sign in successful");
|
|
72
|
+
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
console.error("SSO Sign in failed", e);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// --- Presence ---
|
|
81
|
+
async updatePresence(localNodeId, ticketStr, isOnline = true, ttlMs = 5 * 60 * 1000) {
|
|
82
|
+
if (!this.auth.currentUser)
|
|
83
|
+
return;
|
|
84
|
+
let name = "Web Client";
|
|
85
|
+
if (typeof navigator !== 'undefined') {
|
|
86
|
+
if (navigator.userAgent.includes("Chrome"))
|
|
87
|
+
name = "Chrome";
|
|
88
|
+
else if (navigator.userAgent.includes("Firefox"))
|
|
89
|
+
name = "Firefox";
|
|
90
|
+
else if (navigator.userAgent.includes("Safari"))
|
|
91
|
+
name = "Safari";
|
|
92
|
+
}
|
|
93
|
+
if (this.options.storagePrefix) {
|
|
94
|
+
const prefix = this.options.storagePrefix.replace(/_$/, '');
|
|
95
|
+
name = `${name} (${prefix})`;
|
|
96
|
+
}
|
|
97
|
+
const deviceRef = doc(this.db, 'users', this.auth.currentUser.uid, 'devices', localNodeId);
|
|
98
|
+
const expiresAt = Timestamp.fromMillis(Date.now() + ttlMs);
|
|
99
|
+
try {
|
|
100
|
+
await setDoc(deviceRef, {
|
|
101
|
+
userId: this.auth.currentUser.uid,
|
|
102
|
+
nodeId: localNodeId,
|
|
103
|
+
ticket: ticketStr,
|
|
104
|
+
deviceName: name,
|
|
105
|
+
platformType: 'web',
|
|
106
|
+
tag: this.tag,
|
|
107
|
+
online: isOnline,
|
|
108
|
+
lastSeenAt: serverTimestamp(),
|
|
109
|
+
expiresAt: expiresAt
|
|
110
|
+
}, { merge: true });
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
console.warn("Update presence failed", e);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async setOffline(localNodeId) {
|
|
117
|
+
if (this.auth.currentUser && localNodeId) {
|
|
118
|
+
const deviceRef = doc(this.db, 'users', this.auth.currentUser.uid, 'devices', localNodeId);
|
|
119
|
+
updateDoc(deviceRef, { online: false }).catch(() => { });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async cleanupStaleDevices() {
|
|
123
|
+
if (!this.auth.currentUser)
|
|
124
|
+
return;
|
|
125
|
+
const devicesRef = collection(this.db, 'users', this.auth.currentUser.uid, 'devices');
|
|
126
|
+
// Delete expired devices
|
|
127
|
+
const now = Timestamp.now();
|
|
128
|
+
const q = query(devicesRef, where('expiresAt', '<', now));
|
|
129
|
+
try {
|
|
130
|
+
const snapshot = await getDocs(q);
|
|
131
|
+
const deletePromises = [];
|
|
132
|
+
snapshot.forEach(doc => {
|
|
133
|
+
deletePromises.push(deleteDoc(doc.ref));
|
|
134
|
+
});
|
|
135
|
+
if (deletePromises.length > 0) {
|
|
136
|
+
await Promise.all(deletePromises);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
console.warn("Failed to cleanup stale devices:", e);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// --- Discovery ---
|
|
144
|
+
async searchDevices(excludeNodeId) {
|
|
145
|
+
if (!this.auth.currentUser)
|
|
146
|
+
return [];
|
|
147
|
+
const devicesRef = collection(this.db, 'users', this.auth.currentUser.uid, 'devices');
|
|
148
|
+
const snapshot = await getDocs(devicesRef);
|
|
149
|
+
const devices = [];
|
|
150
|
+
snapshot.forEach(doc => {
|
|
151
|
+
const data = doc.data();
|
|
152
|
+
if (excludeNodeId && data.nodeId === excludeNodeId)
|
|
153
|
+
return;
|
|
154
|
+
// Check expiration
|
|
155
|
+
if (data.expiresAt) {
|
|
156
|
+
const expiresCmp = data.expiresAt instanceof Timestamp ? data.expiresAt.toMillis() : (data.expiresAt.seconds * 1000);
|
|
157
|
+
if (expiresCmp < Date.now())
|
|
158
|
+
return; // Expired
|
|
159
|
+
}
|
|
160
|
+
devices.push({
|
|
161
|
+
deviceId: doc.id,
|
|
162
|
+
deviceName: data.deviceName || 'Unknown',
|
|
163
|
+
online: data.online,
|
|
164
|
+
ticket: data.ticket || data.nodeId
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
return devices;
|
|
168
|
+
}
|
|
169
|
+
onDevicesChange(callback, excludeNodeId) {
|
|
170
|
+
if (!this.auth.currentUser)
|
|
171
|
+
return () => { };
|
|
172
|
+
const devicesRef = collection(this.db, 'users', this.auth.currentUser.uid, 'devices');
|
|
173
|
+
// Filter by tag to only show relevant devices
|
|
174
|
+
const q = query(devicesRef, where('tag', '==', this.tag));
|
|
175
|
+
const unsub = onSnapshot(q, (snapshot) => {
|
|
176
|
+
const devices = [];
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
snapshot.forEach(doc => {
|
|
179
|
+
const data = doc.data();
|
|
180
|
+
if (excludeNodeId && data.nodeId === excludeNodeId)
|
|
181
|
+
return;
|
|
182
|
+
// Check expiration
|
|
183
|
+
if (data.expiresAt) {
|
|
184
|
+
const expiresCmp = data.expiresAt instanceof Timestamp ? data.expiresAt.toMillis() : (data.expiresAt.seconds * 1000);
|
|
185
|
+
if (expiresCmp < now)
|
|
186
|
+
return; // Expired
|
|
187
|
+
}
|
|
188
|
+
devices.push({
|
|
189
|
+
deviceId: doc.id,
|
|
190
|
+
deviceName: data.deviceName || 'Unknown',
|
|
191
|
+
online: data.online,
|
|
192
|
+
ticket: data.ticket || data.nodeId
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
callback(devices);
|
|
196
|
+
});
|
|
197
|
+
return unsub;
|
|
198
|
+
}
|
|
199
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './core/Client';
|
|
2
|
+
export * from './core/Connection';
|
|
3
|
+
export * from './core/Signaling';
|
|
4
|
+
export * from './core/Room';
|
|
5
|
+
export * from './ConnectionManager';
|
|
6
|
+
export * from './api/PlutoWebSocket';
|
|
7
|
+
export * from './api/PlutoWebTransport';
|
|
8
|
+
export * from './api/PlutoPeerConnection';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './core/Client';
|
|
2
|
+
export * from './core/Connection';
|
|
3
|
+
export * from './core/Signaling';
|
|
4
|
+
export * from './core/Room';
|
|
5
|
+
export * from './ConnectionManager';
|
|
6
|
+
export * from './api/PlutoWebSocket';
|
|
7
|
+
export * from './api/PlutoWebTransport';
|
|
8
|
+
export * from './api/PlutoPeerConnection';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React, { ReactNode } from 'react';
|
|
2
|
+
import { Client, ClientOptions } from '../core/Client';
|
|
3
|
+
export interface PlutoProviderProps {
|
|
4
|
+
client?: Client;
|
|
5
|
+
clientOptions?: ClientOptions;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
export declare const PlutoProvider: React.FC<PlutoProviderProps>;
|
|
9
|
+
export declare const usePlutoClient: () => Client;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useState } from 'react';
|
|
2
|
+
import { Client } from '../core/Client';
|
|
3
|
+
const PlutoContext = createContext(null);
|
|
4
|
+
export const PlutoProvider = ({ client: existingClient, clientOptions, children }) => {
|
|
5
|
+
const [client, setClient] = useState(existingClient || null);
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (existingClient) {
|
|
8
|
+
setClient(existingClient);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
if (clientOptions) {
|
|
12
|
+
const newClient = new Client(clientOptions);
|
|
13
|
+
newClient.init().then(() => {
|
|
14
|
+
setClient(newClient);
|
|
15
|
+
});
|
|
16
|
+
return () => {
|
|
17
|
+
// Should we stop listening on unmount?
|
|
18
|
+
// Mostly yes, to cleanup resources in that context context.
|
|
19
|
+
newClient.stopListening();
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}, [existingClient, clientOptions]);
|
|
23
|
+
if (!client) {
|
|
24
|
+
return null; // Or some loading state? null is safer for now.
|
|
25
|
+
}
|
|
26
|
+
return (React.createElement(PlutoContext.Provider, { value: client }, children));
|
|
27
|
+
};
|
|
28
|
+
export const usePlutoClient = () => {
|
|
29
|
+
const context = useContext(PlutoContext);
|
|
30
|
+
if (!context) {
|
|
31
|
+
throw new Error("usePlutoClient must be used within a PlutoProvider");
|
|
32
|
+
}
|
|
33
|
+
return context;
|
|
34
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pluto-rtc",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "A WebRTC library for Plutonium, supporting secure P2P communication.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"webrtc",
|
|
8
|
+
"p2p",
|
|
9
|
+
"networking",
|
|
10
|
+
"plutonium",
|
|
11
|
+
"iroh"
|
|
12
|
+
],
|
|
13
|
+
"author": "Bryant Hargreaves",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"homepage": "https://plutonium.hargreaves.dev",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/bluestarburst/Plutonium.git"
|
|
19
|
+
},
|
|
20
|
+
"main": "dist/index.js",
|
|
21
|
+
"types": "dist/index.d.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"wasm"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc",
|
|
28
|
+
"build:wasm": "export CC=/opt/homebrew/opt/llvm/bin/clang && export AR=/opt/homebrew/opt/llvm/bin/llvm-ar && cd ../src-tauri/crates/desktop/iroh-wasm && wasm-pack build --target web --out-dir ../../../../pluto-rtc/wasm/pkg && rm -rf ../../../../pluto-rtc/wasm/pkg/.gitignore",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest",
|
|
31
|
+
"coverage": "vitest run --coverage",
|
|
32
|
+
"prepublishOnly": "npm run build:wasm && npm run build"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"firebase": "^9.0.0 || ^10.0.0 || ^11.0.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"firebase": "^9.0.0 || ^10.0.0 || ^11.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^20.0.0",
|
|
42
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
43
|
+
"happy-dom": "^20.5.0",
|
|
44
|
+
"typescript": "^5.0.0",
|
|
45
|
+
"vitest": "^4.0.18"
|
|
46
|
+
}
|
|
47
|
+
}
|