neoagent 3.0.1-beta.8 → 3.1.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.
- package/.env.example +2 -10
- package/README.md +12 -3
- package/docs/automation.md +37 -0
- package/docs/benchmarking.md +102 -0
- package/docs/billing.md +34 -8
- package/docs/configuration.md +11 -2
- package/docs/getting-started.md +10 -8
- package/docs/operations.md +1 -1
- package/flutter_app/lib/main.dart +3 -0
- package/flutter_app/lib/main_account_settings.dart +138 -0
- package/flutter_app/lib/main_app_shell.dart +38 -13
- package/flutter_app/lib/main_billing.dart +1465 -0
- package/flutter_app/lib/main_chat.dart +1612 -214
- package/flutter_app/lib/main_controller.dart +398 -26
- package/flutter_app/lib/main_devices.dart +293 -207
- package/flutter_app/lib/main_models.dart +142 -0
- package/flutter_app/lib/main_navigation.dart +19 -1
- package/flutter_app/lib/main_operations.dart +288 -9
- package/flutter_app/lib/main_settings.dart +510 -300
- package/flutter_app/lib/main_shared.dart +2 -0
- package/flutter_app/lib/main_timeline.dart +1378 -0
- package/flutter_app/lib/src/backend_client.dart +149 -19
- package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
- package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
- package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
- package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
- package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
- package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
- package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
- package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
- package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
- package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
- package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
- package/flutter_app/lib/src/recording_payloads.dart +9 -0
- package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
- package/flutter_app/windows/runner/flutter_window.cpp +75 -0
- package/landing/index.html +3 -3
- package/lib/manager.js +184 -66
- package/package.json +4 -1
- package/server/admin/access.js +12 -7
- package/server/admin/admin.css +78 -0
- package/server/admin/admin.js +436 -10
- package/server/admin/billing.js +165 -42
- package/server/admin/index.html +80 -5
- package/server/admin/users.js +15 -15
- package/server/db/database.js +125 -20
- package/server/http/routes.js +1 -0
- package/server/http/static.js +4 -2
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/canvaskit/wimp.js.symbols +8475 -8467
- package/server/public/canvaskit/wimp.wasm +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +91077 -87037
- package/server/routes/account.js +53 -0
- package/server/routes/admin.js +345 -64
- package/server/routes/agents.js +203 -21
- package/server/routes/billing.js +5 -0
- package/server/routes/browser.js +8 -1
- package/server/routes/recordings.js +96 -6
- package/server/routes/screenHistory.js +140 -2
- package/server/routes/timeline.js +43 -0
- package/server/services/account/erasure.js +263 -0
- package/server/services/ai/hooks.js +4 -1
- package/server/services/ai/loop/agent_engine_core.js +8 -1
- package/server/services/ai/loop/blank_recovery.js +36 -0
- package/server/services/ai/loop/conversation_loop.js +166 -33
- package/server/services/ai/messagingFallback.js +22 -0
- package/server/services/ai/rate_limits.js +28 -5
- package/server/services/ai/systemPrompt.js +6 -5
- package/server/services/ai/taskAnalysis.js +2 -0
- package/server/services/ai/toolEvidence.js +15 -0
- package/server/services/ai/toolResult.js +40 -0
- package/server/services/ai/tools.js +163 -4
- package/server/services/android/controller.js +6 -2
- package/server/services/billing/plans.js +2 -1
- package/server/services/browser/anti_detection.js +192 -0
- package/server/services/browser/controller.js +180 -54
- package/server/services/desktop/auth.js +3 -0
- package/server/services/desktop/registry.js +50 -2
- package/server/services/integrations/google/calendar.js +22 -14
- package/server/services/manager.js +12 -42
- package/server/services/memory/ingestion_chunking.js +268 -0
- package/server/services/messaging/telnyx.js +9 -8
- package/server/services/recordings/manager.js +60 -27
- package/server/services/runtime/backends/local-vm.js +40 -22
- package/server/services/runtime/docker-vm-manager.js +157 -266
- package/server/services/runtime/guest_bootstrap.js +17 -5
- package/server/services/runtime/guest_image.js +188 -0
- package/server/services/runtime/manager.js +0 -1
- package/server/services/runtime/validation.js +3 -8
- package/server/services/social_video/service.js +60 -10
- package/server/services/tasks/runtime.js +234 -9
- package/server/services/tasks/schedule_utils.js +5 -5
- package/server/services/tasks/task_repository.js +13 -0
- package/server/services/timeline/service.js +558 -0
- package/server/services/wearable/gateway.js +1 -1
- package/server/services/websocket.js +21 -3
- package/server/services/desktop/screenRecorder.js +0 -292
- package/server/services/desktop/screen_recorder_support.js +0 -46
|
@@ -5,9 +5,12 @@ import 'package:desktop_audio_capture/audio_capture.dart';
|
|
|
5
5
|
import 'package:flutter/foundation.dart';
|
|
6
6
|
import 'package:flutter/services.dart';
|
|
7
7
|
import 'package:http/http.dart' as http;
|
|
8
|
+
import 'package:path_provider/path_provider.dart';
|
|
8
9
|
|
|
9
10
|
import 'diagnostics_logger.dart';
|
|
10
11
|
import 'recording_bridge.dart';
|
|
12
|
+
import 'recording_chunk_queue.dart';
|
|
13
|
+
import 'recording_chunk_queue_io.dart';
|
|
11
14
|
|
|
12
15
|
RecordingBridge createPlatformRecordingBridge() {
|
|
13
16
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
|
@@ -349,15 +352,16 @@ class DesktopRecordingBridge extends RecordingBridge {
|
|
|
349
352
|
'microphone': 0,
|
|
350
353
|
'system': 0,
|
|
351
354
|
};
|
|
352
|
-
final Map<String, Future<void>> _uploadQueueBySource = <String, Future<void>>{
|
|
353
|
-
'microphone': Future<void>.value(),
|
|
354
|
-
'system': Future<void>.value(),
|
|
355
|
-
};
|
|
356
355
|
final Map<String, int> _bytesPerSecondBySource = <String, int>{
|
|
357
356
|
'microphone': _sampleRate * _channels * _bytesPerSample,
|
|
358
357
|
'system': _sampleRate * _channels * _bytesPerSample,
|
|
359
358
|
};
|
|
360
359
|
|
|
360
|
+
// Chunks are persisted to disk before upload and retried until confirmed, so a
|
|
361
|
+
// transient network failure during a meeting never loses audio.
|
|
362
|
+
DiskChunkUploadQueue? _uploadQueue;
|
|
363
|
+
String? _systemPermissionErrorMessage;
|
|
364
|
+
|
|
361
365
|
StreamSubscription<Uint8List>? _micAudioSub;
|
|
362
366
|
StreamSubscription<Uint8List>? _systemAudioSub;
|
|
363
367
|
StreamSubscription<MicAudioStatus>? _micStatusSub;
|
|
@@ -411,6 +415,7 @@ class DesktopRecordingBridge extends RecordingBridge {
|
|
|
411
415
|
|
|
412
416
|
try {
|
|
413
417
|
await _ensureDesktopPermissions();
|
|
418
|
+
await _initUploadQueue(sessionId);
|
|
414
419
|
await _startStreams();
|
|
415
420
|
_status = _status.copyWith(
|
|
416
421
|
active: true,
|
|
@@ -443,7 +448,7 @@ class DesktopRecordingBridge extends RecordingBridge {
|
|
|
443
448
|
return;
|
|
444
449
|
}
|
|
445
450
|
await _stopStreams(flushPending: true);
|
|
446
|
-
await
|
|
451
|
+
await _uploadQueue?.flush();
|
|
447
452
|
_status = _status.copyWith(
|
|
448
453
|
paused: true,
|
|
449
454
|
activeSources: const <String>[],
|
|
@@ -528,7 +533,7 @@ class DesktopRecordingBridge extends RecordingBridge {
|
|
|
528
533
|
}
|
|
529
534
|
final sessionId = _sessionId;
|
|
530
535
|
await _stopStreams(flushPending: true);
|
|
531
|
-
await
|
|
536
|
+
await _uploadQueue?.flush();
|
|
532
537
|
_status = _status.copyWith(
|
|
533
538
|
active: false,
|
|
534
539
|
paused: false,
|
|
@@ -614,9 +619,22 @@ class DesktopRecordingBridge extends RecordingBridge {
|
|
|
614
619
|
await _systemCapture.stopCapture();
|
|
615
620
|
} catch (_) {}
|
|
616
621
|
|
|
622
|
+
await _uploadQueue?.dispose();
|
|
617
623
|
_httpClient.close();
|
|
618
624
|
}
|
|
619
625
|
|
|
626
|
+
Future<void> _initUploadQueue(String sessionId) async {
|
|
627
|
+
await _uploadQueue?.dispose();
|
|
628
|
+
final support = await getApplicationSupportDirectory();
|
|
629
|
+
final dir = Directory('${support.path}/recording-pending/$sessionId');
|
|
630
|
+
_uploadQueue = DiskChunkUploadQueue(
|
|
631
|
+
directory: dir,
|
|
632
|
+
uploader: _performChunkUpload,
|
|
633
|
+
logger: (event, {Map<String, Object?> data = const <String, Object?>{}, Object? error}) =>
|
|
634
|
+
_log(event, data: data, error: error),
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
620
638
|
Future<void> _ensureDesktopPermissions() async {
|
|
621
639
|
final micGranted = await _requestMicPermission();
|
|
622
640
|
final systemGranted = await _requestSystemPermission();
|
|
@@ -650,12 +668,25 @@ class DesktopRecordingBridge extends RecordingBridge {
|
|
|
650
668
|
Future<bool> _requestSystemPermission() async {
|
|
651
669
|
try {
|
|
652
670
|
final granted = await _systemCapture.requestPermissions();
|
|
671
|
+
_systemPermissionErrorMessage = null;
|
|
653
672
|
_status = _status.copyWith(
|
|
654
673
|
systemAudioPermission: granted
|
|
655
674
|
? RecordingPermissionState.granted
|
|
656
675
|
: RecordingPermissionState.denied,
|
|
657
676
|
);
|
|
658
677
|
return granted;
|
|
678
|
+
} on MissingPluginException catch (error, stackTrace) {
|
|
679
|
+
// The system-audio plugin is only registered where capture is supported
|
|
680
|
+
// (macOS 13+ via ScreenCaptureKit). Surface that instead of a misleading
|
|
681
|
+
// "grant permission" prompt the user can never satisfy.
|
|
682
|
+
_systemPermissionErrorMessage =
|
|
683
|
+
'System audio capture is unavailable on this OS version. On macOS it '
|
|
684
|
+
'requires macOS 13 (Ventura) or later.';
|
|
685
|
+
_log('system_permission.unavailable', error: error, stackTrace: stackTrace);
|
|
686
|
+
_status = _status.copyWith(
|
|
687
|
+
systemAudioPermission: RecordingPermissionState.unknown,
|
|
688
|
+
);
|
|
689
|
+
return false;
|
|
659
690
|
} catch (error) {
|
|
660
691
|
_status = _status.copyWith(
|
|
661
692
|
systemAudioPermission: _permissionStateFromError(error),
|
|
@@ -746,32 +777,27 @@ class DesktopRecordingBridge extends RecordingBridge {
|
|
|
746
777
|
_nextSequenceBySource[sourceKey] = sequence + 1;
|
|
747
778
|
_lastEndMsBySource[sourceKey] = endMs;
|
|
748
779
|
|
|
749
|
-
final
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
780
|
+
final queue = _uploadQueue;
|
|
781
|
+
if (queue == null) {
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
// Persist + enqueue. The queue uploads in order and retries until the chunk
|
|
785
|
+
// is confirmed stored, so a dropped upload no longer loses that audio.
|
|
786
|
+
unawaited(
|
|
787
|
+
queue.enqueue(
|
|
788
|
+
PendingChunk(
|
|
789
|
+
sourceKey: sourceKey,
|
|
790
|
+
sequence: sequence,
|
|
791
|
+
startMs: startMs,
|
|
792
|
+
endMs: endMs,
|
|
793
|
+
mimeType: 'audio/wav',
|
|
794
|
+
bytes: _wrapPcmAsWav(pcmBytes),
|
|
795
|
+
),
|
|
796
|
+
),
|
|
797
|
+
);
|
|
766
798
|
}
|
|
767
799
|
|
|
768
|
-
Future<void>
|
|
769
|
-
required String sourceKey,
|
|
770
|
-
required int sequence,
|
|
771
|
-
required int startMs,
|
|
772
|
-
required int endMs,
|
|
773
|
-
required Uint8List bytes,
|
|
774
|
-
}) async {
|
|
800
|
+
Future<void> _performChunkUpload(PendingChunk chunk) async {
|
|
775
801
|
final sessionId = _sessionId;
|
|
776
802
|
final baseUrl = _baseUrl;
|
|
777
803
|
if (sessionId == null || baseUrl == null || baseUrl.trim().isEmpty) {
|
|
@@ -783,49 +809,31 @@ class DesktopRecordingBridge extends RecordingBridge {
|
|
|
783
809
|
final uri = Uri.parse(
|
|
784
810
|
'${baseUrl.replaceFirst(RegExp(r'/$'), '')}/api/recordings/$sessionId/chunks',
|
|
785
811
|
);
|
|
812
|
+
final headers = <String, String>{
|
|
813
|
+
'Content-Type': chunk.mimeType,
|
|
814
|
+
'X-Recording-Source-Key': chunk.sourceKey,
|
|
815
|
+
'X-Recording-Sequence': '${chunk.sequence}',
|
|
816
|
+
'X-Recording-Start-Ms': '${chunk.startMs}',
|
|
817
|
+
'X-Recording-End-Ms': '${chunk.endMs}',
|
|
818
|
+
if ((_sessionCookie ?? '').trim().isNotEmpty)
|
|
819
|
+
'Cookie': _sessionCookie!.trim(),
|
|
820
|
+
};
|
|
786
821
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
'
|
|
794
|
-
'X-Recording-End-Ms': '$endMs',
|
|
795
|
-
if ((_sessionCookie ?? '').trim().isNotEmpty)
|
|
796
|
-
'Cookie': _sessionCookie!.trim(),
|
|
797
|
-
};
|
|
798
|
-
final response = await _httpClient
|
|
799
|
-
.post(uri, headers: headers, body: bytes)
|
|
800
|
-
.timeout(const Duration(seconds: 20));
|
|
801
|
-
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
802
|
-
throw RecordingBridgeException(
|
|
803
|
-
'Chunk upload failed with status ${response.statusCode}.',
|
|
804
|
-
);
|
|
805
|
-
}
|
|
806
|
-
return;
|
|
807
|
-
} on TimeoutException catch (error, stackTrace) {
|
|
808
|
-
_log(
|
|
809
|
-
'upload.timeout',
|
|
810
|
-
data: <String, Object?>{
|
|
811
|
-
'sourceKey': sourceKey,
|
|
812
|
-
'sequence': sequence,
|
|
813
|
-
'attempt': attempt + 1,
|
|
814
|
-
},
|
|
815
|
-
error: error,
|
|
816
|
-
stackTrace: stackTrace,
|
|
822
|
+
try {
|
|
823
|
+
final response = await _httpClient
|
|
824
|
+
.post(uri, headers: headers, body: chunk.bytes)
|
|
825
|
+
.timeout(const Duration(seconds: 20));
|
|
826
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
827
|
+
throw RecordingBridgeException(
|
|
828
|
+
'Chunk upload failed with status ${response.statusCode}.',
|
|
817
829
|
);
|
|
818
|
-
_httpClient.close();
|
|
819
|
-
_httpClient = http.Client();
|
|
820
|
-
if (attempt == 2) {
|
|
821
|
-
throw const RecordingBridgeException('Chunk upload timed out.');
|
|
822
|
-
}
|
|
823
|
-
} catch (error) {
|
|
824
|
-
if (attempt == 2) {
|
|
825
|
-
rethrow;
|
|
826
|
-
}
|
|
827
|
-
await Future<void>.delayed(Duration(milliseconds: 400 * (attempt + 1)));
|
|
828
830
|
}
|
|
831
|
+
} on TimeoutException {
|
|
832
|
+
// A stuck socket can wedge subsequent requests; rebuild the client so the
|
|
833
|
+
// queue's retry gets a fresh connection.
|
|
834
|
+
_httpClient.close();
|
|
835
|
+
_httpClient = http.Client();
|
|
836
|
+
rethrow;
|
|
829
837
|
}
|
|
830
838
|
}
|
|
831
839
|
|
|
@@ -873,7 +881,6 @@ class DesktopRecordingBridge extends RecordingBridge {
|
|
|
873
881
|
_pcmBuffers[sourceKey] = <int>[];
|
|
874
882
|
_nextSequenceBySource[sourceKey] = 0;
|
|
875
883
|
_lastEndMsBySource[sourceKey] = 0;
|
|
876
|
-
_uploadQueueBySource[sourceKey] = Future<void>.value();
|
|
877
884
|
}
|
|
878
885
|
}
|
|
879
886
|
|
|
@@ -930,7 +937,8 @@ class DesktopRecordingBridge extends RecordingBridge {
|
|
|
930
937
|
if (!micGranted) {
|
|
931
938
|
return 'Grant microphone permission before starting desktop recording.';
|
|
932
939
|
}
|
|
933
|
-
return
|
|
940
|
+
return _systemPermissionErrorMessage ??
|
|
941
|
+
'Grant system audio permission before starting desktop recording.';
|
|
934
942
|
}
|
|
935
943
|
|
|
936
944
|
Future<void> _openPlatformSettings({
|
|
@@ -7,6 +7,7 @@ import 'dart:typed_data';
|
|
|
7
7
|
|
|
8
8
|
import 'diagnostics_logger.dart';
|
|
9
9
|
import 'recording_bridge.dart';
|
|
10
|
+
import 'recording_chunk_queue.dart';
|
|
10
11
|
|
|
11
12
|
RecordingBridge createPlatformRecordingBridge() => WebRecordingBridge();
|
|
12
13
|
|
|
@@ -18,13 +19,18 @@ class WebRecordingBridge extends RecordingBridge {
|
|
|
18
19
|
);
|
|
19
20
|
|
|
20
21
|
html.MediaStream? _displayStream;
|
|
22
|
+
html.MediaStream? _systemAudioStream;
|
|
21
23
|
html.MediaStream? _microphoneStream;
|
|
22
24
|
html.MediaRecorder? _screenRecorder;
|
|
25
|
+
html.MediaRecorder? _systemAudioRecorder;
|
|
23
26
|
html.MediaRecorder? _microphoneRecorder;
|
|
24
27
|
final Map<String, int> _nextSequenceBySource = <String, int>{};
|
|
25
28
|
final Map<String, int> _lastEndMsBySource = <String, int>{};
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
// One durable, retry-until-success queue per source so a failed upload is
|
|
30
|
+
// re-sent instead of silently dropped, and a stall in one source does not
|
|
31
|
+
// block the others.
|
|
32
|
+
final Map<String, MemoryChunkUploadQueue> _uploadQueues =
|
|
33
|
+
<String, MemoryChunkUploadQueue>{};
|
|
28
34
|
Stopwatch? _stopwatch;
|
|
29
35
|
String? _baseUrl;
|
|
30
36
|
String? _sessionId;
|
|
@@ -119,22 +125,31 @@ class WebRecordingBridge extends RecordingBridge {
|
|
|
119
125
|
);
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
// Split the display capture: video-only feeds the screen-analysis source,
|
|
129
|
+
// while the shared system/tab audio gets its own transcribable source so
|
|
130
|
+
// remote participants are captured in the transcript (the mic alone does
|
|
131
|
+
// not reliably pick up other speakers, e.g. when wearing headphones).
|
|
132
|
+
final screenVideoStream = html.MediaStream();
|
|
133
|
+
for (final track in displayStream.getVideoTracks()) {
|
|
134
|
+
screenVideoStream.addTrack(track);
|
|
135
|
+
}
|
|
136
|
+
final systemAudioStream = html.MediaStream();
|
|
137
|
+
for (final track in displayStream.getAudioTracks()) {
|
|
138
|
+
systemAudioStream.addTrack(track);
|
|
139
|
+
}
|
|
140
|
+
|
|
122
141
|
_baseUrl = baseUrl;
|
|
123
142
|
_sessionId = sessionId;
|
|
124
143
|
_displayStream = displayStream;
|
|
144
|
+
_systemAudioStream = systemAudioStream;
|
|
125
145
|
_microphoneStream = microphoneStream;
|
|
126
146
|
_nextSequenceBySource
|
|
127
147
|
..clear()
|
|
128
|
-
..addAll(<String, int>{'screen': 0, 'microphone': 0});
|
|
148
|
+
..addAll(<String, int>{'screen': 0, 'system': 0, 'microphone': 0});
|
|
129
149
|
_lastEndMsBySource
|
|
130
150
|
..clear()
|
|
131
|
-
..addAll(<String, int>{'screen': 0, 'microphone': 0});
|
|
132
|
-
|
|
133
|
-
..clear()
|
|
134
|
-
..addAll(<String, Future<void>>{
|
|
135
|
-
'screen': Future<void>.value(),
|
|
136
|
-
'microphone': Future<void>.value(),
|
|
137
|
-
});
|
|
151
|
+
..addAll(<String, int>{'screen': 0, 'system': 0, 'microphone': 0});
|
|
152
|
+
_initUploadQueues(<String>['screen', 'system', 'microphone']);
|
|
138
153
|
_stopwatch = Stopwatch()..start();
|
|
139
154
|
|
|
140
155
|
final screenMimeType = _pickMimeType(<String>[
|
|
@@ -142,13 +157,13 @@ class WebRecordingBridge extends RecordingBridge {
|
|
|
142
157
|
'video/webm;codecs=vp8,opus',
|
|
143
158
|
'video/webm',
|
|
144
159
|
]);
|
|
145
|
-
final
|
|
160
|
+
final audioMimeType = _pickMimeType(<String>[
|
|
146
161
|
'audio/webm;codecs=opus',
|
|
147
162
|
'audio/webm',
|
|
148
163
|
]);
|
|
149
164
|
|
|
150
165
|
_screenRecorder = html.MediaRecorder(
|
|
151
|
-
|
|
166
|
+
screenVideoStream,
|
|
152
167
|
screenMimeType == null
|
|
153
168
|
? null
|
|
154
169
|
: <String, String>{'mimeType': screenMimeType},
|
|
@@ -165,17 +180,27 @@ class WebRecordingBridge extends RecordingBridge {
|
|
|
165
180
|
sourceKey: 'screen',
|
|
166
181
|
mimeType: screenMimeType ?? 'video/webm',
|
|
167
182
|
);
|
|
183
|
+
_systemAudioRecorder = html.MediaRecorder(
|
|
184
|
+
systemAudioStream,
|
|
185
|
+
audioMimeType == null ? null : <String, String>{'mimeType': audioMimeType},
|
|
186
|
+
);
|
|
187
|
+
_bindRecorder(
|
|
188
|
+
recorder: _systemAudioRecorder!,
|
|
189
|
+
sourceKey: 'system',
|
|
190
|
+
mimeType: audioMimeType ?? 'audio/webm',
|
|
191
|
+
);
|
|
168
192
|
_microphoneRecorder = html.MediaRecorder(
|
|
169
193
|
microphoneStream,
|
|
170
|
-
|
|
194
|
+
audioMimeType == null ? null : <String, String>{'mimeType': audioMimeType},
|
|
171
195
|
);
|
|
172
196
|
_bindRecorder(
|
|
173
197
|
recorder: _microphoneRecorder!,
|
|
174
198
|
sourceKey: 'microphone',
|
|
175
|
-
mimeType:
|
|
199
|
+
mimeType: audioMimeType ?? 'audio/webm',
|
|
176
200
|
);
|
|
177
201
|
|
|
178
202
|
_screenRecorder!.start(4000);
|
|
203
|
+
_systemAudioRecorder!.start(4000);
|
|
179
204
|
_microphoneRecorder!.start(4000);
|
|
180
205
|
_status = _status.copyWith(
|
|
181
206
|
active: true,
|
|
@@ -189,7 +214,8 @@ class WebRecordingBridge extends RecordingBridge {
|
|
|
189
214
|
data: <String, Object?>{
|
|
190
215
|
'sessionId': sessionId,
|
|
191
216
|
'screenMimeType': screenMimeType ?? 'video/webm',
|
|
192
|
-
'
|
|
217
|
+
'systemMimeType': audioMimeType ?? 'audio/webm',
|
|
218
|
+
'micMimeType': audioMimeType ?? 'audio/webm',
|
|
193
219
|
},
|
|
194
220
|
);
|
|
195
221
|
notifyListeners();
|
|
@@ -261,11 +287,7 @@ class WebRecordingBridge extends RecordingBridge {
|
|
|
261
287
|
_lastEndMsBySource
|
|
262
288
|
..clear()
|
|
263
289
|
..addAll(<String, int>{'microphone': 0});
|
|
264
|
-
|
|
265
|
-
..clear()
|
|
266
|
-
..addAll(<String, Future<void>>{
|
|
267
|
-
'microphone': Future<void>.value(),
|
|
268
|
-
});
|
|
290
|
+
_initUploadQueues(<String>['microphone']);
|
|
269
291
|
_stopwatch = Stopwatch()..start();
|
|
270
292
|
|
|
271
293
|
final micMimeType = _pickMimeType(<String>[
|
|
@@ -404,7 +426,9 @@ class WebRecordingBridge extends RecordingBridge {
|
|
|
404
426
|
);
|
|
405
427
|
try {
|
|
406
428
|
await _stopRecorders();
|
|
407
|
-
await Future.wait(
|
|
429
|
+
await Future.wait(
|
|
430
|
+
_uploadQueues.values.map((queue) => queue.flush()),
|
|
431
|
+
);
|
|
408
432
|
await _disposeStreams();
|
|
409
433
|
_status = _status.copyWith(
|
|
410
434
|
active: false,
|
|
@@ -432,6 +456,30 @@ class WebRecordingBridge extends RecordingBridge {
|
|
|
432
456
|
}
|
|
433
457
|
}
|
|
434
458
|
|
|
459
|
+
void _initUploadQueues(List<String> sourceKeys) {
|
|
460
|
+
for (final queue in _uploadQueues.values) {
|
|
461
|
+
unawaited(queue.dispose());
|
|
462
|
+
}
|
|
463
|
+
_uploadQueues
|
|
464
|
+
..clear()
|
|
465
|
+
..addEntries(
|
|
466
|
+
sourceKeys.map(
|
|
467
|
+
(sourceKey) => MapEntry<String, MemoryChunkUploadQueue>(
|
|
468
|
+
sourceKey,
|
|
469
|
+
MemoryChunkUploadQueue(
|
|
470
|
+
uploader: _performChunkUpload,
|
|
471
|
+
logger:
|
|
472
|
+
(
|
|
473
|
+
event, {
|
|
474
|
+
Map<String, Object?> data = const <String, Object?>{},
|
|
475
|
+
Object? error,
|
|
476
|
+
}) => _log(event, data: data, error: error),
|
|
477
|
+
),
|
|
478
|
+
),
|
|
479
|
+
),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
435
483
|
void _bindRecorder({
|
|
436
484
|
required html.MediaRecorder recorder,
|
|
437
485
|
required String sourceKey,
|
|
@@ -447,49 +495,42 @@ class WebRecordingBridge extends RecordingBridge {
|
|
|
447
495
|
_lastEndMsBySource[sourceKey] = endMs;
|
|
448
496
|
final sequence = _nextSequenceBySource[sourceKey] ?? 0;
|
|
449
497
|
_nextSequenceBySource[sourceKey] = sequence + 1;
|
|
450
|
-
final
|
|
451
|
-
|
|
452
|
-
|
|
498
|
+
final queue = _uploadQueues[sourceKey];
|
|
499
|
+
if (queue == null) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
// Read the blob bytes, then hand the chunk to the durable queue, which
|
|
503
|
+
// uploads and retries until the server confirms it (so a transient
|
|
504
|
+
// failure no longer drops the chunk or stalls the rest of the source).
|
|
505
|
+
unawaited(() async {
|
|
506
|
+
try {
|
|
507
|
+
final bytes = await _blobToBytes(blob);
|
|
508
|
+
queue.enqueue(
|
|
509
|
+
PendingChunk(
|
|
453
510
|
sourceKey: sourceKey,
|
|
454
511
|
sequence: sequence,
|
|
455
512
|
startMs: startMs,
|
|
456
513
|
endMs: endMs,
|
|
457
|
-
blob: blob,
|
|
458
514
|
mimeType: mimeType,
|
|
515
|
+
bytes: bytes,
|
|
459
516
|
),
|
|
460
517
|
);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
'sequence': sequence,
|
|
474
|
-
'stackTrace': stackText,
|
|
475
|
-
},
|
|
476
|
-
error: error,
|
|
477
|
-
stackTrace: stackTrace,
|
|
478
|
-
);
|
|
479
|
-
notifyListeners();
|
|
480
|
-
Error.throwWithStackTrace(error, stackTrace);
|
|
481
|
-
});
|
|
518
|
+
} catch (error, stackTrace) {
|
|
519
|
+
_log(
|
|
520
|
+
'chunk.read.failed',
|
|
521
|
+
data: <String, Object?>{
|
|
522
|
+
'sourceKey': sourceKey,
|
|
523
|
+
'sequence': sequence,
|
|
524
|
+
},
|
|
525
|
+
error: error,
|
|
526
|
+
stackTrace: stackTrace,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}());
|
|
482
530
|
});
|
|
483
531
|
}
|
|
484
532
|
|
|
485
|
-
Future<void>
|
|
486
|
-
required String sourceKey,
|
|
487
|
-
required int sequence,
|
|
488
|
-
required int startMs,
|
|
489
|
-
required int endMs,
|
|
490
|
-
required html.Blob blob,
|
|
491
|
-
required String mimeType,
|
|
492
|
-
}) async {
|
|
533
|
+
Future<void> _performChunkUpload(PendingChunk chunk) async {
|
|
493
534
|
final sessionId = _sessionId;
|
|
494
535
|
final baseUrl = _baseUrl;
|
|
495
536
|
if (sessionId == null || baseUrl == null) {
|
|
@@ -497,38 +538,38 @@ class WebRecordingBridge extends RecordingBridge {
|
|
|
497
538
|
'Recording session is not initialized.',
|
|
498
539
|
);
|
|
499
540
|
}
|
|
500
|
-
final bytes = await _blobToBytes(blob);
|
|
501
541
|
final uri = _resolveUri(baseUrl, '/api/recordings/$sessionId/chunks');
|
|
542
|
+
final headers = <String, String>{
|
|
543
|
+
'Content-Type': chunk.mimeType,
|
|
544
|
+
'X-Recording-Source-Key': chunk.sourceKey,
|
|
545
|
+
'X-Recording-Sequence': '${chunk.sequence}',
|
|
546
|
+
'X-Recording-Start-Ms': '${chunk.startMs}',
|
|
547
|
+
'X-Recording-End-Ms': '${chunk.endMs}',
|
|
548
|
+
};
|
|
502
549
|
_log(
|
|
503
|
-
'chunk.upload.
|
|
550
|
+
'chunk.upload.attempt',
|
|
504
551
|
data: <String, Object?>{
|
|
505
552
|
'sessionId': sessionId,
|
|
506
|
-
'sourceKey': sourceKey,
|
|
507
|
-
'sequence': sequence,
|
|
508
|
-
'
|
|
509
|
-
'
|
|
510
|
-
'size': bytes.length,
|
|
511
|
-
'mimeType': mimeType,
|
|
553
|
+
'sourceKey': chunk.sourceKey,
|
|
554
|
+
'sequence': chunk.sequence,
|
|
555
|
+
'size': chunk.bytes.length,
|
|
556
|
+
'headers': _filterHeadersForLog(headers),
|
|
512
557
|
},
|
|
513
558
|
);
|
|
514
|
-
await
|
|
559
|
+
await html.HttpRequest.request(
|
|
515
560
|
uri.toString(),
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
'X-Recording-Start-Ms': '$startMs',
|
|
521
|
-
'X-Recording-End-Ms': '$endMs',
|
|
522
|
-
},
|
|
523
|
-
body: bytes,
|
|
561
|
+
method: 'POST',
|
|
562
|
+
sendData: chunk.bytes,
|
|
563
|
+
requestHeaders: headers,
|
|
564
|
+
withCredentials: true,
|
|
524
565
|
);
|
|
525
566
|
_log(
|
|
526
567
|
'chunk.upload.done',
|
|
527
568
|
data: <String, Object?>{
|
|
528
569
|
'sessionId': sessionId,
|
|
529
|
-
'sourceKey': sourceKey,
|
|
530
|
-
'sequence': sequence,
|
|
531
|
-
'size': bytes.length,
|
|
570
|
+
'sourceKey': chunk.sourceKey,
|
|
571
|
+
'sequence': chunk.sequence,
|
|
572
|
+
'size': chunk.bytes.length,
|
|
532
573
|
},
|
|
533
574
|
);
|
|
534
575
|
}
|
|
@@ -551,6 +592,11 @@ class WebRecordingBridge extends RecordingBridge {
|
|
|
551
592
|
futures.add(_waitForStop(_screenRecorder!));
|
|
552
593
|
_screenRecorder!.stop();
|
|
553
594
|
}
|
|
595
|
+
if (_systemAudioRecorder != null &&
|
|
596
|
+
_systemAudioRecorder!.state != 'inactive') {
|
|
597
|
+
futures.add(_waitForStop(_systemAudioRecorder!));
|
|
598
|
+
_systemAudioRecorder!.stop();
|
|
599
|
+
}
|
|
554
600
|
if (_microphoneRecorder != null &&
|
|
555
601
|
_microphoneRecorder!.state != 'inactive') {
|
|
556
602
|
futures.add(_waitForStop(_microphoneRecorder!));
|
|
@@ -563,55 +609,22 @@ class WebRecordingBridge extends RecordingBridge {
|
|
|
563
609
|
await _displayEndedSub?.cancel();
|
|
564
610
|
_displayEndedSub = null;
|
|
565
611
|
_displayStream?.getTracks().forEach((track) => track.stop());
|
|
612
|
+
_systemAudioStream?.getTracks().forEach((track) => track.stop());
|
|
566
613
|
_microphoneStream?.getTracks().forEach((track) => track.stop());
|
|
567
614
|
_displayStream = null;
|
|
615
|
+
_systemAudioStream = null;
|
|
568
616
|
_microphoneStream = null;
|
|
569
617
|
_screenRecorder = null;
|
|
618
|
+
_systemAudioRecorder = null;
|
|
570
619
|
_microphoneRecorder = null;
|
|
620
|
+
for (final queue in _uploadQueues.values) {
|
|
621
|
+
await queue.dispose();
|
|
622
|
+
}
|
|
623
|
+
_uploadQueues.clear();
|
|
571
624
|
_stopwatch?.stop();
|
|
572
625
|
_stopwatch = null;
|
|
573
626
|
}
|
|
574
627
|
|
|
575
|
-
Future<void> _requestWithRetry(
|
|
576
|
-
String url, {
|
|
577
|
-
required Map<String, String> headers,
|
|
578
|
-
required Uint8List body,
|
|
579
|
-
}) async {
|
|
580
|
-
Object? lastError;
|
|
581
|
-
for (var attempt = 0; attempt < 3; attempt += 1) {
|
|
582
|
-
try {
|
|
583
|
-
_log(
|
|
584
|
-
'chunk.upload.attempt',
|
|
585
|
-
data: <String, Object?>{
|
|
586
|
-
'url': url,
|
|
587
|
-
'attempt': attempt + 1,
|
|
588
|
-
'size': body.length,
|
|
589
|
-
'headers': _filterHeadersForLog(headers),
|
|
590
|
-
},
|
|
591
|
-
);
|
|
592
|
-
await html.HttpRequest.request(
|
|
593
|
-
url,
|
|
594
|
-
method: 'POST',
|
|
595
|
-
sendData: body,
|
|
596
|
-
requestHeaders: headers,
|
|
597
|
-
withCredentials: true,
|
|
598
|
-
);
|
|
599
|
-
return;
|
|
600
|
-
} catch (error) {
|
|
601
|
-
lastError = error;
|
|
602
|
-
_log(
|
|
603
|
-
'chunk.upload.attempt_failed',
|
|
604
|
-
data: <String, Object?>{'url': url, 'attempt': attempt + 1},
|
|
605
|
-
error: error,
|
|
606
|
-
);
|
|
607
|
-
await Future<void>.delayed(Duration(milliseconds: 400 * (attempt + 1)));
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
throw RecordingBridgeException(
|
|
611
|
-
'Could not upload a recording chunk: ${lastError ?? 'unknown error'}',
|
|
612
|
-
);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
628
|
Map<String, String> _filterHeadersForLog(Map<String, String> headers) {
|
|
616
629
|
final filtered = <String, String>{};
|
|
617
630
|
headers.forEach((key, value) {
|