filepizza-client 0.1.0 → 2.0.0-alpha.0

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.
@@ -0,0 +1,789 @@
1
+ // src/filepizza-downloader.ts
2
+ import Peer, { DataConnection } from 'peerjs'
3
+ import { EventEmitter } from './event-emitter'
4
+ import { DownloadHelper } from './download-helper';
5
+ import { FileInfo, ProgressInfo, ConnectionStatus, CompletedFile, MessageType } from './types'
6
+
7
+ /**
8
+ * FilePizza Downloader - connects to FilePizza uploads
9
+ */
10
+ export class FilePizzaDownloader extends EventEmitter {
11
+ private peer?: Peer;
12
+ private connection?: DataConnection;
13
+ private filePizzaServerUrl: string;
14
+ private filesInfo: FileInfo[] = [];
15
+ private currentFileIndex = 0;
16
+ private currentFileBytesReceived = 0;
17
+ private totalBytesReceived = 0;
18
+ private totalBytes = 0;
19
+ private status = ConnectionStatus.Pending;
20
+ private fileStreams: Map<string, {
21
+ stream: ReadableStream<Uint8Array>;
22
+ enqueue: (chunk: Uint8Array) => void;
23
+ close: () => void;
24
+ }> = new Map();
25
+ private isPasswordRequired = false;
26
+ private isPasswordInvalid = false;
27
+ private errorMessage?: string;
28
+ private iceServers?: RTCIceServer[];
29
+ private completedFiles: CompletedFile[] = [];
30
+
31
+ /**
32
+ * Create a new FilePizza downloader
33
+ * @param options Configuration options
34
+ */
35
+ constructor(options: {
36
+ filePizzaServerUrl?: string;
37
+ } = {}) {
38
+ super();
39
+ this.filePizzaServerUrl = options.filePizzaServerUrl || 'http://localhost:8081';
40
+ }
41
+
42
+ /**
43
+ * Initialize the downloader
44
+ */
45
+ async initialize(): Promise<void> {
46
+ if (this.peer) {
47
+ return;
48
+ }
49
+
50
+ // Get ICE servers
51
+ await this.getIceServers();
52
+
53
+ // Initialize PeerJS
54
+ this.peer = new Peer({
55
+ config: {
56
+ iceServers: this.iceServers || [{ urls: 'stun:stun.l.google.com:19302' }],
57
+ },
58
+ debug: 2,
59
+ });
60
+
61
+ // Wait for peer to be ready
62
+ if (!this.peer.id) {
63
+ await new Promise<void>((resolve) => {
64
+ const onOpen = () => {
65
+ this.peer?.off('open', onOpen);
66
+ resolve();
67
+ };
68
+ this.peer?.on('open', onOpen);
69
+ });
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Connect to an uploader using a FilePizza URL or slug
75
+ */
76
+ async connect(urlOrSlug: string): Promise<boolean> {
77
+ // Extract slug from URL if needed
78
+ const slug = this.extractSlug(urlOrSlug);
79
+
80
+ try {
81
+ // Look up the uploader's peer ID
82
+ const uploaderPeerID = await this.lookupUploaderPeerID(slug);
83
+
84
+ // Now connect directly to the uploader
85
+ return this.connectToPeer(uploaderPeerID);
86
+ } catch (error) {
87
+ this.errorMessage = `Failed to connect: ${error instanceof Error ? error.message : String(error)}`;
88
+ this.emit('error', this.errorMessage);
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Submit password for protected download
95
+ */
96
+ submitPassword(password: string): void {
97
+ if (!this.connection || this.status !== ConnectionStatus.Authenticating) {
98
+ throw new Error('Not in authentication state');
99
+ }
100
+
101
+ this.connection.send({
102
+ type: MessageType.UsePassword,
103
+ password,
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Start downloading the files
109
+ */
110
+ async startDownload(): Promise<void> {
111
+ // This is just a stub - actual implementation will depend on how you want to save files
112
+ // The real FilePizza implementation uses the StreamSaver library
113
+
114
+ if (!this.connection) {
115
+ throw new Error('Not connected');
116
+ }
117
+
118
+ if (this.filesInfo.length === 0) {
119
+ throw new Error('No files available');
120
+ }
121
+
122
+ if (this.status !== ConnectionStatus.Ready) {
123
+ throw new Error(`Cannot start download in current state: ${this.status}`);
124
+ }
125
+
126
+ this.status = ConnectionStatus.Downloading;
127
+ this.currentFileIndex = 0;
128
+ this.currentFileBytesReceived = 0;
129
+ this.totalBytesReceived = 0;
130
+
131
+ // Initialize file streams
132
+ this.initializeFileStreams();
133
+
134
+ // Request the first file
135
+ this.requestNextFile();
136
+ }
137
+
138
+ /**
139
+ * Pause the download
140
+ */
141
+ pauseDownload(): void {
142
+ if (!this.connection || this.status !== ConnectionStatus.Downloading) {
143
+ return;
144
+ }
145
+
146
+ this.connection.send({ type: MessageType.Pause });
147
+ this.status = ConnectionStatus.Paused;
148
+ this.emit('paused');
149
+ }
150
+
151
+ /**
152
+ * Resume the download
153
+ */
154
+ resumeDownload(): void {
155
+ if (!this.connection || this.status !== ConnectionStatus.Paused) {
156
+ return;
157
+ }
158
+
159
+ const currentFile = this.filesInfo[this.currentFileIndex];
160
+
161
+ this.connection.send({
162
+ type: MessageType.Resume,
163
+ fileName: currentFile.fileName,
164
+ offset: this.currentFileBytesReceived,
165
+ });
166
+
167
+ this.status = ConnectionStatus.Downloading;
168
+ this.emit('resumed');
169
+ }
170
+
171
+ /**
172
+ * Cancel the download
173
+ */
174
+ /**
175
+ * Cancel the download
176
+ */
177
+ cancelDownload(): void {
178
+ // Close all file streams
179
+ for (const fileStreamData of this.fileStreams.values()) {
180
+ // Check if the stream is already closed before closing it
181
+ if (fileStreamData.stream.locked) {
182
+ try {
183
+ fileStreamData.close();
184
+ } catch (error) {
185
+ console.warn('Error closing stream:', error);
186
+ }
187
+ }
188
+ }
189
+ this.fileStreams.clear();
190
+
191
+ // Close the connection
192
+ if (this.connection) {
193
+ if (this.connection.open) {
194
+ this.connection.close();
195
+ }
196
+ this.connection = undefined;
197
+ }
198
+
199
+ this.status = ConnectionStatus.Closed;
200
+ this.emit('cancelled');
201
+ }
202
+
203
+ /**
204
+ * Get file information
205
+ */
206
+ getFileInfo(): FileInfo[] {
207
+ return this.filesInfo;
208
+ }
209
+
210
+ /**
211
+ * Get download status
212
+ */
213
+ getStatus(): {
214
+ status: ConnectionStatus;
215
+ isPasswordRequired: boolean;
216
+ isPasswordInvalid: boolean;
217
+ errorMessage?: string;
218
+ } {
219
+ return {
220
+ status: this.status,
221
+ isPasswordRequired: this.isPasswordRequired,
222
+ isPasswordInvalid: this.isPasswordInvalid,
223
+ errorMessage: this.errorMessage,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Get progress information
229
+ */
230
+ getProgress(): ProgressInfo {
231
+ return {
232
+ fileIndex: this.currentFileIndex,
233
+ fileName: this.filesInfo[this.currentFileIndex]?.fileName || '',
234
+ totalFiles: this.filesInfo.length,
235
+ currentFileProgress: this.currentFileBytesReceived /
236
+ (this.filesInfo[this.currentFileIndex]?.size || 1),
237
+ overallProgress: this.totalBytesReceived / (this.totalBytes || 1),
238
+ bytesTransferred: this.totalBytesReceived,
239
+ totalBytes: this.totalBytes,
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Extract slug from URL or use directly
245
+ */
246
+ private extractSlug(urlOrSlug: string): string {
247
+ // Check if it's a URL
248
+ if (urlOrSlug.startsWith('http')) {
249
+ const url = new URL(urlOrSlug);
250
+ const pathParts = url.pathname.split('/').filter(Boolean);
251
+
252
+ // Extract the download slug
253
+ if (pathParts[0] === 'download' && pathParts.length > 1) {
254
+ return pathParts.slice(1).join('/');
255
+ } else {
256
+ throw new Error('Invalid FilePizza URL');
257
+ }
258
+ }
259
+
260
+ // If it's not a URL, assume it's already a slug
261
+ return urlOrSlug;
262
+ }
263
+
264
+ /**
265
+ * Extract uploader peer IDs from the FilePizza server when multiple uploaders are present
266
+ */
267
+ private async extractUploaderPeerIDs(slug: string): Promise<string[]> {
268
+ try {
269
+ const response = await fetch(`${this.filePizzaServerUrl}/download/${slug}`);
270
+
271
+ if (!response.ok) {
272
+ throw new Error(`FilePizza server returned ${response.status}`);
273
+ }
274
+
275
+ const html = await response.text();
276
+
277
+ if (!html || html.trim() === '') {
278
+ throw new Error('Received empty response from server');
279
+ }
280
+
281
+ // Match either "uploaderPeerID" (for single uploader) or "primaryUploaderID" and "additionalUploaders" (for multiple)
282
+ const primaryMatch = html.match(/\\"primaryUploaderID\\":\\"([^\\]+)\\"/);
283
+ const singleMatch = html.match(/\\"uploaderPeerID\\":\\"([^\\]+)\\"/);
284
+
285
+ if (primaryMatch && primaryMatch[1]) {
286
+ // Handle multiple uploader case
287
+ const uploaderIDs = [primaryMatch[1]];
288
+
289
+ // Look for additional uploaders
290
+ const additionalMatch = html.match(/\\"additionalUploaders\\":\[([^\]]+)\]/);
291
+ if (additionalMatch && additionalMatch[1]) {
292
+ // Parse additional uploader IDs from JSON string
293
+ const additionalIDs = additionalMatch[1].split(',')
294
+ .map(id => id.trim().replace(/\\"|"/g, ''))
295
+ .filter(id => id.length > 0);
296
+
297
+ uploaderIDs.push(...additionalIDs);
298
+ }
299
+
300
+ return uploaderIDs;
301
+ } else if (singleMatch && singleMatch[1]) {
302
+ // Handle single uploader case
303
+ return [singleMatch[1]];
304
+ }
305
+
306
+ throw new Error('Could not find uploader peer ID');
307
+ } catch (error) {
308
+ console.error('Error extracting uploader peer IDs:', error);
309
+ throw new Error('Failed to look up uploader information');
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Look up the uploader's peer ID from the FilePizza server
315
+ */
316
+ private async lookupUploaderPeerID(slug: string): Promise<string> {
317
+ try {
318
+ const uploaderIDs = await this.extractUploaderPeerIDs(slug);
319
+
320
+ if (uploaderIDs.length === 0) {
321
+ throw new Error('No uploader peer IDs found');
322
+ }
323
+
324
+ // Return the first uploader ID by default
325
+ return uploaderIDs[0];
326
+ } catch (error) {
327
+ console.error('Error looking up uploader peer ID:', error);
328
+ throw new Error('Failed to look up uploader information');
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Connect to a specific uploader by ID
334
+ */
335
+ public async getAvailableUploaders(urlOrSlug: string): Promise<string[]> {
336
+ const slug = this.extractSlug(urlOrSlug);
337
+ return this.extractUploaderPeerIDs(slug);
338
+ }
339
+
340
+ /**
341
+ * Connect to a specific uploader by ID
342
+ */
343
+ public async connectToUploader(uploaderId: string): Promise<boolean> {
344
+ try {
345
+ return this.connectToPeer(uploaderId);
346
+ } catch (error) {
347
+ this.errorMessage = `Failed to connect to uploader: ${error instanceof Error ? error.message : String(error)}`;
348
+ this.emit('error', this.errorMessage);
349
+ return false;
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Get ICE servers from the FilePizza server
355
+ */
356
+ private async getIceServers(): Promise<RTCIceServer[]> {
357
+ try {
358
+ const response = await fetch(`${this.filePizzaServerUrl}/api/ice`, {
359
+ method: 'POST',
360
+ });
361
+
362
+ if (!response.ok) {
363
+ throw new Error(`Failed to get ICE servers: ${response.status}`);
364
+ }
365
+
366
+ const data = await response.json();
367
+ this.iceServers = data.iceServers;
368
+ return data.iceServers;
369
+ } catch (error) {
370
+ console.error('Error getting ICE servers:', error);
371
+ return [{ urls: 'stun:stun.l.google.com:19302' }];
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Connect directly to a peer
377
+ */
378
+ private async connectToPeer(peerId: string): Promise<boolean> {
379
+ // Make sure we're initialized
380
+ await this.initialize();
381
+
382
+ if (!this.peer) {
383
+ throw new Error('Peer not initialized');
384
+ }
385
+
386
+ try {
387
+ // Close existing connection if any
388
+ if (this.connection) {
389
+ this.connection.close();
390
+ }
391
+
392
+ // Connect to the uploader
393
+ this.connection = this.peer.connect(peerId, { reliable: true });
394
+ this.status = ConnectionStatus.Pending;
395
+
396
+ // Set up connection event handlers
397
+ return new Promise<boolean>((resolve) => {
398
+ if (!this.connection) {
399
+ resolve(false);
400
+ return;
401
+ }
402
+
403
+ this.connection.on('open', () => {
404
+ this.status = ConnectionStatus.Ready;
405
+
406
+ // Send request for file info
407
+ if (this.connection) {
408
+ this.connection.send({
409
+ type: MessageType.RequestInfo,
410
+ browserName: this.getBrowserName(),
411
+ browserVersion: this.getBrowserVersion(),
412
+ osName: this.getOSName(),
413
+ osVersion: this.getOSVersion(),
414
+ mobileVendor: this.getMobileVendor(),
415
+ mobileModel: this.getMobileModel(),
416
+ });
417
+ }
418
+
419
+ this.emit('connected');
420
+ resolve(true);
421
+ });
422
+
423
+ this.connection.on('data', this.handleData.bind(this));
424
+
425
+ this.connection.on('close', () => {
426
+ this.status = ConnectionStatus.Closed;
427
+ this.emit('disconnected');
428
+ });
429
+
430
+ this.connection.on('error', (error) => {
431
+ this.errorMessage = `Connection error: ${error.message}`;
432
+ this.status = ConnectionStatus.Error;
433
+ this.emit('error', this.errorMessage);
434
+ });
435
+ });
436
+ } catch (error) {
437
+ this.errorMessage = `Failed to connect: ${error instanceof Error ? error.message : String(error)}`;
438
+ this.emit('error', this.errorMessage);
439
+ return false;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Handle incoming data from uploader
445
+ */
446
+ private handleData(data: unknown): void {
447
+ try {
448
+ // WebRTC messages follow a specific format with a type field
449
+ const message = data as any;
450
+
451
+ switch (message.type) {
452
+ case MessageType.PasswordRequired:
453
+ this.handlePasswordRequired(message);
454
+ break;
455
+
456
+ case MessageType.Info:
457
+ this.handleInfo(message);
458
+ break;
459
+
460
+ case MessageType.Chunk:
461
+ this.handleChunk(message);
462
+ break;
463
+
464
+ case MessageType.Error:
465
+ this.handleError(message);
466
+ break;
467
+
468
+ case MessageType.Report:
469
+ this.handleReport();
470
+ break;
471
+ }
472
+ } catch (error) {
473
+ console.error('Error handling message:', error);
474
+ this.errorMessage = `Error processing data: ${error instanceof Error ? error.message : String(error)}`;
475
+ this.emit('error', this.errorMessage);
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Handle password required message
481
+ */
482
+ private handlePasswordRequired(message: any): void {
483
+ this.isPasswordRequired = true;
484
+ this.status = ConnectionStatus.Authenticating;
485
+
486
+ if (message.errorMessage) {
487
+ this.errorMessage = message.errorMessage;
488
+ this.isPasswordInvalid = true;
489
+ this.emit('passwordInvalid', message.errorMessage);
490
+ } else {
491
+ this.emit('passwordRequired');
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Handle file info message
497
+ */
498
+ private handleInfo(message: any): void {
499
+ this.filesInfo = message.files;
500
+ this.totalBytes = this.filesInfo.reduce((sum: number, file: FileInfo) => sum + file.size, 0);
501
+ this.isPasswordRequired = false;
502
+ this.isPasswordInvalid = false;
503
+ this.status = ConnectionStatus.Ready;
504
+
505
+ this.emit('info', this.filesInfo);
506
+ }
507
+
508
+ /**
509
+ * Handle error message
510
+ */
511
+ private handleError(message: any): void {
512
+ this.errorMessage = message.error;
513
+ this.status = ConnectionStatus.Error;
514
+ this.emit('error', this.errorMessage);
515
+ }
516
+
517
+ /**
518
+ * Handle report message (channel reported for violation)
519
+ */
520
+ private handleReport(): void {
521
+ this.emit('reported');
522
+
523
+ // Redirect to reported page if in browser
524
+ if (typeof window !== 'undefined') {
525
+ window.location.href = `${this.filePizzaServerUrl}/reported`;
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Handle chunk message
531
+ */
532
+ private handleChunk(message: any): void {
533
+ const { fileName, bytes, final } = message;
534
+ const fileStream = this.fileStreams.get(fileName);
535
+
536
+ if (!fileStream) {
537
+ console.error(`No stream found for file: ${fileName}`);
538
+ return;
539
+ }
540
+
541
+ // Convert bytes to Uint8Array if needed
542
+ const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
543
+
544
+ // Update progress
545
+ this.currentFileBytesReceived += data.byteLength;
546
+ this.totalBytesReceived += data.byteLength;
547
+
548
+ // Add to file stream
549
+ fileStream.enqueue(data);
550
+
551
+ // Emit progress
552
+ this.emit('progress', this.getProgress());
553
+
554
+ // Handle file completion
555
+ if (final) {
556
+ // Close this file's stream
557
+ fileStream.close();
558
+
559
+ // Store the completed file
560
+ this.storeCompletedFile(fileName);
561
+
562
+ // Move to next file if available
563
+ this.currentFileIndex++;
564
+ this.currentFileBytesReceived = 0;
565
+
566
+ if (this.currentFileIndex < this.filesInfo.length) {
567
+ this.requestNextFile();
568
+ } else {
569
+ this.status = ConnectionStatus.Done;
570
+ this.emit('complete', this.completedFiles);
571
+ }
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Initialize streams for all files
577
+ */
578
+ private initializeFileStreams(): void {
579
+ this.fileStreams.clear();
580
+
581
+ for (const fileInfo of this.filesInfo) {
582
+ let enqueue: ((chunk: Uint8Array) => void) | null = null;
583
+ let close: (() => void) | null = null;
584
+
585
+ const stream = new ReadableStream<Uint8Array>({
586
+ start(controller) {
587
+ enqueue = (chunk: Uint8Array) => controller.enqueue(chunk);
588
+ close = () => controller.close();
589
+ },
590
+ });
591
+
592
+ if (!enqueue || !close) {
593
+ throw new Error('Failed to initialize stream controllers');
594
+ }
595
+
596
+ this.fileStreams.set(fileInfo.fileName, {
597
+ stream,
598
+ enqueue,
599
+ close,
600
+ });
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Store a completed file
606
+ */
607
+ private async storeCompletedFile(fileName: string): Promise<void> {
608
+ const fileStream = this.fileStreams.get(fileName);
609
+ const fileInfo = this.filesInfo.find(info => info.fileName === fileName);
610
+
611
+ if (!fileStream || !fileInfo) {
612
+ console.error(`No stream or file info found for file: ${fileName}`);
613
+ return;
614
+ }
615
+
616
+ try {
617
+ // Clone the stream since we're going to use it
618
+ const [streamToRead, streamToStore] = fileStream.stream.tee();
619
+
620
+ // Convert stream to Uint8Array
621
+ const fileData = await DownloadHelper.streamToUint8Array(streamToRead);
622
+
623
+ // Store the completed file
624
+ const completedFile: CompletedFile = {
625
+ ...fileInfo,
626
+ data: fileData,
627
+ };
628
+
629
+ this.completedFiles.push(completedFile);
630
+
631
+ // Emit fileComplete event
632
+ this.emit('fileComplete', completedFile);
633
+ } catch (error) {
634
+ console.error(`Error storing file ${fileName}:`, error);
635
+ this.emit('error', `Failed to store file: ${error.message}`);
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Download a completed file
641
+ */
642
+ public async downloadFile(fileName: string): Promise<void> {
643
+ const completedFile = this.completedFiles.find(file => file.fileName === fileName);
644
+
645
+ if (!completedFile) {
646
+ throw new Error(`File not found: ${fileName}`);
647
+ }
648
+
649
+ try {
650
+ await DownloadHelper.downloadFile(fileName, completedFile.data);
651
+ } catch (error) {
652
+ console.error(`Error downloading file ${fileName}:`, error);
653
+ throw new Error(`Failed to download file: ${error.message}`);
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Get completed files
659
+ */
660
+ public getCompletedFiles(): CompletedFile[] {
661
+ return [...this.completedFiles];
662
+ }
663
+
664
+ /**
665
+ * Download all completed files
666
+ */
667
+ public async downloadAllFiles(): Promise<void> {
668
+ for (const file of this.completedFiles) {
669
+ try {
670
+ await this.downloadFile(file.fileName);
671
+ } catch (error) {
672
+ console.error(`Error downloading file ${file.fileName}:`, error);
673
+ // Continue with other files even if one fails
674
+ }
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Request the next file
680
+ */
681
+ private requestNextFile(): void {
682
+ if (!this.connection || this.currentFileIndex >= this.filesInfo.length) {
683
+ return;
684
+ }
685
+
686
+ const nextFile = this.filesInfo[this.currentFileIndex];
687
+
688
+ this.connection.send({
689
+ type: MessageType.Start,
690
+ fileName: nextFile.fileName,
691
+ offset: 0,
692
+ });
693
+ }
694
+
695
+ // Browser detection helper methods
696
+ private getBrowserName(): string {
697
+ if (typeof navigator === 'undefined') return 'unknown';
698
+ const ua = navigator.userAgent;
699
+ if (ua.includes('Firefox')) return 'Firefox';
700
+ if (ua.includes('Chrome')) return 'Chrome';
701
+ if (ua.includes('Safari')) return 'Safari';
702
+ if (ua.includes('Edge')) return 'Edge';
703
+ if (ua.includes('MSIE') || ua.includes('Trident/')) return 'IE';
704
+ return 'unknown';
705
+ }
706
+
707
+ private getBrowserVersion(): string {
708
+ if (typeof navigator === 'undefined') return 'unknown';
709
+ const ua = navigator.userAgent;
710
+
711
+ let match;
712
+ if ((match = ua.match(/(Firefox|Chrome|Safari|Edge|MSIE)\/(\d+\.\d+)/))) {
713
+ return match[2];
714
+ }
715
+ if ((match = ua.match(/rv:(\d+\.\d+)/))) {
716
+ return match[1];
717
+ }
718
+
719
+ return 'unknown';
720
+ }
721
+
722
+ private getOSName(): string {
723
+ if (typeof navigator === 'undefined') return 'unknown';
724
+ const ua = navigator.userAgent;
725
+
726
+ if (ua.includes('Windows')) return 'Windows';
727
+ if (ua.includes('Mac OS X')) return 'macOS';
728
+ if (ua.includes('Linux')) return 'Linux';
729
+ if (ua.includes('Android')) return 'Android';
730
+ if (ua.includes('iOS')) return 'iOS';
731
+
732
+ return 'unknown';
733
+ }
734
+
735
+ private getOSVersion(): string {
736
+ if (typeof navigator === 'undefined') return 'unknown';
737
+ const ua = navigator.userAgent;
738
+
739
+ let match;
740
+ if ((match = ua.match(/Windows NT (\d+\.\d+)/))) {
741
+ return match[1];
742
+ }
743
+ if ((match = ua.match(/Mac OS X (\d+[._]\d+)/))) {
744
+ return match[1].replace('_', '.');
745
+ }
746
+ if ((match = ua.match(/Android (\d+\.\d+)/))) {
747
+ return match[1];
748
+ }
749
+ if ((match = ua.match(/iPhone OS (\d+_\d+)/))) {
750
+ return match[1].replace('_', '.');
751
+ }
752
+
753
+ return 'unknown';
754
+ }
755
+
756
+ private getMobileVendor(): string {
757
+ if (typeof navigator === 'undefined') return '';
758
+
759
+ const ua = navigator.userAgent;
760
+ if (ua.includes('iPhone') || ua.includes('iPad')) return 'Apple';
761
+ if (ua.includes('Samsung')) return 'Samsung';
762
+ if (ua.includes('Pixel')) return 'Google';
763
+ if (ua.includes('Huawei')) return 'Huawei';
764
+
765
+ return '';
766
+ }
767
+
768
+ private getMobileModel(): string {
769
+ if (typeof navigator === 'undefined') return '';
770
+
771
+ const ua = navigator.userAgent;
772
+ let match;
773
+
774
+ if ((match = ua.match(/iPhone(\d+),(\d+)/))) {
775
+ return `iPhone ${match[1]}`;
776
+ }
777
+ if ((match = ua.match(/iPad(\d+),(\d+)/))) {
778
+ return `iPad ${match[1]}`;
779
+ }
780
+ if ((match = ua.match(/SM-\w+/))) {
781
+ return match[0];
782
+ }
783
+ if ((match = ua.match(/Pixel \d+/))) {
784
+ return match[0];
785
+ }
786
+
787
+ return '';
788
+ }
789
+ }