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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import 'dart:async';
|
|
2
|
+
import 'dart:typed_data';
|
|
3
|
+
|
|
4
|
+
/// Metadata plus bytes for one recording chunk awaiting upload.
|
|
5
|
+
class PendingChunk {
|
|
6
|
+
const PendingChunk({
|
|
7
|
+
required this.sourceKey,
|
|
8
|
+
required this.sequence,
|
|
9
|
+
required this.startMs,
|
|
10
|
+
required this.endMs,
|
|
11
|
+
required this.mimeType,
|
|
12
|
+
required this.bytes,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
final String sourceKey;
|
|
16
|
+
final int sequence;
|
|
17
|
+
final int startMs;
|
|
18
|
+
final int endMs;
|
|
19
|
+
final String mimeType;
|
|
20
|
+
final Uint8List bytes;
|
|
21
|
+
|
|
22
|
+
Map<String, Object?> toMeta() => <String, Object?>{
|
|
23
|
+
'sourceKey': sourceKey,
|
|
24
|
+
'sequence': sequence,
|
|
25
|
+
'startMs': startMs,
|
|
26
|
+
'endMs': endMs,
|
|
27
|
+
'mimeType': mimeType,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
static PendingChunk fromMeta(Map<String, Object?> meta, Uint8List bytes) {
|
|
31
|
+
return PendingChunk(
|
|
32
|
+
sourceKey: '${meta['sourceKey']}',
|
|
33
|
+
sequence: (meta['sequence'] as num?)?.toInt() ?? 0,
|
|
34
|
+
startMs: (meta['startMs'] as num?)?.toInt() ?? 0,
|
|
35
|
+
endMs: (meta['endMs'] as num?)?.toInt() ?? 0,
|
|
36
|
+
mimeType: meta['mimeType'] as String? ?? 'application/octet-stream',
|
|
37
|
+
bytes: bytes,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Uploads a single chunk. Must throw on any failure so the queue retries.
|
|
43
|
+
typedef ChunkUploader = Future<void> Function(PendingChunk chunk);
|
|
44
|
+
|
|
45
|
+
typedef QueueLogger =
|
|
46
|
+
void Function(String event, {Map<String, Object?> data, Object? error});
|
|
47
|
+
|
|
48
|
+
/// In-memory, ordered, retry-until-success upload queue.
|
|
49
|
+
///
|
|
50
|
+
/// Chunks upload one at a time in enqueue order. A chunk that fails is kept and
|
|
51
|
+
/// retried (with backoff) until it succeeds or [dispose] is called, so a
|
|
52
|
+
/// transient network failure never silently drops audio while new chunks keep
|
|
53
|
+
/// buffering. Used on web, where no filesystem is available; the queue lives
|
|
54
|
+
/// only for the lifetime of the tab.
|
|
55
|
+
class MemoryChunkUploadQueue {
|
|
56
|
+
MemoryChunkUploadQueue({
|
|
57
|
+
required ChunkUploader uploader,
|
|
58
|
+
this.maxAttemptsPerPass = 3,
|
|
59
|
+
this.retryBackoff = const Duration(seconds: 2),
|
|
60
|
+
QueueLogger? logger,
|
|
61
|
+
}) : _uploader = uploader,
|
|
62
|
+
_logger = logger;
|
|
63
|
+
|
|
64
|
+
final ChunkUploader _uploader;
|
|
65
|
+
final int maxAttemptsPerPass;
|
|
66
|
+
final Duration retryBackoff;
|
|
67
|
+
final QueueLogger? _logger;
|
|
68
|
+
|
|
69
|
+
final List<PendingChunk> _pending = <PendingChunk>[];
|
|
70
|
+
bool _draining = false;
|
|
71
|
+
bool _disposed = false;
|
|
72
|
+
Timer? _retryTimer;
|
|
73
|
+
|
|
74
|
+
int get pendingCount => _pending.length;
|
|
75
|
+
|
|
76
|
+
void enqueue(PendingChunk chunk) {
|
|
77
|
+
if (_disposed) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
_pending.add(chunk);
|
|
81
|
+
unawaited(drain());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
Future<void> drain() async {
|
|
85
|
+
if (_draining || _disposed) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
_draining = true;
|
|
89
|
+
try {
|
|
90
|
+
while (_pending.isNotEmpty && !_disposed) {
|
|
91
|
+
final chunk = _pending.first;
|
|
92
|
+
final uploaded = await _attemptUpload(chunk);
|
|
93
|
+
if (!uploaded) {
|
|
94
|
+
_scheduleRetry();
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
if (_pending.isNotEmpty && identical(_pending.first, chunk)) {
|
|
98
|
+
_pending.removeAt(0);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} finally {
|
|
102
|
+
_draining = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
Future<bool> _attemptUpload(PendingChunk chunk) async {
|
|
107
|
+
for (var attempt = 0; attempt < maxAttemptsPerPass && !_disposed; attempt += 1) {
|
|
108
|
+
try {
|
|
109
|
+
await _uploader(chunk);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
_logger?.call(
|
|
113
|
+
'chunk.upload.failed',
|
|
114
|
+
data: <String, Object?>{
|
|
115
|
+
'sourceKey': chunk.sourceKey,
|
|
116
|
+
'sequence': chunk.sequence,
|
|
117
|
+
'attempt': attempt + 1,
|
|
118
|
+
},
|
|
119
|
+
error: error,
|
|
120
|
+
);
|
|
121
|
+
await Future<void>.delayed(retryBackoff * (attempt + 1));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
void _scheduleRetry() {
|
|
128
|
+
_retryTimer?.cancel();
|
|
129
|
+
_retryTimer = Timer(retryBackoff, () => unawaited(drain()));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Wait until every queued chunk has uploaded, or [timeout] elapses.
|
|
133
|
+
Future<void> flush({Duration timeout = const Duration(seconds: 30)}) async {
|
|
134
|
+
final deadline = DateTime.now().add(timeout);
|
|
135
|
+
while (_pending.isNotEmpty && !_disposed && DateTime.now().isBefore(deadline)) {
|
|
136
|
+
await drain();
|
|
137
|
+
if (_pending.isNotEmpty) {
|
|
138
|
+
await Future<void>.delayed(const Duration(milliseconds: 200));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
Future<void> dispose() async {
|
|
144
|
+
_disposed = true;
|
|
145
|
+
_retryTimer?.cancel();
|
|
146
|
+
_retryTimer = null;
|
|
147
|
+
_pending.clear();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import 'dart:async';
|
|
2
|
+
import 'dart:convert';
|
|
3
|
+
import 'dart:io';
|
|
4
|
+
|
|
5
|
+
import 'recording_chunk_queue.dart';
|
|
6
|
+
|
|
7
|
+
/// Disk-backed, ordered, retry-until-success upload queue for desktop.
|
|
8
|
+
///
|
|
9
|
+
/// Each chunk is persisted atomically before upload, so a network outage or
|
|
10
|
+
/// backend hiccup mid-meeting never loses audio: the chunk stays on disk and is
|
|
11
|
+
/// re-sent on the next drain. Files are deleted only after a confirmed upload.
|
|
12
|
+
class DiskChunkUploadQueue {
|
|
13
|
+
DiskChunkUploadQueue({
|
|
14
|
+
required Directory directory,
|
|
15
|
+
required ChunkUploader uploader,
|
|
16
|
+
this.maxAttemptsPerPass = 3,
|
|
17
|
+
this.retryBackoff = const Duration(seconds: 2),
|
|
18
|
+
QueueLogger? logger,
|
|
19
|
+
}) : _dir = directory,
|
|
20
|
+
_uploader = uploader,
|
|
21
|
+
_logger = logger;
|
|
22
|
+
|
|
23
|
+
final Directory _dir;
|
|
24
|
+
final ChunkUploader _uploader;
|
|
25
|
+
final int maxAttemptsPerPass;
|
|
26
|
+
final Duration retryBackoff;
|
|
27
|
+
final QueueLogger? _logger;
|
|
28
|
+
|
|
29
|
+
bool _draining = false;
|
|
30
|
+
bool _disposed = false;
|
|
31
|
+
Timer? _retryTimer;
|
|
32
|
+
final Set<Future<void>> _pendingEnqueues = <Future<void>>{};
|
|
33
|
+
|
|
34
|
+
Future<void> enqueue(PendingChunk chunk) async {
|
|
35
|
+
if (_disposed) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
final write = _persistChunk(chunk);
|
|
39
|
+
_pendingEnqueues.add(write);
|
|
40
|
+
try {
|
|
41
|
+
await write;
|
|
42
|
+
} finally {
|
|
43
|
+
_pendingEnqueues.remove(write);
|
|
44
|
+
}
|
|
45
|
+
unawaited(drain());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Future<void> drain() async {
|
|
49
|
+
if (_draining || _disposed) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
_draining = true;
|
|
53
|
+
try {
|
|
54
|
+
while (!_disposed) {
|
|
55
|
+
final metaFiles = await _listMetaFiles();
|
|
56
|
+
if (metaFiles.isEmpty) {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
final metaFile = metaFiles.first;
|
|
60
|
+
final audioFile = File(
|
|
61
|
+
metaFile.path.replaceFirst(RegExp(r'\.json$'), '.bin'),
|
|
62
|
+
);
|
|
63
|
+
if (!await audioFile.exists()) {
|
|
64
|
+
await _deleteQuietly(metaFile);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
final PendingChunk chunk;
|
|
69
|
+
try {
|
|
70
|
+
final meta =
|
|
71
|
+
jsonDecode(await metaFile.readAsString()) as Map<String, dynamic>;
|
|
72
|
+
chunk = PendingChunk.fromMeta(meta, await audioFile.readAsBytes());
|
|
73
|
+
} catch (error) {
|
|
74
|
+
// Corrupt metadata: drop the pair rather than blocking the queue.
|
|
75
|
+
_logger?.call('chunk.read.failed', error: error);
|
|
76
|
+
await _deleteQuietly(metaFile);
|
|
77
|
+
await _deleteQuietly(audioFile);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
final uploaded = await _attemptUpload(chunk);
|
|
82
|
+
if (!uploaded) {
|
|
83
|
+
_scheduleRetry();
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
await _deleteQuietly(audioFile);
|
|
87
|
+
await _deleteQuietly(metaFile);
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
_draining = false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
Future<bool> _attemptUpload(PendingChunk chunk) async {
|
|
95
|
+
for (var attempt = 0; attempt < maxAttemptsPerPass && !_disposed; attempt += 1) {
|
|
96
|
+
try {
|
|
97
|
+
await _uploader(chunk);
|
|
98
|
+
return true;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
_logger?.call(
|
|
101
|
+
'chunk.upload.failed',
|
|
102
|
+
data: <String, Object?>{
|
|
103
|
+
'sourceKey': chunk.sourceKey,
|
|
104
|
+
'sequence': chunk.sequence,
|
|
105
|
+
'attempt': attempt + 1,
|
|
106
|
+
},
|
|
107
|
+
error: error,
|
|
108
|
+
);
|
|
109
|
+
await Future<void>.delayed(retryBackoff * (attempt + 1));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
Future<List<File>> _listMetaFiles() async {
|
|
116
|
+
if (!await _dir.exists()) {
|
|
117
|
+
return const <File>[];
|
|
118
|
+
}
|
|
119
|
+
final entries = await _dir.list().toList();
|
|
120
|
+
final metaFiles = entries
|
|
121
|
+
.whereType<File>()
|
|
122
|
+
.where((file) => file.path.endsWith('.json'))
|
|
123
|
+
.toList()
|
|
124
|
+
..sort((a, b) => a.path.compareTo(b.path));
|
|
125
|
+
return metaFiles;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
Future<int> pendingCount() async => (await _listMetaFiles()).length;
|
|
129
|
+
|
|
130
|
+
Future<void> _persistChunk(PendingChunk chunk) async {
|
|
131
|
+
await _dir.create(recursive: true);
|
|
132
|
+
final base = '${chunk.sourceKey}-${chunk.sequence.toString().padLeft(6, '0')}';
|
|
133
|
+
final audioFile = File('${_dir.path}/$base.bin');
|
|
134
|
+
final metaFile = File('${_dir.path}/$base.json');
|
|
135
|
+
final audioTmp = File('${audioFile.path}.tmp');
|
|
136
|
+
final metaTmp = File('${metaFile.path}.tmp');
|
|
137
|
+
await audioTmp.writeAsBytes(chunk.bytes, flush: true);
|
|
138
|
+
await metaTmp.writeAsString(jsonEncode(chunk.toMeta()), flush: true);
|
|
139
|
+
await audioTmp.rename(audioFile.path);
|
|
140
|
+
await metaTmp.rename(metaFile.path);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
Future<void> _waitForPendingEnqueues() async {
|
|
144
|
+
while (_pendingEnqueues.isNotEmpty && !_disposed) {
|
|
145
|
+
await Future.wait<void>(List<Future<void>>.from(_pendingEnqueues));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
void _scheduleRetry() {
|
|
150
|
+
_retryTimer?.cancel();
|
|
151
|
+
_retryTimer = Timer(retryBackoff, () => unawaited(drain()));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
Future<void> _deleteQuietly(File file) async {
|
|
155
|
+
try {
|
|
156
|
+
if (await file.exists()) {
|
|
157
|
+
await file.delete();
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
_logger?.call('chunk.delete.failed', error: error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// Wait until every persisted chunk has uploaded, or [timeout] elapses.
|
|
165
|
+
Future<void> flush({Duration timeout = const Duration(seconds: 30)}) async {
|
|
166
|
+
final deadline = DateTime.now().add(timeout);
|
|
167
|
+
while (!_disposed && DateTime.now().isBefore(deadline)) {
|
|
168
|
+
await _waitForPendingEnqueues();
|
|
169
|
+
await drain();
|
|
170
|
+
if (_pendingEnqueues.isEmpty && await pendingCount() == 0) {
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
await Future<void>.delayed(const Duration(milliseconds: 200));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
Future<void> dispose() async {
|
|
178
|
+
_disposed = true;
|
|
179
|
+
_retryTimer?.cancel();
|
|
180
|
+
_retryTimer = null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -13,6 +13,15 @@ Map<String, dynamic> buildWebScreenAndMicRecordingPayload() {
|
|
|
13
13
|
'transcribe': false,
|
|
14
14
|
},
|
|
15
15
|
},
|
|
16
|
+
<String, dynamic>{
|
|
17
|
+
'sourceKey': 'system',
|
|
18
|
+
'sourceKind': 'system-audio',
|
|
19
|
+
'mediaKind': 'audio',
|
|
20
|
+
'mimeType': 'audio/webm',
|
|
21
|
+
'metadata': <String, dynamic>{
|
|
22
|
+
'transcribe': true,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
16
25
|
<String, dynamic>{
|
|
17
26
|
'sourceKey': 'microphone',
|
|
18
27
|
'sourceKind': 'microphone',
|
|
@@ -16,6 +16,7 @@ class AppDelegate: FlutterAppDelegate {
|
|
|
16
16
|
|
|
17
17
|
final class DesktopCompanionNativePlugin: NSObject {
|
|
18
18
|
private static let channelName = "neoagent/desktop_companion_native"
|
|
19
|
+
private static let idleThresholdSeconds: Double = 300
|
|
19
20
|
private static var channelAssociationKey: UInt8 = 0
|
|
20
21
|
private static var instanceAssociationKey: UInt8 = 0
|
|
21
22
|
|
|
@@ -268,6 +269,7 @@ final class DesktopCompanionNativePlugin: NSObject {
|
|
|
268
269
|
|
|
269
270
|
private func buildDesktopStatus() -> [String: Any] {
|
|
270
271
|
let accessibilityTrusted = isAccessibilityTrusted()
|
|
272
|
+
let idleSeconds = userIdleSeconds()
|
|
271
273
|
var status: [String: Any] = [
|
|
272
274
|
"permissions": [
|
|
273
275
|
"screenCapture": preflightScreenCapturePermission() ? "available" : "required",
|
|
@@ -276,6 +278,9 @@ final class DesktopCompanionNativePlugin: NSObject {
|
|
|
276
278
|
],
|
|
277
279
|
"displays": desktopDisplays(),
|
|
278
280
|
"activeDisplayId": defaultDisplayIdentifier(),
|
|
281
|
+
"sessionLocked": isSessionLocked(),
|
|
282
|
+
"idleSeconds": idleSeconds,
|
|
283
|
+
"userIdle": idleSeconds >= Self.idleThresholdSeconds,
|
|
279
284
|
]
|
|
280
285
|
|
|
281
286
|
if let appName = NSWorkspace.shared.frontmostApplication?.localizedName {
|
|
@@ -424,6 +429,26 @@ final class DesktopCompanionNativePlugin: NSObject {
|
|
|
424
429
|
return title
|
|
425
430
|
}
|
|
426
431
|
|
|
432
|
+
private func isSessionLocked() -> Bool {
|
|
433
|
+
guard let session = CGSessionCopyCurrentDictionary() as? [String: Any] else {
|
|
434
|
+
return false
|
|
435
|
+
}
|
|
436
|
+
if let locked = session["CGSSessionScreenIsLocked"] as? Bool {
|
|
437
|
+
return locked
|
|
438
|
+
}
|
|
439
|
+
if let locked = session["CGSSessionScreenIsLocked"] as? NSNumber {
|
|
440
|
+
return locked.boolValue
|
|
441
|
+
}
|
|
442
|
+
return false
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private func userIdleSeconds() -> Double {
|
|
446
|
+
CGEventSource.secondsSinceLastEventType(
|
|
447
|
+
.combinedSessionState,
|
|
448
|
+
eventType: .null
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
427
452
|
private func performClick(x: Double, y: Double, button: String, displayId: String?) {
|
|
428
453
|
let point = nativePointForCapturedPixel(x: x, y: y, displayId: displayId)
|
|
429
454
|
let mouseButton = cgMouseButton(button)
|
|
@@ -241,6 +241,70 @@ std::string ForegroundWindowTitle() {
|
|
|
241
241
|
return WideToUtf8(std::wstring(buffer, buffer + length));
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
std::string BasenameFromPath(const std::wstring& path) {
|
|
245
|
+
if (path.empty()) {
|
|
246
|
+
return std::string();
|
|
247
|
+
}
|
|
248
|
+
const size_t slash = path.find_last_of(L"\\/");
|
|
249
|
+
const std::wstring name =
|
|
250
|
+
slash == std::wstring::npos ? path : path.substr(slash + 1);
|
|
251
|
+
return WideToUtf8(name);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
std::string ForegroundWindowApp() {
|
|
255
|
+
HWND window = GetForegroundWindow();
|
|
256
|
+
if (window == nullptr) {
|
|
257
|
+
return std::string();
|
|
258
|
+
}
|
|
259
|
+
DWORD process_id = 0;
|
|
260
|
+
GetWindowThreadProcessId(window, &process_id);
|
|
261
|
+
if (process_id == 0) {
|
|
262
|
+
return std::string();
|
|
263
|
+
}
|
|
264
|
+
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, process_id);
|
|
265
|
+
if (process == nullptr) {
|
|
266
|
+
return std::string();
|
|
267
|
+
}
|
|
268
|
+
wchar_t buffer[MAX_PATH];
|
|
269
|
+
DWORD size = MAX_PATH;
|
|
270
|
+
std::string app_name;
|
|
271
|
+
if (QueryFullProcessImageNameW(process, 0, buffer, &size)) {
|
|
272
|
+
app_name = BasenameFromPath(std::wstring(buffer, size));
|
|
273
|
+
}
|
|
274
|
+
CloseHandle(process);
|
|
275
|
+
return app_name;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
double UserIdleSeconds() {
|
|
279
|
+
LASTINPUTINFO info{};
|
|
280
|
+
info.cbSize = sizeof(LASTINPUTINFO);
|
|
281
|
+
if (!GetLastInputInfo(&info)) {
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
const ULONGLONG now = GetTickCount64();
|
|
285
|
+
const ULONGLONG then = static_cast<ULONGLONG>(info.dwTime);
|
|
286
|
+
if (now < then) {
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
return static_cast<double>(now - then) / 1000.0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
bool IsWorkstationLocked() {
|
|
293
|
+
HDESK input_desktop = OpenInputDesktop(0, FALSE, GENERIC_READ);
|
|
294
|
+
if (input_desktop == nullptr) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
wchar_t name[256];
|
|
298
|
+
DWORD needed = 0;
|
|
299
|
+
const bool ok = GetUserObjectInformationW(
|
|
300
|
+
input_desktop, UOI_NAME, name, sizeof(name), &needed);
|
|
301
|
+
CloseDesktop(input_desktop);
|
|
302
|
+
if (!ok) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
return _wcsicmp(name, L"Default") != 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
244
308
|
void SendMouseButton(DWORD flag) {
|
|
245
309
|
INPUT input{};
|
|
246
310
|
input.type = INPUT_MOUSE;
|
|
@@ -329,6 +393,8 @@ bool FlutterWindow::OnCreate() {
|
|
|
329
393
|
const auto* arguments = std::get_if<EncodableMap>(call.arguments());
|
|
330
394
|
|
|
331
395
|
if (call.method_name() == "getStatus") {
|
|
396
|
+
const std::string frontmost_app = ForegroundWindowApp();
|
|
397
|
+
const double idle_seconds = UserIdleSeconds();
|
|
332
398
|
EncodableMap permissions;
|
|
333
399
|
permissions[EncodableValue("screenCapture")] = EncodableValue("available");
|
|
334
400
|
permissions[EncodableValue("inputControl")] = EncodableValue("available");
|
|
@@ -339,6 +405,15 @@ bool FlutterWindow::OnCreate() {
|
|
|
339
405
|
status[EncodableValue("displays")] = EncodableValue(DisplaysToEncodable());
|
|
340
406
|
status[EncodableValue("activeDisplayId")] =
|
|
341
407
|
EncodableValue(WideToUtf8(ResolveDisplay("").id));
|
|
408
|
+
status[EncodableValue("sessionLocked")] =
|
|
409
|
+
EncodableValue(IsWorkstationLocked());
|
|
410
|
+
status[EncodableValue("idleSeconds")] = EncodableValue(idle_seconds);
|
|
411
|
+
status[EncodableValue("userIdle")] =
|
|
412
|
+
EncodableValue(idle_seconds >= 300.0);
|
|
413
|
+
if (!frontmost_app.empty()) {
|
|
414
|
+
status[EncodableValue("frontmostApp")] =
|
|
415
|
+
EncodableValue(frontmost_app);
|
|
416
|
+
}
|
|
342
417
|
const std::string window_title = ForegroundWindowTitle();
|
|
343
418
|
if (!window_title.empty()) {
|
|
344
419
|
status[EncodableValue("frontmostWindowTitle")] =
|
package/landing/index.html
CHANGED
|
@@ -640,7 +640,7 @@ p { margin: 0; text-wrap: pretty; }
|
|
|
640
640
|
.hero-note { margin-top: 16px; font-family: var(--mono); font-size: 12.5px; color: var(--ink-3); letter-spacing: 0.02em; }
|
|
641
641
|
|
|
642
642
|
/* ---------- orbit system ---------- */
|
|
643
|
-
.orbit-stage { position: relative; width: min(460px, 86vw); aspect-ratio: 1; margin-inline: auto; }
|
|
643
|
+
.orbit-stage { position: relative; width: min(460px, 86vw, 100%); aspect-ratio: 1; margin-inline: auto; }
|
|
644
644
|
.orbit-glow { position: absolute; inset: 16%; border-radius: 50%; background: radial-gradient(circle, rgba(199,154,82,0.28), transparent 65%); filter: blur(22px); animation: corepulse 7s ease-in-out infinite; }
|
|
645
645
|
@media (prefers-color-scheme: dark) { .orbit-glow { background: radial-gradient(circle, rgba(228,197,141,0.22), transparent 65%); } }
|
|
646
646
|
@keyframes corepulse { 0%,100% { opacity: .7; transform: scale(1); } 50% { opacity: 1; transform: scale(1.07); } }
|
|
@@ -830,7 +830,7 @@ p { margin: 0; text-wrap: pretty; }
|
|
|
830
830
|
.hero-actions { flex-direction: column; width: 100%; }
|
|
831
831
|
.hero-actions .btn { width: 100%; justify-content: center; }
|
|
832
832
|
.final .hero-actions .btn { width: auto; }
|
|
833
|
-
.orbit-stage { width: min(320px,
|
|
833
|
+
.orbit-stage { width: min(320px, 78vw); }
|
|
834
834
|
.frame-bar .url { max-width: 70%; }
|
|
835
835
|
.footer-inner { flex-direction: column; gap: 22px; }
|
|
836
836
|
.footer-bar { flex-direction: column; align-items: flex-start; gap: 8px; }
|
|
@@ -848,7 +848,7 @@ p { margin: 0; text-wrap: pretty; }
|
|
|
848
848
|
.logo .ri { transform-box: fill-box; transform-origin: center; }
|
|
849
849
|
.logo .ri-a { animation: spinA 18s linear infinite; }
|
|
850
850
|
.logo .ri-b { animation: spinB 26s linear infinite; }
|
|
851
|
-
.logo .nd { animation: nd 14s linear infinite; transform-box:
|
|
851
|
+
.logo .nd { animation: nd 14s linear infinite; transform-box: view-box; transform-origin: 50px 50px; }
|
|
852
852
|
@keyframes spinA { to { transform: rotate(360deg); } }
|
|
853
853
|
@keyframes spinB { to { transform: rotate(-360deg); } }
|
|
854
854
|
@keyframes nd { to { transform: rotate(360deg); } }
|