shell-mirror 1.5.45 โ†’ 1.5.47

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.
@@ -55,17 +55,79 @@ logToFile(`๐Ÿš Shell: ${shell}`);
55
55
 
56
56
  // Circular buffer for session output persistence
57
57
  class CircularBuffer {
58
- constructor(size = 10000) {
58
+ constructor(size = 10000, maxTotalSize = 512 * 1024) { // 512KB max total size
59
59
  this.size = size;
60
+ this.maxTotalSize = maxTotalSize;
60
61
  this.buffer = [];
61
62
  this.index = 0;
62
63
  this.full = false;
64
+ this.totalSize = 0;
63
65
  }
64
66
 
65
67
  add(data) {
68
+ const dataSize = Buffer.byteLength(data, 'utf8');
69
+
70
+ // Add new data
71
+ const oldData = this.buffer[this.index];
72
+ if (oldData) {
73
+ this.totalSize -= Buffer.byteLength(oldData, 'utf8');
74
+ }
75
+
66
76
  this.buffer[this.index] = data;
77
+ this.totalSize += dataSize;
67
78
  this.index = (this.index + 1) % this.size;
68
79
  if (this.index === 0) this.full = true;
80
+
81
+ // If total size exceeds limit, remove older data
82
+ this.enforceMaxSize();
83
+ }
84
+
85
+ enforceMaxSize() {
86
+ if (this.totalSize <= this.maxTotalSize) return;
87
+
88
+ // Remove data from the oldest end until under limit
89
+ let removed = 0;
90
+ while (this.totalSize > this.maxTotalSize && this.getTotalItems() > 0) {
91
+ let oldestIndex;
92
+ if (this.full) {
93
+ oldestIndex = this.index; // Oldest item when buffer is full
94
+ } else {
95
+ oldestIndex = 0; // Start from beginning when not full
96
+ }
97
+
98
+ const oldestData = this.buffer[oldestIndex];
99
+ if (oldestData) {
100
+ this.totalSize -= Buffer.byteLength(oldestData, 'utf8');
101
+ this.buffer[oldestIndex] = '';
102
+ removed++;
103
+
104
+ if (this.full) {
105
+ this.index = (this.index + 1) % this.size;
106
+ if (this.getTotalItems() === 0) {
107
+ this.full = false;
108
+ this.index = 0;
109
+ }
110
+ } else {
111
+ // Shift array to remove empty spot
112
+ this.buffer.splice(oldestIndex, 1);
113
+ this.buffer.push('');
114
+ this.index = Math.max(0, this.index - 1);
115
+ }
116
+ } else {
117
+ break; // Prevent infinite loop
118
+ }
119
+ }
120
+
121
+ if (removed > 0) {
122
+ logToFile(`[BUFFER] Enforced max size limit: removed ${removed} old entries, total size now ${this.totalSize} bytes`);
123
+ }
124
+ }
125
+
126
+ getTotalItems() {
127
+ if (!this.full) {
128
+ return this.buffer.slice(0, this.index).filter(item => item).length;
129
+ }
130
+ return this.buffer.filter(item => item).length;
69
131
  }
70
132
 
71
133
  getAll() {
@@ -79,6 +141,16 @@ class CircularBuffer {
79
141
  this.buffer = [];
80
142
  this.index = 0;
81
143
  this.full = false;
144
+ this.totalSize = 0;
145
+ }
146
+
147
+ getStats() {
148
+ return {
149
+ items: this.getTotalItems(),
150
+ totalSize: this.totalSize,
151
+ maxSize: this.maxTotalSize,
152
+ utilizationPercent: Math.round((this.totalSize / this.maxTotalSize) * 100)
153
+ };
82
154
  }
83
155
  }
84
156
 
@@ -261,10 +333,9 @@ class SessionManager {
261
333
  // For now, we'll use a global dataChannel reference
262
334
  // In a full implementation, this would use a clientId-to-dataChannel mapping
263
335
  if (typeof dataChannel !== 'undefined' && dataChannel && dataChannel.readyState === 'open') {
264
- try {
265
- dataChannel.send(JSON.stringify(message));
266
- } catch (err) {
267
- logToFile(`[SESSION] Error sending to client ${clientId}: ${err.message}`);
336
+ const success = sendLargeMessage(dataChannel, message, '[SESSION]');
337
+ if (!success) {
338
+ logToFile(`[SESSION] โŒ Failed to send message to client ${clientId}`);
268
339
  }
269
340
  } else {
270
341
  logToFile(`[SESSION] โš ๏ธ Cannot send to client ${clientId} - data channel not available`);
@@ -423,6 +494,11 @@ function connectToSignalingServer() {
423
494
  // Force ICE gathering if it hasn't started within 2 seconds
424
495
  logToFile('[AGENT] ๐Ÿ”ง Setting up ICE gathering fallback timer...');
425
496
  setTimeout(() => {
497
+ if (!peerConnection) {
498
+ logToFile('[AGENT] โš ๏ธ ICE gathering timer fired but peerConnection is null (connection already closed)');
499
+ return;
500
+ }
501
+
426
502
  if (peerConnection.iceGatheringState === 'new') {
427
503
  logToFile('[AGENT] โš ๏ธ ICE gathering hasn\'t started - checking peer connection state');
428
504
  logToFile(`[AGENT] Current ICE gathering state: ${peerConnection.iceGatheringState}`);
@@ -628,6 +704,80 @@ function cleanup(clientId = null) {
628
704
  }
629
705
  }
630
706
 
707
+ // WebRTC data channel message size limits and chunking
708
+ const MAX_WEBRTC_MESSAGE_SIZE = 32 * 1024; // 32KB - conservative limit for compatibility
709
+ const CHUNK_TYPE_START = 'chunk_start';
710
+ const CHUNK_TYPE_DATA = 'chunk_data';
711
+ const CHUNK_TYPE_END = 'chunk_end';
712
+
713
+ function sendLargeMessage(dataChannel, message, logPrefix = '[AGENT]') {
714
+ try {
715
+ const messageStr = JSON.stringify(message);
716
+ const messageBytes = Buffer.byteLength(messageStr, 'utf8');
717
+
718
+ if (messageBytes <= MAX_WEBRTC_MESSAGE_SIZE) {
719
+ // Small message, send directly
720
+ dataChannel.send(messageStr);
721
+ logToFile(`${logPrefix} โœ… Sent small message (${messageBytes} bytes)`);
722
+ return true;
723
+ }
724
+
725
+ // Large message, chunk it
726
+ logToFile(`${logPrefix} ๐Ÿ“ฆ Chunking large message (${messageBytes} bytes) into ${MAX_WEBRTC_MESSAGE_SIZE} byte chunks`);
727
+
728
+ const chunkId = Date.now().toString() + Math.random().toString(36).substr(2, 9);
729
+ const chunks = [];
730
+
731
+ // Split message string into chunks
732
+ for (let i = 0; i < messageStr.length; i += MAX_WEBRTC_MESSAGE_SIZE - 200) { // Reserve 200 bytes for chunk metadata
733
+ chunks.push(messageStr.slice(i, i + MAX_WEBRTC_MESSAGE_SIZE - 200));
734
+ }
735
+
736
+ logToFile(`${logPrefix} ๐Ÿ“ฆ Split into ${chunks.length} chunks`);
737
+
738
+ // Send chunk start notification
739
+ dataChannel.send(JSON.stringify({
740
+ type: CHUNK_TYPE_START,
741
+ chunkId: chunkId,
742
+ totalChunks: chunks.length,
743
+ totalSize: messageBytes,
744
+ originalType: message.type
745
+ }));
746
+
747
+ // Send each chunk with a small delay to prevent overwhelming
748
+ chunks.forEach((chunk, index) => {
749
+ setTimeout(() => {
750
+ try {
751
+ dataChannel.send(JSON.stringify({
752
+ type: CHUNK_TYPE_DATA,
753
+ chunkId: chunkId,
754
+ chunkIndex: index,
755
+ data: chunk
756
+ }));
757
+
758
+ // Send end notification after last chunk
759
+ if (index === chunks.length - 1) {
760
+ setTimeout(() => {
761
+ dataChannel.send(JSON.stringify({
762
+ type: CHUNK_TYPE_END,
763
+ chunkId: chunkId
764
+ }));
765
+ logToFile(`${logPrefix} โœ… Large message sent successfully (${chunks.length} chunks)`);
766
+ }, 10);
767
+ }
768
+ } catch (err) {
769
+ logToFile(`${logPrefix} โŒ Error sending chunk ${index}: ${err.message}`);
770
+ }
771
+ }, index * 10); // 10ms delay between chunks
772
+ });
773
+
774
+ return true;
775
+ } catch (err) {
776
+ logToFile(`${logPrefix} โŒ Error in sendLargeMessage: ${err.message}`);
777
+ return false;
778
+ }
779
+ }
780
+
631
781
  function setupDataChannel(clientId) {
632
782
  dataChannel.onopen = () => {
633
783
  logToFile('[AGENT] โœ… Data channel is open!');
@@ -638,11 +788,9 @@ function setupDataChannel(clientId) {
638
788
  const bufferedOutput = session.buffer.getAll();
639
789
  if (bufferedOutput) {
640
790
  logToFile(`[AGENT] ๐Ÿ“ค Sending buffered output to client (${bufferedOutput.length} chars)`);
641
- try {
642
- dataChannel.send(JSON.stringify({ type: 'output', data: bufferedOutput }));
643
- logToFile('[AGENT] โœ… Buffered output sent successfully');
644
- } catch (err) {
645
- logToFile(`[AGENT] โŒ Error sending buffered output: ${err.message}`);
791
+ const success = sendLargeMessage(dataChannel, { type: 'output', data: bufferedOutput }, '[AGENT]');
792
+ if (!success) {
793
+ logToFile('[AGENT] โŒ Failed to send buffered output');
646
794
  }
647
795
  } else {
648
796
  logToFile('[AGENT] โ„น๏ธ No buffered output to send for this session');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shell-mirror",
3
- "version": "1.5.45",
3
+ "version": "1.5.47",
4
4
  "description": "Access your Mac shell from any device securely. Perfect for mobile coding with Claude Code CLI, Gemini CLI, and any shell tool.",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -4,6 +4,25 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Shell Mirror Dashboard</title>
7
+
8
+ <!-- Google Analytics 4 -->
9
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-LG7ZGLB8FK"></script>
10
+ <script>
11
+ window.dataLayer = window.dataLayer || [];
12
+ function gtag(){dataLayer.push(arguments);}
13
+ gtag('js', new Date());
14
+ gtag('config', 'G-LG7ZGLB8FK');
15
+ </script>
16
+
17
+ <!-- Microsoft Clarity -->
18
+ <script type="text/javascript">
19
+ (function(c,l,a,r,i,t,y){
20
+ c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
21
+ t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
22
+ y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
23
+ })(window, document, "clarity", "script", "CLARITY_PROJECT_ID");
24
+ </script>
25
+
7
26
  <link rel="stylesheet" href="dashboard.css">
8
27
  </head>
9
28
  <body>
@@ -429,21 +429,62 @@ class ShellMirrorDashboard {
429
429
  session.lastActivity > latest.lastActivity ? session : latest
430
430
  );
431
431
  console.log(`[DASHBOARD] โœ… Reconnecting to existing session: ${mostRecentSession.id}`);
432
+
433
+ // Track terminal connection in Google Analytics
434
+ if (typeof gtag !== 'undefined') {
435
+ gtag('event', 'terminal_connect', {
436
+ event_category: 'terminal',
437
+ event_label: 'existing_session',
438
+ agent_id: agentId,
439
+ session_id: mostRecentSession.id
440
+ });
441
+ }
442
+
432
443
  window.location.href = `/app/terminal.html?agent=${agentId}&session=${mostRecentSession.id}`;
433
444
  } else {
434
445
  // No existing sessions, create new one
435
446
  console.log(`[DASHBOARD] ๐Ÿ†• Creating new session for agent: ${agentId}`);
447
+
448
+ // Track new session creation in Google Analytics
449
+ if (typeof gtag !== 'undefined') {
450
+ gtag('event', 'terminal_connect', {
451
+ event_category: 'terminal',
452
+ event_label: 'new_session',
453
+ agent_id: agentId
454
+ });
455
+ }
456
+
436
457
  window.location.href = `/app/terminal.html?agent=${agentId}`;
437
458
  }
438
459
  }
439
460
 
440
461
  async connectToSession(agentId, sessionId) {
462
+ // Track specific session connection in Google Analytics
463
+ if (typeof gtag !== 'undefined') {
464
+ gtag('event', 'terminal_connect', {
465
+ event_category: 'terminal',
466
+ event_label: 'specific_session',
467
+ agent_id: agentId,
468
+ session_id: sessionId
469
+ });
470
+ }
471
+
441
472
  window.location.href = `/app/terminal.html?agent=${agentId}&session=${sessionId}`;
442
473
  }
443
474
 
444
475
  async createNewSession(agentId) {
445
476
  // Force creation of new session by not passing session parameter
446
477
  console.log(`[DASHBOARD] Creating new session for agent: ${agentId}`);
478
+
479
+ // Track explicit new session creation in Google Analytics
480
+ if (typeof gtag !== 'undefined') {
481
+ gtag('event', 'terminal_connect', {
482
+ event_category: 'terminal',
483
+ event_label: 'force_new_session',
484
+ agent_id: agentId
485
+ });
486
+ }
487
+
447
488
  window.location.href = `/app/terminal.html?agent=${agentId}`;
448
489
  }
449
490
 
@@ -2,6 +2,25 @@
2
2
  <html>
3
3
  <head>
4
4
  <title>Terminal Mirror</title>
5
+
6
+ <!-- Google Analytics 4 -->
7
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-LG7ZGLB8FK"></script>
8
+ <script>
9
+ window.dataLayer = window.dataLayer || [];
10
+ function gtag(){dataLayer.push(arguments);}
11
+ gtag('js', new Date());
12
+ gtag('config', 'G-LG7ZGLB8FK');
13
+ </script>
14
+
15
+ <!-- Microsoft Clarity -->
16
+ <script type="text/javascript">
17
+ (function(c,l,a,r,i,t,y){
18
+ c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
19
+ t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
20
+ y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
21
+ })(window, document, "clarity", "script", "CLARITY_PROJECT_ID");
22
+ </script>
23
+
5
24
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.15.0/css/xterm.css" />
6
25
  <script src="https://cdn.jsdelivr.net/npm/xterm@4.15.0/lib/xterm.js"></script>
7
26
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
@@ -222,6 +241,6 @@
222
241
  </div>
223
242
  </footer>
224
243
 
225
- <script src="/app/terminal.js?v=debug-20250819-v2"></script>
244
+ <script src="/app/terminal.js?v=chunked-20250820"></script>
226
245
  </body>
227
246
  </html>
@@ -55,6 +55,102 @@ let currentSession = null;
55
55
  let availableSessions = [];
56
56
  let requestedSessionId = null; // For connecting to specific session from URL
57
57
 
58
+ // Chunk reassembly for large messages
59
+ const chunkAssembler = {
60
+ activeChunks: new Map(),
61
+
62
+ handleChunkedMessage(message) {
63
+ const { type, chunkId } = message;
64
+
65
+ switch (type) {
66
+ case 'chunk_start':
67
+ console.log(`[CLIENT] ๐Ÿ“ฆ Starting chunk reassembly: ${chunkId} (${message.totalChunks} chunks, ${message.totalSize} bytes)`);
68
+ this.activeChunks.set(chunkId, {
69
+ originalType: message.originalType,
70
+ totalChunks: message.totalChunks,
71
+ totalSize: message.totalSize,
72
+ receivedChunks: new Map(),
73
+ startTime: Date.now()
74
+ });
75
+ return true;
76
+
77
+ case 'chunk_data':
78
+ const chunkInfo = this.activeChunks.get(chunkId);
79
+ if (!chunkInfo) {
80
+ console.error(`[CLIENT] โŒ Received chunk data for unknown chunk ID: ${chunkId}`);
81
+ return true;
82
+ }
83
+
84
+ chunkInfo.receivedChunks.set(message.chunkIndex, message.data);
85
+ console.log(`[CLIENT] ๐Ÿ“ฆ Received chunk ${message.chunkIndex + 1}/${chunkInfo.totalChunks}`);
86
+ return true;
87
+
88
+ case 'chunk_end':
89
+ return this.reassembleChunks(chunkId);
90
+
91
+ default:
92
+ return false; // Not a chunk message
93
+ }
94
+ },
95
+
96
+ reassembleChunks(chunkId) {
97
+ const chunkInfo = this.activeChunks.get(chunkId);
98
+ if (!chunkInfo) {
99
+ console.error(`[CLIENT] โŒ Cannot reassemble unknown chunk: ${chunkId}`);
100
+ return true;
101
+ }
102
+
103
+ try {
104
+ // Check if we have all chunks
105
+ if (chunkInfo.receivedChunks.size !== chunkInfo.totalChunks) {
106
+ console.error(`[CLIENT] โŒ Missing chunks: expected ${chunkInfo.totalChunks}, got ${chunkInfo.receivedChunks.size}`);
107
+ return true;
108
+ }
109
+
110
+ // Reassemble chunks in order
111
+ let reassembledData = '';
112
+ for (let i = 0; i < chunkInfo.totalChunks; i++) {
113
+ if (!chunkInfo.receivedChunks.has(i)) {
114
+ console.error(`[CLIENT] โŒ Missing chunk ${i}`);
115
+ return true;
116
+ }
117
+ reassembledData += chunkInfo.receivedChunks.get(i);
118
+ }
119
+
120
+ const elapsed = Date.now() - chunkInfo.startTime;
121
+ console.log(`[CLIENT] โœ… Reassembled ${chunkInfo.totalChunks} chunks in ${elapsed}ms (${reassembledData.length} chars)`);
122
+
123
+ // Parse and process the reassembled message
124
+ const originalMessage = JSON.parse(reassembledData);
125
+ this.activeChunks.delete(chunkId);
126
+
127
+ // Process the original message
128
+ if (originalMessage.type === 'output') {
129
+ term.write(originalMessage.data);
130
+ } else {
131
+ handleSessionMessage(originalMessage);
132
+ }
133
+
134
+ return true;
135
+ } catch (err) {
136
+ console.error(`[CLIENT] โŒ Error reassembling chunks for ${chunkId}:`, err);
137
+ this.activeChunks.delete(chunkId);
138
+ return true;
139
+ }
140
+ },
141
+
142
+ cleanup() {
143
+ // Clean up old incomplete chunks (older than 30 seconds)
144
+ const now = Date.now();
145
+ for (const [chunkId, chunkInfo] of this.activeChunks.entries()) {
146
+ if (now - chunkInfo.startTime > 30000) {
147
+ console.log(`[CLIENT] ๐Ÿงน Cleaning up stale chunk: ${chunkId}`);
148
+ this.activeChunks.delete(chunkId);
149
+ }
150
+ }
151
+ }
152
+ };
153
+
58
154
  // Connection status management
59
155
  function updateConnectionStatus(status) {
60
156
  const statusElement = document.getElementById('connection-status');
@@ -75,6 +171,11 @@ function updateConnectionStatus(status) {
75
171
  }
76
172
  }
77
173
 
174
+ // Cleanup timer for chunk assembler
175
+ setInterval(() => {
176
+ chunkAssembler.cleanup();
177
+ }, 30000); // Clean up every 30 seconds
178
+
78
179
  // Check for agent parameter and connect directly
79
180
  window.addEventListener('load', () => {
80
181
  loadVersionInfo();
@@ -132,6 +233,17 @@ function startConnection() {
132
233
  connectContainer.style.display = 'none';
133
234
  terminalContainer.classList.add('show');
134
235
  term.open(document.getElementById('terminal'));
236
+
237
+ // Track terminal session start in Google Analytics
238
+ if (typeof gtag !== 'undefined') {
239
+ gtag('event', 'terminal_session_start', {
240
+ event_category: 'terminal',
241
+ event_label: requestedSessionId ? 'existing_session' : 'new_session',
242
+ agent_id: AGENT_ID,
243
+ session_id: requestedSessionId || 'new'
244
+ });
245
+ }
246
+
135
247
  // Delay fit to ensure proper dimensions after CSS transitions
136
248
  setTimeout(() => {
137
249
  fitAddon.fit();
@@ -419,6 +531,16 @@ async function createPeerConnection() {
419
531
  case 'connected':
420
532
  console.log('[CLIENT] โœ… WebRTC connection established!');
421
533
  updateConnectionStatus('connected');
534
+
535
+ // Track successful connection in Google Analytics
536
+ if (typeof gtag !== 'undefined') {
537
+ gtag('event', 'terminal_connection_success', {
538
+ event_category: 'terminal',
539
+ event_label: 'webrtc_established',
540
+ agent_id: AGENT_ID
541
+ });
542
+ }
543
+
422
544
  break;
423
545
  case 'completed':
424
546
  console.log('[CLIENT] โœ… ICE connection completed successfully!');
@@ -508,6 +630,14 @@ function setupDataChannel() {
508
630
  dataChannel.onmessage = (event) => {
509
631
  try {
510
632
  const message = JSON.parse(event.data);
633
+
634
+ // Check if this is a chunked message
635
+ if (chunkAssembler.handleChunkedMessage(message)) {
636
+ // Message was handled by chunk assembler
637
+ return;
638
+ }
639
+
640
+ // Handle normal messages
511
641
  if (message.type === 'output') {
512
642
  term.write(message.data);
513
643
  } else {
package/public/index.html CHANGED
@@ -18,6 +18,24 @@
18
18
  <meta property="twitter:title" content="Shell Mirror - Use Claude Code CLI from your phone">
19
19
  <meta property="twitter:description" content="Access your Mac terminal from your phone to use Claude Code CLI, Gemini CLI, and other command-line tools.">
20
20
 
21
+ <!-- Google Analytics 4 -->
22
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-LG7ZGLB8FK"></script>
23
+ <script>
24
+ window.dataLayer = window.dataLayer || [];
25
+ function gtag(){dataLayer.push(arguments);}
26
+ gtag('js', new Date());
27
+ gtag('config', 'G-LG7ZGLB8FK');
28
+ </script>
29
+
30
+ <!-- Microsoft Clarity -->
31
+ <script type="text/javascript">
32
+ (function(c,l,a,r,i,t,y){
33
+ c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
34
+ t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
35
+ y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
36
+ })(window, document, "clarity", "script", "CLARITY_PROJECT_ID");
37
+ </script>
38
+
21
39
  <style>
22
40
  * {
23
41
  margin: 0;
@@ -679,12 +697,28 @@
679
697
 
680
698
  // Handle Google login - direct web OAuth
681
699
  async function handleGoogleLogin() {
700
+ // Track login attempt in Google Analytics
701
+ if (typeof gtag !== 'undefined') {
702
+ gtag('event', 'login_attempt', {
703
+ event_category: 'authentication',
704
+ event_label: 'google_oauth'
705
+ });
706
+ }
707
+
682
708
  // Direct OAuth flow using the web backend
683
709
  window.location.href = '/php-backend/api/auth-login.php?return=' + encodeURIComponent('/app/dashboard');
684
710
  }
685
711
 
686
712
  // Handle dashboard navigation
687
713
  async function openDashboard() {
714
+ // Track dashboard access in Google Analytics
715
+ if (typeof gtag !== 'undefined') {
716
+ gtag('event', 'dashboard_access', {
717
+ event_category: 'navigation',
718
+ event_label: 'from_landing_page'
719
+ });
720
+ }
721
+
688
722
  window.location.href = '/app/dashboard.html';
689
723
  }
690
724