@bota.dev/react-native-app-sdk 2.0.0-beta.0 → 2.0.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -3
- package/android/build.gradle +1 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevices.kt +22 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDiagnostics.kt +53 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogs.kt +16 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +144 -14
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +150 -29
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKEncryptedUploadV2Materials.kt +27 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +86 -3
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevicesTest.kt +35 -0
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogsTest.kt +33 -0
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +135 -6
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingsTest.kt +236 -10
- package/generated/codegen-contract.json +568 -8
- package/ios/BotaDeviceSDK.mm +114 -0
- package/ios/BotaDeviceSDKAppleBridge.swift +141 -4
- package/ios/BotaDeviceSDKAppleDevices.swift +11 -0
- package/ios/BotaDeviceSDKAppleDiagnostics.swift +40 -0
- package/ios/BotaDeviceSDKAppleLogs.swift +18 -0
- package/ios/BotaDeviceSDKAppleRecordingUploads.swift +138 -6
- package/ios/BotaDeviceSDKAppleRecordings.swift +113 -5
- package/ios/BotaDeviceSDKEncryptedUploadV2Materials.swift +40 -0
- package/lib/commonjs/BotaClient.js +16 -2
- package/lib/commonjs/BotaClient.js.map +1 -1
- package/lib/commonjs/ble/constants.js +5 -1
- package/lib/commonjs/ble/constants.js.map +1 -1
- package/lib/commonjs/ble/deviceDiagnostics.js +240 -0
- package/lib/commonjs/ble/deviceDiagnostics.js.map +1 -0
- package/lib/commonjs/client.js +183 -28
- package/lib/commonjs/client.js.map +1 -1
- package/lib/commonjs/clientPresence.js +26 -0
- package/lib/commonjs/clientPresence.js.map +1 -0
- package/lib/commonjs/index.js +29 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/managers/DeviceManager.js +23 -0
- package/lib/commonjs/managers/DeviceManager.js.map +1 -1
- package/lib/commonjs/managers/NativeUploadQueue.js +386 -0
- package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -0
- package/lib/commonjs/managers/RecordingManager.js +83 -102
- package/lib/commonjs/managers/RecordingManager.js.map +1 -1
- package/lib/commonjs/managers/uploadRecoveryMetadata.js +83 -0
- package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -0
- package/lib/commonjs/sdkIdentity.js +10 -0
- package/lib/commonjs/sdkIdentity.js.map +1 -0
- package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/commonjs/uploadRecovery.js +68 -0
- package/lib/commonjs/uploadRecovery.js.map +1 -0
- package/lib/module/BotaClient.js +16 -2
- package/lib/module/BotaClient.js.map +1 -1
- package/lib/module/ble/constants.js +4 -0
- package/lib/module/ble/constants.js.map +1 -1
- package/lib/module/ble/deviceDiagnostics.js +234 -0
- package/lib/module/ble/deviceDiagnostics.js.map +1 -0
- package/lib/module/client.js +183 -28
- package/lib/module/client.js.map +1 -1
- package/lib/module/clientPresence.js +22 -0
- package/lib/module/clientPresence.js.map +1 -0
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/managers/DeviceManager.js +23 -0
- package/lib/module/managers/DeviceManager.js.map +1 -1
- package/lib/module/managers/NativeUploadQueue.js +380 -0
- package/lib/module/managers/NativeUploadQueue.js.map +1 -0
- package/lib/module/managers/RecordingManager.js +83 -102
- package/lib/module/managers/RecordingManager.js.map +1 -1
- package/lib/module/managers/uploadRecoveryMetadata.js +77 -0
- package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -0
- package/lib/module/sdkIdentity.js +6 -0
- package/lib/module/sdkIdentity.js.map +1 -0
- package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/module/uploadRecovery.js +64 -0
- package/lib/module/uploadRecovery.js.map +1 -0
- package/lib/typescript/BotaClient.d.ts +2 -0
- package/lib/typescript/BotaClient.d.ts.map +1 -1
- package/lib/typescript/ble/constants.d.ts +4 -0
- package/lib/typescript/ble/constants.d.ts.map +1 -1
- package/lib/typescript/ble/deviceDiagnostics.d.ts +10 -0
- package/lib/typescript/ble/deviceDiagnostics.d.ts.map +1 -0
- package/lib/typescript/client.d.ts +24 -3
- package/lib/typescript/client.d.ts.map +1 -1
- package/lib/typescript/clientPresence.d.ts +26 -0
- package/lib/typescript/clientPresence.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +10 -4
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/managers/DeviceManager.d.ts +5 -1
- package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
- package/lib/typescript/managers/NativeUploadQueue.d.ts +41 -0
- package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -0
- package/lib/typescript/managers/RecordingManager.d.ts +3 -8
- package/lib/typescript/managers/RecordingManager.d.ts.map +1 -1
- package/lib/typescript/managers/types.d.ts +6 -1
- package/lib/typescript/managers/types.d.ts.map +1 -1
- package/lib/typescript/managers/uploadRecoveryMetadata.d.ts +6 -0
- package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -0
- package/lib/typescript/models/Device.d.ts +42 -0
- package/lib/typescript/models/Device.d.ts.map +1 -1
- package/lib/typescript/models/Recording.d.ts +47 -0
- package/lib/typescript/models/Recording.d.ts.map +1 -1
- package/lib/typescript/models/Status.d.ts +5 -1
- package/lib/typescript/models/Status.d.ts.map +1 -1
- package/lib/typescript/sdkIdentity.d.ts +3 -0
- package/lib/typescript/sdkIdentity.d.ts.map +1 -0
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +70 -0
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
- package/lib/typescript/uploadRecovery.d.ts +27 -0
- package/lib/typescript/uploadRecovery.d.ts.map +1 -0
- package/package.json +1 -1
- package/scripts/bota_device_sdk_spm_workaround.rb +26 -15
- package/src/BotaClient.ts +17 -3
- package/src/ble/constants.ts +5 -0
- package/src/ble/deviceDiagnostics.ts +238 -0
- package/src/client.ts +189 -18
- package/src/clientPresence.ts +33 -0
- package/src/index.ts +18 -0
- package/src/managers/DeviceManager.ts +23 -0
- package/src/managers/NativeUploadQueue.ts +330 -0
- package/src/managers/RecordingManager.ts +83 -116
- package/src/managers/types.ts +7 -1
- package/src/managers/uploadRecoveryMetadata.ts +59 -0
- package/src/models/Device.ts +43 -0
- package/src/models/Recording.ts +52 -0
- package/src/models/Status.ts +5 -1
- package/src/sdkIdentity.ts +3 -0
- package/src/specs/NativeBotaDeviceSDK.ts +66 -0
- package/src/uploadRecovery.ts +66 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import EventEmitter from 'eventemitter3';
|
|
2
|
+
import type { BotaDeviceSDKRecordingClient, BotaRecordingTransferProgress } from '../client';
|
|
3
|
+
import type { UploadInfo, UploadRecoveryProvider, UploadTask } from '../models/Recording';
|
|
4
|
+
import type { RecordingManagerEvents } from '../models/Status';
|
|
5
|
+
import { persistedUploadQueue, restoreUploadQueue } from './uploadRecoveryMetadata';
|
|
6
|
+
|
|
7
|
+
const journalOperations = new WeakMap<object, Promise<unknown>>();
|
|
8
|
+
const journalOwners = new WeakMap<object, NativeUploadQueue>();
|
|
9
|
+
const retryDelays = [30_000, 120_000, 600_000, 3_600_000, 14_400_000];
|
|
10
|
+
const cancelled = () => new Error('Upload cancelled');
|
|
11
|
+
const dispose = (info: UploadInfo | null | undefined) => {
|
|
12
|
+
try { info?.dispose?.(); } catch { /* Cleanup cannot change an acknowledged result. */ }
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type Attempt = {
|
|
16
|
+
controller: AbortController;
|
|
17
|
+
nativeId: string;
|
|
18
|
+
promise: Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Races application callbacks, not native teardown. Late credentials still release their lease. */
|
|
22
|
+
const abortable = <T>(promise: Promise<T>, signal: AbortSignal, late?: (value: T) => void): Promise<T> =>
|
|
23
|
+
new Promise((resolve, reject) => {
|
|
24
|
+
let settled = false;
|
|
25
|
+
const abort = () => { if (!settled) { settled = true; reject(cancelled()); } };
|
|
26
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
27
|
+
if (signal.aborted) abort();
|
|
28
|
+
void promise.then(value => {
|
|
29
|
+
signal.removeEventListener('abort', abort);
|
|
30
|
+
if (settled) { late?.(value); return; }
|
|
31
|
+
settled = true;
|
|
32
|
+
resolve(value);
|
|
33
|
+
}, error => {
|
|
34
|
+
signal.removeEventListener('abort', abort);
|
|
35
|
+
if (!settled) { settled = true; reject(error); }
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Only metadata crosses this owner. Native hosts retain and upload every byte. */
|
|
40
|
+
export class NativeUploadQueue extends EventEmitter<RecordingManagerEvents> {
|
|
41
|
+
private tasks: UploadTask[] = [];
|
|
42
|
+
private readonly active = new Map<string, Attempt>();
|
|
43
|
+
private readonly credentials = new Map<string, UploadInfo>();
|
|
44
|
+
private readonly cancelledTasks = new Set<string>();
|
|
45
|
+
private paused = false;
|
|
46
|
+
private destroyed = false;
|
|
47
|
+
private initialized = false;
|
|
48
|
+
private initialization?: Promise<void>;
|
|
49
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly client: BotaDeviceSDKRecordingClient,
|
|
53
|
+
private readonly provider?: UploadRecoveryProvider
|
|
54
|
+
) { super(); }
|
|
55
|
+
|
|
56
|
+
initialize(): Promise<void> {
|
|
57
|
+
if (this.initialization) return this.initialization;
|
|
58
|
+
if (this.destroyed) return Promise.reject(cancelled());
|
|
59
|
+
const owner = journalOwners.get(this.client);
|
|
60
|
+
if (owner && owner !== this) return Promise.reject(new Error('Upload recovery journal is already owned by another RecordingManager'));
|
|
61
|
+
journalOwners.set(this.client, this);
|
|
62
|
+
this.initialization = this.serialize(async () => {
|
|
63
|
+
this.checkAlive();
|
|
64
|
+
const loaded = await this.client.loadUploadQueue();
|
|
65
|
+
this.checkAlive();
|
|
66
|
+
const restored = restoreUploadQueue(loaded);
|
|
67
|
+
if (restored.length > 0) await this.client.saveUploadQueue(restored);
|
|
68
|
+
this.checkAlive();
|
|
69
|
+
this.tasks = restored;
|
|
70
|
+
this.initialized = true;
|
|
71
|
+
}).then(() => {
|
|
72
|
+
for (const task of this.tasks.filter(value => value.status === 'completed')) {
|
|
73
|
+
void this.release(task).catch(() => undefined);
|
|
74
|
+
}
|
|
75
|
+
this.schedule();
|
|
76
|
+
}).catch(error => {
|
|
77
|
+
if (journalOwners.get(this.client) === this) journalOwners.delete(this.client);
|
|
78
|
+
throw error;
|
|
79
|
+
});
|
|
80
|
+
return this.initialization;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
all(): UploadTask[] { return this.tasks.map(task => ({ ...task, createdAt: new Date(task.createdAt), updatedAt: new Date(task.updatedAt) })); }
|
|
84
|
+
get(id: string): UploadTask | undefined { return this.all().find(task => task.id === id); }
|
|
85
|
+
|
|
86
|
+
async add(task: UploadTask, info: UploadInfo): Promise<void> {
|
|
87
|
+
await this.mutate(tasks => [...tasks, ...persistedUploadQueue([task])]);
|
|
88
|
+
this.credentials.set(task.id, info);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
offerCredentials(id: string, info: UploadInfo): boolean {
|
|
92
|
+
if (this.active.has(id) || this.get(id)?.status === 'completed' || this.paused) return false;
|
|
93
|
+
this.discardCredentials(id);
|
|
94
|
+
this.credentials.set(id, info);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async update(id: string, patch: Partial<UploadTask>, check?: () => void): Promise<void> {
|
|
99
|
+
await this.mutate(tasks => {
|
|
100
|
+
check?.();
|
|
101
|
+
if (!tasks.some(task => task.id === id) || this.cancelledTasks.has(id)) throw cancelled();
|
|
102
|
+
return tasks.map(task => task.id === id ? { ...task, ...patch, updatedAt: new Date() } : task);
|
|
103
|
+
});
|
|
104
|
+
const info = this.credentials.get(id);
|
|
105
|
+
if (info && patch.relayUpload === false) this.credentials.set(id, { ...info, relay: undefined });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
run(id: string, onProgress?: (value: BotaRecordingTransferProgress) => void): Promise<void> {
|
|
109
|
+
if (this.active.has(id)) return this.active.get(id)!.promise;
|
|
110
|
+
if (this.get(id)?.status === 'completed') return Promise.resolve();
|
|
111
|
+
if (this.paused || this.destroyed || this.cancelledTasks.has(id)) return Promise.reject(cancelled());
|
|
112
|
+
const attempt: Attempt = {
|
|
113
|
+
controller: new AbortController(),
|
|
114
|
+
nativeId: `${id}_${Date.now()}_${Math.random().toString(36).slice(2)}`,
|
|
115
|
+
promise: Promise.resolve(),
|
|
116
|
+
};
|
|
117
|
+
this.active.set(id, attempt);
|
|
118
|
+
attempt.promise = this.perform(id, attempt, onProgress).finally(() => {
|
|
119
|
+
if (this.active.get(id) === attempt) this.active.delete(id);
|
|
120
|
+
this.schedule();
|
|
121
|
+
});
|
|
122
|
+
return attempt.promise;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async perform(id: string, attempt: Attempt, onProgress?: (value: BotaRecordingTransferProgress) => void): Promise<void> {
|
|
126
|
+
const signal = attempt.controller.signal;
|
|
127
|
+
const check = () => {
|
|
128
|
+
if (this.destroyed || signal.aborted || this.cancelledTasks.has(id) || !this.get(id) || this.active.get(id) !== attempt) throw cancelled();
|
|
129
|
+
};
|
|
130
|
+
let info: UploadInfo | null | undefined;
|
|
131
|
+
let hostAbort: (() => void) | undefined;
|
|
132
|
+
let initialSignal: AbortSignal | undefined;
|
|
133
|
+
const initialAbort = () => attempt.controller.abort();
|
|
134
|
+
let nativeStarted = false;
|
|
135
|
+
let nativeCancellation: Promise<void> | undefined;
|
|
136
|
+
const cancelNative = () => {
|
|
137
|
+
if (nativeStarted && !nativeCancellation) {
|
|
138
|
+
nativeCancellation = this.client.cancelRecordingUpload(attempt.nativeId);
|
|
139
|
+
void nativeCancellation.catch(() => undefined);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
signal.addEventListener('abort', cancelNative);
|
|
143
|
+
try {
|
|
144
|
+
check();
|
|
145
|
+
const task = this.get(id)!;
|
|
146
|
+
info = this.credentials.get(id);
|
|
147
|
+
this.credentials.delete(id);
|
|
148
|
+
initialSignal = info?.signal;
|
|
149
|
+
initialSignal?.addEventListener('abort', initialAbort, { once: true });
|
|
150
|
+
if (initialSignal?.aborted) initialAbort();
|
|
151
|
+
check();
|
|
152
|
+
if (info?.expiresAt && info.expiresAt.getTime() <= Date.now() && !info.alreadyUploaded && this.provider && !info.signal?.aborted) {
|
|
153
|
+
dispose(info);
|
|
154
|
+
info = undefined;
|
|
155
|
+
}
|
|
156
|
+
if (!info) {
|
|
157
|
+
if (!this.provider || !task.recordingUuid) throw new Error('Upload recovery requires a provider and recordingUuid');
|
|
158
|
+
info = await abortable(Promise.resolve().then(() => {
|
|
159
|
+
check();
|
|
160
|
+
return this.provider!({
|
|
161
|
+
taskId: id, recordingId: task.recordingId, deviceId: task.deviceId,
|
|
162
|
+
recordingUuid: task.recordingUuid!, recoveryScope: task.recoveryScope,
|
|
163
|
+
fileSizeBytes: task.fileSizeBytes, relayUpload: !!task.relayUpload,
|
|
164
|
+
contentType: task.contentType, contentSha256: task.contentSha256, signal,
|
|
165
|
+
});
|
|
166
|
+
}), signal, dispose);
|
|
167
|
+
}
|
|
168
|
+
check();
|
|
169
|
+
if (!info) {
|
|
170
|
+
await this.update(id, { status: 'pending', nextAttemptAt: Date.now() + 30_000 }, check);
|
|
171
|
+
throw new Error('Upload waiting for its original account');
|
|
172
|
+
}
|
|
173
|
+
if (info.recordingId !== task.recordingId || info.recoveryScope !== task.recoveryScope) {
|
|
174
|
+
throw new Error('Upload recovery identity changed');
|
|
175
|
+
}
|
|
176
|
+
if (!info.alreadyUploaded && !!info.relay !== !!task.relayUpload) throw new Error('Upload recovery route changed');
|
|
177
|
+
hostAbort = () => attempt.controller.abort();
|
|
178
|
+
info.signal?.addEventListener('abort', hostAbort, { once: true });
|
|
179
|
+
if (info.signal?.aborted) hostAbort();
|
|
180
|
+
check();
|
|
181
|
+
if (info.expiresAt && info.expiresAt.getTime() <= Date.now() && !info.alreadyUploaded) throw new Error('Upload credentials expired');
|
|
182
|
+
await this.update(id, { status: 'uploading', errorMessage: undefined, nextAttemptAt: undefined }, check);
|
|
183
|
+
check();
|
|
184
|
+
this.emit('uploadStarted', id);
|
|
185
|
+
check();
|
|
186
|
+
if (!info.alreadyUploaded) {
|
|
187
|
+
nativeStarted = true;
|
|
188
|
+
await this.client.uploadRecordingFile({
|
|
189
|
+
...task, id: attempt.nativeId, uploadUrl: info.uploadUrl,
|
|
190
|
+
contentType: info.contentType ?? task.contentType,
|
|
191
|
+
uploadToken: info.complete ? undefined : info.uploadToken,
|
|
192
|
+
completeUrl: info.complete ? undefined : info.completeUrl,
|
|
193
|
+
relay: info.relay,
|
|
194
|
+
}, value => {
|
|
195
|
+
try { check(); } catch { return; }
|
|
196
|
+
onProgress?.(value);
|
|
197
|
+
this.emit('uploadProgress', id, value.totalBytes > 0 ? Math.min(value.completedBytes / value.totalBytes, 1) : 0);
|
|
198
|
+
});
|
|
199
|
+
check();
|
|
200
|
+
}
|
|
201
|
+
if (info.complete) {
|
|
202
|
+
if (task.fileSizeBytes === undefined) throw new Error('Upload completion requires known file size');
|
|
203
|
+
await abortable(Promise.resolve().then(() => {
|
|
204
|
+
check();
|
|
205
|
+
return info!.complete!({ fileSizeBytes: task.fileSizeBytes!, contentSha256: task.contentSha256, signal });
|
|
206
|
+
}), signal);
|
|
207
|
+
} else if (info.alreadyUploaded || (this.provider && !info.relay && !(info.completeUrl && info.uploadToken))) {
|
|
208
|
+
throw new Error('Recoverable upload requires durable host completion acknowledgement');
|
|
209
|
+
}
|
|
210
|
+
check();
|
|
211
|
+
await this.update(id, { status: 'completed', errorMessage: undefined, nextAttemptAt: undefined }, check);
|
|
212
|
+
check();
|
|
213
|
+
await this.release(this.get(id)!).catch(() => undefined);
|
|
214
|
+
check();
|
|
215
|
+
this.emit('uploadCompleted', id, task.recordingId);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (!this.destroyed && !this.cancelledTasks.has(id) && this.get(id)?.status !== 'completed') {
|
|
218
|
+
const task = this.get(id)!;
|
|
219
|
+
const parked = info === null || signal.aborted;
|
|
220
|
+
const retry = !!this.provider && task.retryCount < 6 && Date.now() - task.createdAt.getTime() < 86_400_000;
|
|
221
|
+
await this.update(id, {
|
|
222
|
+
status: parked || retry ? 'pending' : 'failed',
|
|
223
|
+
retryCount: task.retryCount + (parked ? 0 : 1),
|
|
224
|
+
nextAttemptAt: parked ? Date.now() + 30_000 : retry ? Date.now() + retryDelays[Math.min(task.retryCount, 4)] : undefined,
|
|
225
|
+
errorMessage: parked ? 'Upload waiting for its original account' : 'Upload attempt failed',
|
|
226
|
+
}).catch(() => { this.pause(); });
|
|
227
|
+
if (!this.destroyed) this.emit('uploadFailed', id, new Error(parked ? 'Upload paused' : 'Upload attempt failed'));
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
} finally {
|
|
231
|
+
signal.removeEventListener('abort', cancelNative);
|
|
232
|
+
initialSignal?.removeEventListener('abort', initialAbort);
|
|
233
|
+
if (hostAbort) info?.signal?.removeEventListener('abort', hostAbort);
|
|
234
|
+
// Credential-only legacy callers can explicitly retry during this process.
|
|
235
|
+
if (!this.provider && info && !info.dispose && !info.signal && !this.destroyed && !this.cancelledTasks.has(id) && this.get(id)?.status !== 'completed') this.credentials.set(id, info);
|
|
236
|
+
dispose(info);
|
|
237
|
+
await nativeCancellation?.catch(() => { this.pause(); });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async release(task: UploadTask): Promise<void> {
|
|
242
|
+
this.checkAlive();
|
|
243
|
+
await this.client.releaseRecordingFile(task.id, task.localPath);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async cancel(id: string): Promise<void> {
|
|
247
|
+
this.cancelledTasks.add(id);
|
|
248
|
+
const active = this.active.get(id);
|
|
249
|
+
active?.controller.abort();
|
|
250
|
+
this.discardCredentials(id);
|
|
251
|
+
if (!active) await this.client.cancelRecordingUpload(id);
|
|
252
|
+
await this.mutate(tasks => tasks.filter(task => task.id !== id));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
discardCredentials(id: string): void {
|
|
256
|
+
const credential = this.credentials.get(id);
|
|
257
|
+
this.credentials.delete(id);
|
|
258
|
+
dispose(credential);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async clearCompleted(): Promise<void> {
|
|
262
|
+
const snapshot = this.all().filter(value => value.status === 'completed');
|
|
263
|
+
for (const task of snapshot) await this.release(task);
|
|
264
|
+
await this.mutate(tasks => tasks.filter(task => !snapshot.some(released =>
|
|
265
|
+
task.status === 'completed' && task.id === released.id && task.localPath === released.localPath &&
|
|
266
|
+
task.recordingId === released.recordingId && task.deviceId === released.deviceId &&
|
|
267
|
+
task.recordingUuid === released.recordingUuid && task.recoveryScope === released.recoveryScope
|
|
268
|
+
)));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async clearAll(): Promise<void> { for (const task of this.all()) await this.cancel(task.id); }
|
|
272
|
+
|
|
273
|
+
async retryFailed(): Promise<void> {
|
|
274
|
+
const retry = this.all().filter(task => (task.status === 'failed' || (task.status === 'pending' && task.nextAttemptAt)) && !this.active.has(task.id) && !!task.localPath);
|
|
275
|
+
for (const task of retry) await this.update(task.id, { status: 'pending', retryCount: 0, nextAttemptAt: undefined, createdAt: new Date() });
|
|
276
|
+
if (!this.paused) for (const task of retry) await this.run(task.id).catch(() => undefined);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
pause(): void { this.paused = true; if (this.timer) clearTimeout(this.timer); }
|
|
280
|
+
resume(): void { this.checkAlive(); this.paused = false; this.schedule(); }
|
|
281
|
+
|
|
282
|
+
destroy(): void {
|
|
283
|
+
if (this.destroyed) return;
|
|
284
|
+
this.destroyed = true;
|
|
285
|
+
if (journalOwners.get(this.client) === this) {
|
|
286
|
+
// New owners must read only after old persistence and native stop have settled.
|
|
287
|
+
const stopped = this.client.destroyCompatibilityOperations();
|
|
288
|
+
void stopped.catch(() => undefined);
|
|
289
|
+
void this.serialize(() => stopped).catch(() => undefined);
|
|
290
|
+
journalOwners.delete(this.client);
|
|
291
|
+
}
|
|
292
|
+
this.pause();
|
|
293
|
+
for (const attempt of this.active.values()) attempt.controller.abort();
|
|
294
|
+
for (const info of this.credentials.values()) dispose(info);
|
|
295
|
+
this.credentials.clear();
|
|
296
|
+
this.removeAllListeners();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private schedule(): void {
|
|
300
|
+
if (!this.initialized || this.destroyed || this.paused) return;
|
|
301
|
+
if (this.timer) clearTimeout(this.timer);
|
|
302
|
+
let wake = Infinity;
|
|
303
|
+
for (const task of this.tasks) {
|
|
304
|
+
if (task.status !== 'pending' || !task.localPath || this.active.has(task.id) || this.cancelledTasks.has(task.id) || (!this.provider && !this.credentials.has(task.id))) continue;
|
|
305
|
+
if ((task.nextAttemptAt ?? 0) > Date.now()) { wake = Math.min(wake, task.nextAttemptAt!); continue; }
|
|
306
|
+
if (this.active.size >= 2) continue;
|
|
307
|
+
void this.run(task.id).catch(() => undefined);
|
|
308
|
+
}
|
|
309
|
+
if (Number.isFinite(wake)) this.timer = setTimeout(() => this.schedule(), Math.max(1, wake - Date.now()));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private serialize<T>(operation: () => Promise<T>): Promise<T> {
|
|
313
|
+
const next = (journalOperations.get(this.client) ?? Promise.resolve()).catch(() => undefined).then(operation);
|
|
314
|
+
journalOperations.set(this.client, next.catch(() => undefined));
|
|
315
|
+
return next;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private mutate(change: (tasks: UploadTask[]) => UploadTask[]): Promise<void> {
|
|
319
|
+
return this.serialize(async () => {
|
|
320
|
+
this.checkAlive();
|
|
321
|
+
const next = change(this.tasks);
|
|
322
|
+
await this.client.saveUploadQueue(persistedUploadQueue(next));
|
|
323
|
+
this.checkAlive();
|
|
324
|
+
this.tasks = next;
|
|
325
|
+
this.emit('queueUpdated', this.all());
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private checkAlive(): void { if (this.destroyed) throw cancelled(); }
|
|
330
|
+
}
|
|
@@ -16,7 +16,9 @@ import type {
|
|
|
16
16
|
UploadTask,
|
|
17
17
|
} from '../models/Recording';
|
|
18
18
|
import type { RecordingManagerEvents } from '../models/Status';
|
|
19
|
-
import type { UploadInfoProvider } from './types';
|
|
19
|
+
import type { RecordingManagerOptions, UploadInfoProvider } from './types';
|
|
20
|
+
import { NativeUploadQueue } from './NativeUploadQueue';
|
|
21
|
+
import { rejectRecordingDataStore } from './uploadRecoveryMetadata';
|
|
20
22
|
import {
|
|
21
23
|
StreamingSession,
|
|
22
24
|
setStreamingSessionTerminalHandler,
|
|
@@ -85,26 +87,30 @@ const observeProgress = <T>(
|
|
|
85
87
|
|
|
86
88
|
export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
|
|
87
89
|
private readonly client: BotaDeviceSDKClient;
|
|
88
|
-
private
|
|
90
|
+
private readonly queue: NativeUploadQueue;
|
|
89
91
|
private initialized = false;
|
|
90
|
-
private paused = false;
|
|
91
92
|
private destroyed = false;
|
|
92
93
|
private activeStreamingSession: StreamingSession | null = null;
|
|
93
94
|
|
|
94
|
-
constructor()
|
|
95
|
+
constructor();
|
|
96
|
+
constructor(options: RecordingManagerOptions);
|
|
97
|
+
constructor(options: RecordingManagerOptions = {}) {
|
|
95
98
|
super();
|
|
99
|
+
rejectRecordingDataStore(options.recordingDataStore);
|
|
96
100
|
this.client = getCompatibilityClient();
|
|
101
|
+
this.queue = new NativeUploadQueue(this.client.recordings, options.uploadRecoveryProvider);
|
|
102
|
+
this.queue.on('queueUpdated', tasks => this.emit('queueUpdated', tasks));
|
|
103
|
+
this.queue.on('uploadStarted', id => this.emit('uploadStarted', id));
|
|
104
|
+
this.queue.on('uploadProgress', (id, progress) => this.emit('uploadProgress', id, progress));
|
|
105
|
+
this.queue.on('uploadCompleted', (id, recordingId) => this.emit('uploadCompleted', id, recordingId));
|
|
106
|
+
this.queue.on('uploadFailed', (id, error) => this.emit('uploadFailed', id, error));
|
|
97
107
|
}
|
|
98
108
|
|
|
99
109
|
async initialize(): Promise<void> {
|
|
100
110
|
if (this.initialized) return;
|
|
101
111
|
this.assertAlive();
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
createdAt: new Date(task.createdAt),
|
|
105
|
-
updatedAt: new Date(task.updatedAt),
|
|
106
|
-
status: task.status === 'uploading' ? 'pending' : task.status,
|
|
107
|
-
}));
|
|
112
|
+
await this.queue.initialize();
|
|
113
|
+
this.assertAlive();
|
|
108
114
|
this.initialized = true;
|
|
109
115
|
}
|
|
110
116
|
|
|
@@ -133,6 +139,10 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
|
|
|
133
139
|
uploadInfo: UploadInfo
|
|
134
140
|
): AsyncGenerator<SyncProgress> {
|
|
135
141
|
this.assertReady();
|
|
142
|
+
if (uploadInfo.signal?.aborted) {
|
|
143
|
+
uploadInfo.dispose?.();
|
|
144
|
+
throw new Error('Upload authorization context cancelled');
|
|
145
|
+
}
|
|
136
146
|
this.emit('syncStarted', recording.uuid);
|
|
137
147
|
const sinkId = createOpaqueId();
|
|
138
148
|
const now = new Date();
|
|
@@ -140,6 +150,9 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
|
|
|
140
150
|
id: createTaskId(),
|
|
141
151
|
recordingId: uploadInfo.recordingId,
|
|
142
152
|
deviceId: device.id,
|
|
153
|
+
recordingUuid: recording.uuid,
|
|
154
|
+
recoveryScope: uploadInfo.recoveryScope,
|
|
155
|
+
fileSizeBytes: recording.fileSizeBytes,
|
|
143
156
|
localPath: '',
|
|
144
157
|
uploadUrl: uploadInfo.uploadUrl,
|
|
145
158
|
...(uploadInfo.uploadToken ? { uploadToken: uploadInfo.uploadToken } : {}),
|
|
@@ -150,10 +163,33 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
|
|
|
150
163
|
createdAt: now,
|
|
151
164
|
updatedAt: now,
|
|
152
165
|
};
|
|
153
|
-
this.
|
|
154
|
-
|
|
155
|
-
|
|
166
|
+
const recovered = uploadInfo.recoveryScope && this.queue.all().find(value =>
|
|
167
|
+
value.recoveryScope === uploadInfo.recoveryScope && value.recordingId === uploadInfo.recordingId &&
|
|
168
|
+
value.deviceId === device.id && value.recordingUuid === recording.uuid && !!value.localPath
|
|
169
|
+
);
|
|
170
|
+
if (recovered) {
|
|
171
|
+
const adopted = this.queue.offerCredentials(recovered.id, uploadInfo);
|
|
172
|
+
try {
|
|
173
|
+
await this.queue.run(recovered.id);
|
|
174
|
+
this.assertReady();
|
|
175
|
+
if (uploadInfo.signal?.aborted) throw new Error('Upload authorization context cancelled');
|
|
176
|
+
await this.client.recordings.confirmRecording(device, recording.uuid);
|
|
177
|
+
this.assertReady();
|
|
178
|
+
yield this.emitSyncProgress(recording.uuid, { stage: 'completed', progress: 1, recordingId: recovered.recordingId, contentSha256: recovered.contentSha256 });
|
|
179
|
+
this.emit('syncCompleted', recording.uuid, recovered.recordingId);
|
|
180
|
+
} finally {
|
|
181
|
+
if (!adopted) uploadInfo.dispose?.();
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
await this.queue.add(task, uploadInfo);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
uploadInfo.dispose?.();
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
156
191
|
|
|
192
|
+
let uploadAttempted = false;
|
|
157
193
|
try {
|
|
158
194
|
yield this.emitSyncProgress(recording.uuid, {
|
|
159
195
|
stage: 'preparing',
|
|
@@ -193,10 +229,19 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
|
|
|
193
229
|
'Device delivered encrypted recording data without an upload relay'
|
|
194
230
|
);
|
|
195
231
|
}
|
|
196
|
-
|
|
232
|
+
this.assertReady();
|
|
233
|
+
if (uploadInfo.signal?.aborted || !this.queue.get(task.id)) throw new Error('Upload cancelled');
|
|
234
|
+
const nativeSize = 'fileSizeBytes' in transferResult && typeof transferResult.fileSizeBytes === 'number'
|
|
235
|
+
? transferResult.fileSizeBytes : undefined;
|
|
236
|
+
if (nativeSize !== undefined && (!Number.isSafeInteger(nativeSize) || nativeSize < 0)) throw new Error('Invalid native recording file size');
|
|
237
|
+
if (nativeSize === undefined && uploadInfo.complete) throw new Error('Host completion requires actual native recording file size');
|
|
238
|
+
// Route selection comes from native transfer metadata, never list flags.
|
|
239
|
+
uploadInfo = { ...uploadInfo, relay: transferResult.e2eEncrypted ? uploadInfo.relay : undefined };
|
|
240
|
+
await this.queue.update(task.id, {
|
|
197
241
|
localPath: transferResult.localPath,
|
|
242
|
+
fileSizeBytes: nativeSize ?? (transferResult.e2eEncrypted ? undefined : recording.fileSizeBytes),
|
|
198
243
|
contentSha256: transferResult.contentSha256,
|
|
199
|
-
|
|
244
|
+
relayUpload: transferResult.e2eEncrypted,
|
|
200
245
|
});
|
|
201
246
|
|
|
202
247
|
yield this.emitSyncProgress(recording.uuid, {
|
|
@@ -206,8 +251,9 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
|
|
|
206
251
|
totalBytes: recording.fileSizeBytes,
|
|
207
252
|
contentSha256: transferResult.contentSha256,
|
|
208
253
|
});
|
|
254
|
+
uploadAttempted = true;
|
|
209
255
|
const upload = observeProgress((onProgress) =>
|
|
210
|
-
this.
|
|
256
|
+
this.queue.run(task.id, onProgress)
|
|
211
257
|
);
|
|
212
258
|
while (true) {
|
|
213
259
|
const value = await upload.next();
|
|
@@ -228,7 +274,10 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
|
|
|
228
274
|
stage: 'completing',
|
|
229
275
|
progress: 0.5,
|
|
230
276
|
});
|
|
277
|
+
this.assertReady();
|
|
278
|
+
if (uploadInfo.signal?.aborted || !this.queue.get(task.id)) throw new Error('Upload cancelled');
|
|
231
279
|
await this.client.recordings.confirmRecording(device, recording.uuid);
|
|
280
|
+
this.assertReady();
|
|
232
281
|
const completed = this.emitSyncProgress(recording.uuid, {
|
|
233
282
|
stage: 'completed',
|
|
234
283
|
progress: 1,
|
|
@@ -239,12 +288,13 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
|
|
|
239
288
|
this.emit('syncCompleted', recording.uuid, uploadInfo.recordingId);
|
|
240
289
|
} catch (error) {
|
|
241
290
|
const failure = error instanceof Error ? error : new Error(String(error));
|
|
242
|
-
if (this.
|
|
243
|
-
await this.
|
|
291
|
+
if (!this.destroyed && this.queue.get(task.id)?.status === 'pending' && !this.queue.get(task.id)?.localPath) {
|
|
292
|
+
await this.queue.update(task.id, {
|
|
244
293
|
status: 'failed',
|
|
245
|
-
errorMessage:
|
|
294
|
+
errorMessage: 'Recording transfer failed',
|
|
246
295
|
});
|
|
247
296
|
}
|
|
297
|
+
if (this.destroyed) throw failure;
|
|
248
298
|
yield this.emitSyncProgress(recording.uuid, {
|
|
249
299
|
stage: 'failed',
|
|
250
300
|
progress: 0,
|
|
@@ -252,6 +302,8 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
|
|
|
252
302
|
});
|
|
253
303
|
this.emit('syncFailed', recording.uuid, failure);
|
|
254
304
|
throw failure;
|
|
305
|
+
} finally {
|
|
306
|
+
if (!uploadAttempted) this.queue.discardCredentials(task.id);
|
|
255
307
|
}
|
|
256
308
|
}
|
|
257
309
|
|
|
@@ -392,13 +444,13 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
|
|
|
392
444
|
}
|
|
393
445
|
|
|
394
446
|
getPendingUploads(): UploadTask[] {
|
|
395
|
-
return this.
|
|
447
|
+
return this.queue.all().filter(
|
|
396
448
|
(task) => task.status === 'pending' || task.status === 'uploading'
|
|
397
449
|
);
|
|
398
450
|
}
|
|
399
451
|
|
|
400
452
|
getAllUploads(): UploadTask[] {
|
|
401
|
-
return this.
|
|
453
|
+
return this.queue.all();
|
|
402
454
|
}
|
|
403
455
|
|
|
404
456
|
startStreamingSync(
|
|
@@ -435,99 +487,43 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
|
|
|
435
487
|
}
|
|
436
488
|
|
|
437
489
|
async cancelUpload(taskId: string): Promise<void> {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
await this.persistTasks();
|
|
441
|
-
this.emitQueueUpdated();
|
|
490
|
+
this.assertReady();
|
|
491
|
+
await this.queue.cancel(taskId);
|
|
442
492
|
}
|
|
443
493
|
|
|
444
494
|
async retryFailedUploads(): Promise<void> {
|
|
445
495
|
this.assertReady();
|
|
446
|
-
|
|
447
|
-
for (const task of this.tasks.filter((value) => value.status === 'failed')) {
|
|
448
|
-
await this.updateTask(task.id, {
|
|
449
|
-
status: 'pending',
|
|
450
|
-
retryCount: task.retryCount + 1,
|
|
451
|
-
errorMessage: undefined,
|
|
452
|
-
});
|
|
453
|
-
try {
|
|
454
|
-
await this.runUpload(task.id);
|
|
455
|
-
} catch {
|
|
456
|
-
// Keep failed tasks available for the next explicit retry.
|
|
457
|
-
}
|
|
458
|
-
}
|
|
496
|
+
await this.queue.retryFailed();
|
|
459
497
|
}
|
|
460
498
|
|
|
461
499
|
async clearCompletedUploads(): Promise<void> {
|
|
462
|
-
this.
|
|
463
|
-
await this.
|
|
464
|
-
this.emitQueueUpdated();
|
|
500
|
+
this.assertReady();
|
|
501
|
+
await this.queue.clearCompleted();
|
|
465
502
|
}
|
|
466
503
|
|
|
467
504
|
async clearAllUploads(): Promise<void> {
|
|
468
|
-
|
|
469
|
-
await
|
|
470
|
-
ids.map((taskId) =>
|
|
471
|
-
this.client.recordings.cancelRecordingUpload(taskId).catch(() => undefined)
|
|
472
|
-
)
|
|
473
|
-
);
|
|
474
|
-
this.tasks = [];
|
|
475
|
-
await this.persistTasks();
|
|
476
|
-
this.emitQueueUpdated();
|
|
505
|
+
this.assertReady();
|
|
506
|
+
await this.queue.clearAll();
|
|
477
507
|
}
|
|
478
508
|
|
|
479
509
|
pauseUploads(): void {
|
|
480
|
-
this.
|
|
510
|
+
this.queue.pause();
|
|
481
511
|
}
|
|
482
512
|
|
|
483
513
|
resumeUploads(): void {
|
|
484
|
-
this.
|
|
485
|
-
void this.retryFailedUploads();
|
|
514
|
+
this.queue.resume();
|
|
486
515
|
}
|
|
487
516
|
|
|
488
517
|
destroy(): void {
|
|
489
518
|
if (this.destroyed) return;
|
|
490
519
|
this.destroyed = true;
|
|
520
|
+
this.queue.destroy();
|
|
491
521
|
this.activeStreamingSession?.abort();
|
|
492
522
|
this.activeStreamingSession = null;
|
|
493
|
-
void this.client.recordings.destroyCompatibilityOperations().catch(() => undefined);
|
|
494
523
|
this.removeAllListeners();
|
|
495
524
|
this.initialized = false;
|
|
496
525
|
}
|
|
497
526
|
|
|
498
|
-
private async runUpload(
|
|
499
|
-
taskId: string,
|
|
500
|
-
onProgress?: (progress: BotaRecordingTransferProgress) => void
|
|
501
|
-
): Promise<void> {
|
|
502
|
-
if (this.paused) throw new Error('Upload queue is paused');
|
|
503
|
-
const task = this.requireTask(taskId);
|
|
504
|
-
await this.updateTask(taskId, { status: 'uploading', errorMessage: undefined });
|
|
505
|
-
this.emit('uploadStarted', taskId);
|
|
506
|
-
try {
|
|
507
|
-
await this.client.recordings.uploadRecordingFile(
|
|
508
|
-
this.requireTask(taskId),
|
|
509
|
-
(progress) => {
|
|
510
|
-
onProgress?.(progress);
|
|
511
|
-
const ratio =
|
|
512
|
-
progress.totalBytes > 0
|
|
513
|
-
? Math.min(progress.completedBytes / progress.totalBytes, 1)
|
|
514
|
-
: 0;
|
|
515
|
-
this.emit('uploadProgress', taskId, ratio);
|
|
516
|
-
}
|
|
517
|
-
);
|
|
518
|
-
await this.updateTask(taskId, { status: 'completed', errorMessage: undefined });
|
|
519
|
-
this.emit('uploadCompleted', taskId, task.recordingId);
|
|
520
|
-
} catch (error) {
|
|
521
|
-
const failure = error instanceof Error ? error : new Error(String(error));
|
|
522
|
-
await this.updateTask(taskId, {
|
|
523
|
-
status: 'failed',
|
|
524
|
-
errorMessage: failure.message,
|
|
525
|
-
});
|
|
526
|
-
this.emit('uploadFailed', taskId, failure);
|
|
527
|
-
throw failure;
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
527
|
private emitSyncProgress(
|
|
532
528
|
recordingUuid: string,
|
|
533
529
|
progress: SyncProgress
|
|
@@ -536,35 +532,6 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
|
|
|
536
532
|
return progress;
|
|
537
533
|
}
|
|
538
534
|
|
|
539
|
-
private requireTask(taskId: string): UploadTask {
|
|
540
|
-
const task = this.tasks.find((value) => value.id === taskId);
|
|
541
|
-
if (!task) throw new Error(`Upload task not found: ${taskId}`);
|
|
542
|
-
return task;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
private async updateTask(
|
|
546
|
-
taskId: string,
|
|
547
|
-
patch: Partial<UploadTask>
|
|
548
|
-
): Promise<void> {
|
|
549
|
-
const index = this.tasks.findIndex((task) => task.id === taskId);
|
|
550
|
-
if (index < 0) throw new Error(`Upload task not found: ${taskId}`);
|
|
551
|
-
this.tasks[index] = {
|
|
552
|
-
...this.tasks[index],
|
|
553
|
-
...patch,
|
|
554
|
-
updatedAt: new Date(),
|
|
555
|
-
};
|
|
556
|
-
await this.persistTasks();
|
|
557
|
-
this.emitQueueUpdated();
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
private async persistTasks(): Promise<void> {
|
|
561
|
-
await this.client.recordings.saveUploadQueue(this.tasks);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
private emitQueueUpdated(): void {
|
|
565
|
-
this.emit('queueUpdated', this.getAllUploads());
|
|
566
|
-
}
|
|
567
|
-
|
|
568
535
|
private assertReady(): void {
|
|
569
536
|
this.assertAlive();
|
|
570
537
|
if (!this.initialized) {
|
package/src/managers/types.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type { DeviceRecording, UploadInfo } from '../models/Recording';
|
|
1
|
+
import type { DeviceRecording, RecordingDataStore, UploadInfo, UploadRecoveryProvider } from '../models/Recording';
|
|
2
|
+
|
|
3
|
+
export interface RecordingManagerOptions {
|
|
4
|
+
uploadRecoveryProvider?: UploadRecoveryProvider;
|
|
5
|
+
/** Unsupported: recording bytes remain native-owned. Configuration throws. */
|
|
6
|
+
recordingDataStore?: RecordingDataStore;
|
|
7
|
+
}
|
|
2
8
|
|
|
3
9
|
export type UploadInfoProvider = (
|
|
4
10
|
recording: DeviceRecording
|