@pickleball/expo-sdk 0.1.1 → 0.2.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/CHANGELOG.md +24 -0
- package/README.md +69 -0
- package/dist/advanced-BXDrDl1h.d.ts +316 -0
- package/dist/advanced-BdAa2Erv.d.cts +316 -0
- package/dist/advanced.cjs +35 -0
- package/dist/advanced.d.cts +3 -0
- package/dist/advanced.d.ts +3 -0
- package/dist/advanced.js +35 -0
- package/dist/chunk-2JHULJZ7.js +265 -0
- package/dist/chunk-6JOMESNT.js +9 -0
- package/dist/chunk-7OX2DSKI.cjs +33 -0
- package/dist/chunk-DHDX3ZOO.js +800 -0
- package/dist/chunk-FODE2W2H.js +1953 -0
- package/dist/chunk-KFQGP6VL.js +33 -0
- package/dist/chunk-LCWGNGEN.cjs +265 -0
- package/dist/chunk-TBPR2QND.cjs +1953 -0
- package/dist/chunk-TRLASNPQ.cjs +9 -0
- package/dist/chunk-YGSQ25UA.cjs +800 -0
- package/dist/device-agent/index.cjs +498 -0
- package/dist/device-agent/index.d.cts +157 -0
- package/dist/device-agent/index.d.ts +157 -0
- package/dist/device-agent/index.js +498 -0
- package/dist/engine-CCSSqPbZ.d.cts +516 -0
- package/dist/engine-CCSSqPbZ.d.ts +516 -0
- package/dist/index.cjs +339 -5569
- package/dist/index.d.cts +22 -1406
- package/dist/index.d.ts +22 -1406
- package/dist/index.js +269 -5401
- package/dist/match/index.cjs +2456 -0
- package/dist/match/index.d.cts +253 -0
- package/dist/match/index.d.ts +253 -0
- package/dist/match/index.js +2456 -0
- package/dist/match/remote/index.cjs +21 -0
- package/dist/match/remote/index.d.cts +67 -0
- package/dist/match/remote/index.d.ts +67 -0
- package/dist/match/remote/index.js +21 -0
- package/dist/match/voice/index.cjs +23 -0
- package/dist/match/voice/index.d.cts +114 -0
- package/dist/match/voice/index.d.ts +114 -0
- package/dist/match/voice/index.js +23 -0
- package/dist/plugin.cjs +53 -70
- package/dist/plugin.d.cts +13 -0
- package/dist/plugin.d.ts +13 -0
- package/dist/plugin.js +20 -15
- package/dist/types-CrmcX408.d.cts +176 -0
- package/dist/types-CrmcX408.d.ts +176 -0
- package/dist/types-DiKWjwvN.d.cts +96 -0
- package/dist/types-DiKWjwvN.d.ts +96 -0
- package/dist/version.cjs +4 -32
- package/dist/version.js +2 -1
- package/package.json +68 -4
- package/src/advanced.ts +47 -0
- package/src/device-agent/agent.ts +443 -0
- package/src/device-agent/component.tsx +49 -0
- package/src/device-agent/convex-control-channel.ts +82 -0
- package/src/device-agent/http-transport.ts +143 -0
- package/src/device-agent/index.ts +4 -0
- package/src/engine.ts +21 -5
- package/src/errors.ts +16 -0
- package/src/http-session-provider.ts +19 -13
- package/src/index.ts +38 -14
- package/src/livekit-room-adapter.ts +39 -2
- package/src/livestream-screen.tsx +26 -4
- package/src/match/index.ts +29 -0
- package/src/match/provider.tsx +4 -2
- package/src/match/remote/hid-gesture.ts +10 -2
- package/src/match/remote/types.ts +6 -4
- package/src/match/voice/scheduler.ts +6 -1
- package/src/match/voice/service.ts +16 -4
- package/src/native-runtime.ts +54 -4
- package/src/plugin.ts +35 -14
- package/src/provider.tsx +35 -4
- package/src/rtmp-room-adapter.ts +196 -0
- package/dist/chunk-VHLUJQG5.js +0 -17
package/dist/version.cjs
CHANGED
|
@@ -1,35 +1,7 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __defProp = Object.defineProperty;
|
|
3
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
-
var __export = (target, all) => {
|
|
7
|
-
for (var name in all)
|
|
8
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
-
};
|
|
10
|
-
var __copyProps = (to, from, except, desc) => {
|
|
11
|
-
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
-
for (let key of __getOwnPropNames(from))
|
|
13
|
-
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
-
}
|
|
16
|
-
return to;
|
|
17
|
-
};
|
|
18
|
-
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
1
|
+
"use strict";Object.defineProperty(exports, "__esModule", {value: true});
|
|
19
2
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
__export(version_exports, {
|
|
23
|
-
PICKLEBALL_EXPO_SDK_VERSION: () => PICKLEBALL_EXPO_SDK_VERSION
|
|
24
|
-
});
|
|
25
|
-
module.exports = __toCommonJS(version_exports);
|
|
3
|
+
var _chunkTRLASNPQcjs = require('./chunk-TRLASNPQ.cjs');
|
|
4
|
+
require('./chunk-7OX2DSKI.cjs');
|
|
26
5
|
|
|
27
|
-
// package.json
|
|
28
|
-
var version = "0.1.1";
|
|
29
6
|
|
|
30
|
-
|
|
31
|
-
var PICKLEBALL_EXPO_SDK_VERSION = version;
|
|
32
|
-
// Annotate the CommonJS export names for ESM import in node:
|
|
33
|
-
0 && (module.exports = {
|
|
34
|
-
PICKLEBALL_EXPO_SDK_VERSION
|
|
35
|
-
});
|
|
7
|
+
exports.PICKLEBALL_EXPO_SDK_VERSION = _chunkTRLASNPQcjs.PICKLEBALL_EXPO_SDK_VERSION;
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pickleball/expo-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Foreground livestream engine, React hooks, UI, and Expo config plugin for Pickleball Live",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -27,6 +27,61 @@
|
|
|
27
27
|
"default": "./dist/index.cjs"
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
|
+
"./match": {
|
|
31
|
+
"react-native": "./src/match/index.ts",
|
|
32
|
+
"import": {
|
|
33
|
+
"types": "./dist/match/index.d.ts",
|
|
34
|
+
"default": "./dist/match/index.js"
|
|
35
|
+
},
|
|
36
|
+
"require": {
|
|
37
|
+
"types": "./dist/match/index.d.cts",
|
|
38
|
+
"default": "./dist/match/index.cjs"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"./match/voice": {
|
|
42
|
+
"react-native": "./src/match/voice/index.ts",
|
|
43
|
+
"import": {
|
|
44
|
+
"types": "./dist/match/voice/index.d.ts",
|
|
45
|
+
"default": "./dist/match/voice/index.js"
|
|
46
|
+
},
|
|
47
|
+
"require": {
|
|
48
|
+
"types": "./dist/match/voice/index.d.cts",
|
|
49
|
+
"default": "./dist/match/voice/index.cjs"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"./match/remote": {
|
|
53
|
+
"react-native": "./src/match/remote/index.ts",
|
|
54
|
+
"import": {
|
|
55
|
+
"types": "./dist/match/remote/index.d.ts",
|
|
56
|
+
"default": "./dist/match/remote/index.js"
|
|
57
|
+
},
|
|
58
|
+
"require": {
|
|
59
|
+
"types": "./dist/match/remote/index.d.cts",
|
|
60
|
+
"default": "./dist/match/remote/index.cjs"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"./device-agent": {
|
|
64
|
+
"react-native": "./src/device-agent/index.ts",
|
|
65
|
+
"import": {
|
|
66
|
+
"types": "./dist/device-agent/index.d.ts",
|
|
67
|
+
"default": "./dist/device-agent/index.js"
|
|
68
|
+
},
|
|
69
|
+
"require": {
|
|
70
|
+
"types": "./dist/device-agent/index.d.cts",
|
|
71
|
+
"default": "./dist/device-agent/index.cjs"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"./advanced": {
|
|
75
|
+
"react-native": "./src/advanced.ts",
|
|
76
|
+
"import": {
|
|
77
|
+
"types": "./dist/advanced.d.ts",
|
|
78
|
+
"default": "./dist/advanced.js"
|
|
79
|
+
},
|
|
80
|
+
"require": {
|
|
81
|
+
"types": "./dist/advanced.d.cts",
|
|
82
|
+
"default": "./dist/advanced.cjs"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
30
85
|
"./plugin": {
|
|
31
86
|
"import": {
|
|
32
87
|
"types": "./dist/plugin.d.ts",
|
|
@@ -37,6 +92,7 @@
|
|
|
37
92
|
"default": "./dist/plugin.cjs"
|
|
38
93
|
}
|
|
39
94
|
},
|
|
95
|
+
"./app.plugin.js": "./app.plugin.js",
|
|
40
96
|
"./version": {
|
|
41
97
|
"react-native": "./src/version.ts",
|
|
42
98
|
"import": {
|
|
@@ -68,11 +124,13 @@
|
|
|
68
124
|
"video"
|
|
69
125
|
],
|
|
70
126
|
"sideEffects": [
|
|
71
|
-
"./dist/native-runtime.*"
|
|
127
|
+
"./dist/native-runtime.*",
|
|
128
|
+
"./src/native-runtime.ts"
|
|
72
129
|
],
|
|
73
130
|
"peerDependencies": {
|
|
74
131
|
"@livekit/react-native": ">=2.11.1 <3",
|
|
75
132
|
"@livekit/react-native-webrtc": ">=144.1.1 <145",
|
|
133
|
+
"@pickleball/rtmp-native": ">=0.1.0 <1",
|
|
76
134
|
"@react-native-community/netinfo": ">=11 <13",
|
|
77
135
|
"expo": ">=56 <58",
|
|
78
136
|
"expo-audio": ">=56 <58",
|
|
@@ -86,7 +144,7 @@
|
|
|
86
144
|
},
|
|
87
145
|
"dependencies": {
|
|
88
146
|
"@livekit/react-native-expo-plugin": "1.0.2",
|
|
89
|
-
"
|
|
147
|
+
"convex": "^1.42.1",
|
|
90
148
|
"@pickleball/remote-native": "0.1.0",
|
|
91
149
|
"@pickleball/voice-native": "0.1.0"
|
|
92
150
|
},
|
|
@@ -114,10 +172,16 @@
|
|
|
114
172
|
"react-test-renderer": "19.2.3",
|
|
115
173
|
"tsup": "^8.5.0",
|
|
116
174
|
"typescript": "^5.8.0",
|
|
175
|
+
"@pickleball/rtmp-native": "0.1.0",
|
|
117
176
|
"@pickleball/shared": "0.1.0"
|
|
118
177
|
},
|
|
178
|
+
"peerDependenciesMeta": {
|
|
179
|
+
"@pickleball/rtmp-native": {
|
|
180
|
+
"optional": true
|
|
181
|
+
}
|
|
182
|
+
},
|
|
119
183
|
"scripts": {
|
|
120
|
-
"build": "tsup
|
|
184
|
+
"build": "tsup",
|
|
121
185
|
"test": "jest --runInBand",
|
|
122
186
|
"typecheck": "tsc --noEmit",
|
|
123
187
|
"verify:pack": "node scripts/verify-pack.mjs",
|
package/src/advanced.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API nâng cao: engine internals, dependency injection, adapter LiveKit và các
|
|
3
|
+
* factory runtime mặc định — dùng cho mock/test hoặc tuỳ biến sâu. App tích hợp
|
|
4
|
+
* thông thường không cần import từ đây.
|
|
5
|
+
*/
|
|
6
|
+
export {
|
|
7
|
+
LivestreamEngine,
|
|
8
|
+
LEGAL_LIVESTREAM_TRANSITIONS,
|
|
9
|
+
PENDING_END_KEY,
|
|
10
|
+
type EngineDependencies,
|
|
11
|
+
type RoomAdapter,
|
|
12
|
+
type RoomConnection,
|
|
13
|
+
type RoomEvent,
|
|
14
|
+
type SdkRuntime,
|
|
15
|
+
type SdkStorage,
|
|
16
|
+
type SdkNetwork,
|
|
17
|
+
type TelemetrySink,
|
|
18
|
+
type LivestreamSnapshot,
|
|
19
|
+
type DeviceMetricsSample,
|
|
20
|
+
type CaptureOrientation,
|
|
21
|
+
type CameraDeviceInfo,
|
|
22
|
+
} from "./engine";
|
|
23
|
+
export {
|
|
24
|
+
LiveKitRoomAdapter,
|
|
25
|
+
type NativeLiveKitBindings,
|
|
26
|
+
type LocalPreviewReference,
|
|
27
|
+
} from "./livekit-room-adapter";
|
|
28
|
+
export {
|
|
29
|
+
RtmpRoomAdapter,
|
|
30
|
+
type NativeRtmpBindings,
|
|
31
|
+
type RtmpStatus,
|
|
32
|
+
} from "./rtmp-room-adapter";
|
|
33
|
+
export {
|
|
34
|
+
createNativeLiveKitBindings,
|
|
35
|
+
createDefaultRuntime,
|
|
36
|
+
createDefaultStorage,
|
|
37
|
+
createDefaultNetwork,
|
|
38
|
+
createDefaultTelemetry,
|
|
39
|
+
createDefaultRoomFactory,
|
|
40
|
+
// Mode device-rtmp: thiết bị đẩy RTMP thẳng lên server tự host, không dùng
|
|
41
|
+
// LiveKit. Truyền vào prop roomFactory của PickleballLiveProvider để đổi mode.
|
|
42
|
+
createRtmpRoomFactory,
|
|
43
|
+
createNativeRtmpBindings,
|
|
44
|
+
createDefaultDeviceMetrics,
|
|
45
|
+
} from "./native-runtime";
|
|
46
|
+
// Context nội bộ cho module opt-in (PickleballDeviceAgent) cần engine trực tiếp
|
|
47
|
+
export { useLivestreamHost, type LivestreamHostContextValue } from "./provider";
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import type { BootstrapInput } from "../contracts";
|
|
2
|
+
import type { DeviceMetricsSample, LivestreamEngine } from "../engine";
|
|
3
|
+
import { normalizeError } from "../errors";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DeviceAgent — đăng ký thiết bị với backend khi app mở và giữ heartbeat
|
|
7
|
+
* presence (12s/lần khi foreground) qua proxy. Khi dashboard yêu cầu "Kết nối",
|
|
8
|
+
* heartbeat trả về connect-token → agent mở kênh điều khiển realtime (Convex
|
|
9
|
+
* websocket) để nhận lệnh start/stop livestream tức thì. Publish/unpublish sau
|
|
10
|
+
* khi vào standby vẫn đi đường LiveKit data channel sẵn có của engine.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface DeviceAgentDeviceInfo {
|
|
14
|
+
appVersion?: string;
|
|
15
|
+
deviceName?: string;
|
|
16
|
+
deviceModel?: string;
|
|
17
|
+
osVersion?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DeviceCommand {
|
|
21
|
+
id: string;
|
|
22
|
+
type: "start_livestream" | "stop_livestream";
|
|
23
|
+
payload: { title?: string; visibility?: "public" | "private" };
|
|
24
|
+
expiresAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DeviceControlSessionGrant {
|
|
28
|
+
id: string;
|
|
29
|
+
token: string;
|
|
30
|
+
convexUrl: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DeviceHeartbeatBody {
|
|
34
|
+
agentState?: string;
|
|
35
|
+
currentSessionId?: string | null;
|
|
36
|
+
appVersion?: string;
|
|
37
|
+
metrics?: Record<string, number | string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DeviceAgentTransport {
|
|
41
|
+
register(
|
|
42
|
+
input: BootstrapInput & DeviceAgentDeviceInfo,
|
|
43
|
+
): Promise<{ deviceId: string; heartbeatIntervalMs: number }>;
|
|
44
|
+
heartbeat(
|
|
45
|
+
input: BootstrapInput & DeviceHeartbeatBody,
|
|
46
|
+
): Promise<{ controlSession: DeviceControlSessionGrant | null }>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Kênh điều khiển realtime — mặc định là Convex websocket (convex-control-channel). */
|
|
50
|
+
export interface DeviceControlChannel {
|
|
51
|
+
attach(controlSessionId: string, token: string): Promise<void>;
|
|
52
|
+
/** Subscribe lệnh pending — callback gọi lại mỗi khi danh sách đổi. */
|
|
53
|
+
onCommands(
|
|
54
|
+
controlSessionId: string,
|
|
55
|
+
token: string,
|
|
56
|
+
callback: (commands: DeviceCommand[]) => void,
|
|
57
|
+
): () => void;
|
|
58
|
+
/** Subscribe trạng thái kênh — "ended"/"expired" thì agent tự đóng. */
|
|
59
|
+
onState(
|
|
60
|
+
controlSessionId: string,
|
|
61
|
+
token: string,
|
|
62
|
+
callback: (state: { status: string }) => void,
|
|
63
|
+
): () => void;
|
|
64
|
+
ack(input: {
|
|
65
|
+
controlSessionId: string;
|
|
66
|
+
token: string;
|
|
67
|
+
commandId: string;
|
|
68
|
+
status: "acked" | "completed" | "failed";
|
|
69
|
+
errorMessage?: string;
|
|
70
|
+
resultSessionId?: string;
|
|
71
|
+
}): Promise<void>;
|
|
72
|
+
close(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RemoteStartConfig {
|
|
76
|
+
consentVersion: string;
|
|
77
|
+
/** Tiêu đề mặc định khi dashboard không gửi title. */
|
|
78
|
+
defaultTitle?: string;
|
|
79
|
+
visibility?: "public" | "private";
|
|
80
|
+
metadata?: Record<string, string>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type AppStateListener = (state: "active" | "background" | "inactive") => void;
|
|
84
|
+
|
|
85
|
+
export interface DeviceAgentOptions {
|
|
86
|
+
engine: LivestreamEngine;
|
|
87
|
+
bootstrapInput: BootstrapInput;
|
|
88
|
+
transport: DeviceAgentTransport;
|
|
89
|
+
remoteStart: RemoteStartConfig;
|
|
90
|
+
deviceInfo?: DeviceAgentDeviceInfo;
|
|
91
|
+
onRemoteSessionStarted?: (sessionId: string) => void;
|
|
92
|
+
deviceMetrics?: () => Promise<DeviceMetricsSample | null>;
|
|
93
|
+
/** Factory kênh điều khiển — test truyền fake; mặc định Convex websocket. */
|
|
94
|
+
createControlChannel?: (convexUrl: string) => DeviceControlChannel;
|
|
95
|
+
/** Test hook: nguồn app-state; mặc định react-native AppState. */
|
|
96
|
+
subscribeAppState?: (listener: AppStateListener) => () => void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 12_000;
|
|
100
|
+
const REGISTER_RETRY_BASE_MS = 2_000;
|
|
101
|
+
const REGISTER_RETRY_MAX_MS = 60_000;
|
|
102
|
+
/** Chỉ các field server chấp nhận (proxy từ chối key lạ trong metrics). */
|
|
103
|
+
const METRIC_FIELDS = [
|
|
104
|
+
"ramFreeMb",
|
|
105
|
+
"ramTotalMb",
|
|
106
|
+
"storageFreeGb",
|
|
107
|
+
"batteryPct",
|
|
108
|
+
"batteryTempC",
|
|
109
|
+
"thermal",
|
|
110
|
+
"netType",
|
|
111
|
+
"linkMbps",
|
|
112
|
+
"rssi",
|
|
113
|
+
"connQuality",
|
|
114
|
+
] as const;
|
|
115
|
+
|
|
116
|
+
function defaultSubscribeAppState(listener: AppStateListener): () => void {
|
|
117
|
+
const { AppState } = require("react-native") as typeof import("react-native");
|
|
118
|
+
const subscription = AppState.addEventListener("change", (state) => {
|
|
119
|
+
listener(
|
|
120
|
+
state === "active" ? "active" : state === "background" ? "background" : "inactive",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
return () => subscription.remove();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function defaultCreateControlChannel(convexUrl: string): DeviceControlChannel {
|
|
127
|
+
const { createConvexControlChannel } =
|
|
128
|
+
require("./convex-control-channel") as typeof import("./convex-control-channel");
|
|
129
|
+
return createConvexControlChannel(convexUrl);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function pickMetrics(
|
|
133
|
+
sample: DeviceMetricsSample | null,
|
|
134
|
+
): Record<string, number | string> | undefined {
|
|
135
|
+
if (!sample) return undefined;
|
|
136
|
+
const metrics: Record<string, number | string> = {};
|
|
137
|
+
for (const field of METRIC_FIELDS) {
|
|
138
|
+
const value = (sample as Record<string, unknown>)[field];
|
|
139
|
+
if (typeof value === "number" && Number.isFinite(value)) metrics[field] = value;
|
|
140
|
+
else if (typeof value === "string" && value.trim() !== "") metrics[field] = value;
|
|
141
|
+
}
|
|
142
|
+
return Object.keys(metrics).length > 0 ? metrics : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class DeviceAgent {
|
|
146
|
+
private readonly options: DeviceAgentOptions;
|
|
147
|
+
private running = false;
|
|
148
|
+
private registered = false;
|
|
149
|
+
private foreground = true;
|
|
150
|
+
private heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
151
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
152
|
+
private beatInFlight = false;
|
|
153
|
+
private registerAttempt = 0;
|
|
154
|
+
private unsubscribeAppState: (() => void) | null = null;
|
|
155
|
+
|
|
156
|
+
private heartbeatFailures = 0;
|
|
157
|
+
private controlChannel: DeviceControlChannel | null = null;
|
|
158
|
+
private controlSessionId: string | null = null;
|
|
159
|
+
private controlUnsubscribers: Array<() => void> = [];
|
|
160
|
+
private executing = false;
|
|
161
|
+
private readonly handledCommandIds = new Set<string>();
|
|
162
|
+
|
|
163
|
+
constructor(options: DeviceAgentOptions) {
|
|
164
|
+
this.options = options;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
start() {
|
|
168
|
+
if (this.running) return;
|
|
169
|
+
this.running = true;
|
|
170
|
+
this.unsubscribeAppState = (
|
|
171
|
+
this.options.subscribeAppState ?? defaultSubscribeAppState
|
|
172
|
+
)((state) => this.handleAppState(state));
|
|
173
|
+
this.schedule(0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
stop() {
|
|
177
|
+
this.running = false;
|
|
178
|
+
if (this.timer) clearTimeout(this.timer);
|
|
179
|
+
this.timer = null;
|
|
180
|
+
this.unsubscribeAppState?.();
|
|
181
|
+
this.unsubscribeAppState = null;
|
|
182
|
+
this.closeControlChannel();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private handleAppState(state: "active" | "background" | "inactive") {
|
|
186
|
+
const wasForeground = this.foreground;
|
|
187
|
+
this.foreground = state === "active";
|
|
188
|
+
if (!this.running) return;
|
|
189
|
+
if (this.foreground && !wasForeground) {
|
|
190
|
+
// Về foreground: beat ngay để dashboard thấy online lại nhanh
|
|
191
|
+
this.schedule(0);
|
|
192
|
+
}
|
|
193
|
+
if (!this.foreground) {
|
|
194
|
+
if (this.timer) clearTimeout(this.timer);
|
|
195
|
+
this.timer = null;
|
|
196
|
+
// App nền không quay được — đóng kênh, server sẽ end control-session
|
|
197
|
+
this.closeControlChannel();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private schedule(delayMs: number) {
|
|
202
|
+
if (!this.running || !this.foreground) return;
|
|
203
|
+
if (this.timer) clearTimeout(this.timer);
|
|
204
|
+
this.timer = setTimeout(() => {
|
|
205
|
+
void this.beat();
|
|
206
|
+
}, delayMs);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async beat() {
|
|
210
|
+
if (!this.running || !this.foreground || this.beatInFlight) return;
|
|
211
|
+
this.beatInFlight = true;
|
|
212
|
+
try {
|
|
213
|
+
if (!this.registered) {
|
|
214
|
+
await this.register();
|
|
215
|
+
if (!this.registered) return; // register() đã schedule retry
|
|
216
|
+
}
|
|
217
|
+
await this.heartbeat();
|
|
218
|
+
this.schedule(this.heartbeatIntervalMs);
|
|
219
|
+
} finally {
|
|
220
|
+
this.beatInFlight = false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async register() {
|
|
225
|
+
try {
|
|
226
|
+
const result = await this.options.transport.register({
|
|
227
|
+
...this.options.bootstrapInput,
|
|
228
|
+
...this.options.deviceInfo,
|
|
229
|
+
});
|
|
230
|
+
if (result.heartbeatIntervalMs > 0) {
|
|
231
|
+
this.heartbeatIntervalMs = result.heartbeatIntervalMs;
|
|
232
|
+
}
|
|
233
|
+
this.registered = true;
|
|
234
|
+
this.registerAttempt = 0;
|
|
235
|
+
} catch {
|
|
236
|
+
// Mạng lỗi/proxy chưa sẵn sàng — backoff rồi thử lại
|
|
237
|
+
const delay = Math.min(
|
|
238
|
+
REGISTER_RETRY_BASE_MS * 2 ** this.registerAttempt,
|
|
239
|
+
REGISTER_RETRY_MAX_MS,
|
|
240
|
+
);
|
|
241
|
+
this.registerAttempt += 1;
|
|
242
|
+
this.schedule(delay);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async heartbeat() {
|
|
247
|
+
const snapshot = this.options.engine.getSnapshot();
|
|
248
|
+
let metrics: Record<string, number | string> | undefined;
|
|
249
|
+
try {
|
|
250
|
+
metrics = pickMetrics((await this.options.deviceMetrics?.()) ?? null);
|
|
251
|
+
} catch {
|
|
252
|
+
metrics = undefined;
|
|
253
|
+
}
|
|
254
|
+
let result: { controlSession: DeviceControlSessionGrant | null };
|
|
255
|
+
try {
|
|
256
|
+
result = await this.options.transport.heartbeat({
|
|
257
|
+
...this.options.bootstrapInput,
|
|
258
|
+
agentState: snapshot.state,
|
|
259
|
+
currentSessionId: snapshot.sessionId,
|
|
260
|
+
...(this.options.deviceInfo?.appVersion
|
|
261
|
+
? { appVersion: this.options.deviceInfo.appVersion }
|
|
262
|
+
: {}),
|
|
263
|
+
...(metrics ? { metrics } : {}),
|
|
264
|
+
});
|
|
265
|
+
} catch {
|
|
266
|
+
// Lỗi liên tiếp (mạng, hoặc server đã xoá device) → thử re-register.
|
|
267
|
+
// Register là upsert idempotent nên gọi lại vô hại.
|
|
268
|
+
this.heartbeatFailures += 1;
|
|
269
|
+
if (this.heartbeatFailures >= 3) {
|
|
270
|
+
this.registered = false;
|
|
271
|
+
this.heartbeatFailures = 0;
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.heartbeatFailures = 0;
|
|
276
|
+
if (result.controlSession && !this.controlChannel) {
|
|
277
|
+
this.openControlChannel(result.controlSession);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private openControlChannel(grant: DeviceControlSessionGrant) {
|
|
282
|
+
let channel: DeviceControlChannel;
|
|
283
|
+
try {
|
|
284
|
+
channel = (this.options.createControlChannel ?? defaultCreateControlChannel)(
|
|
285
|
+
grant.convexUrl,
|
|
286
|
+
);
|
|
287
|
+
} catch {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
this.controlChannel = channel;
|
|
291
|
+
this.controlSessionId = grant.id;
|
|
292
|
+
void channel
|
|
293
|
+
.attach(grant.id, grant.token)
|
|
294
|
+
.then(() => {
|
|
295
|
+
if (this.controlChannel !== channel) return;
|
|
296
|
+
this.controlUnsubscribers.push(
|
|
297
|
+
channel.onCommands(grant.id, grant.token, (commands) => {
|
|
298
|
+
void this.executeCommands(channel, grant, commands);
|
|
299
|
+
}),
|
|
300
|
+
channel.onState(grant.id, grant.token, (state) => {
|
|
301
|
+
if (state.status === "ended" || state.status === "expired") {
|
|
302
|
+
this.closeControlChannel();
|
|
303
|
+
}
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
})
|
|
307
|
+
.catch(() => {
|
|
308
|
+
if (this.controlChannel === channel) this.closeControlChannel();
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private closeControlChannel() {
|
|
313
|
+
for (const unsubscribe of this.controlUnsubscribers) {
|
|
314
|
+
try {
|
|
315
|
+
unsubscribe();
|
|
316
|
+
} catch {
|
|
317
|
+
/* kênh đã đóng */
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
this.controlUnsubscribers = [];
|
|
321
|
+
try {
|
|
322
|
+
this.controlChannel?.close();
|
|
323
|
+
} catch {
|
|
324
|
+
/* kênh đã đóng */
|
|
325
|
+
}
|
|
326
|
+
this.controlChannel = null;
|
|
327
|
+
this.controlSessionId = null;
|
|
328
|
+
this.handledCommandIds.clear();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private async executeCommands(
|
|
332
|
+
channel: DeviceControlChannel,
|
|
333
|
+
grant: DeviceControlSessionGrant,
|
|
334
|
+
commands: DeviceCommand[],
|
|
335
|
+
) {
|
|
336
|
+
for (const command of commands) {
|
|
337
|
+
if (this.controlChannel !== channel) return;
|
|
338
|
+
if (this.handledCommandIds.has(command.id)) continue;
|
|
339
|
+
this.handledCommandIds.add(command.id);
|
|
340
|
+
if (this.executing) {
|
|
341
|
+
// Single-flight: server cũng dedupe, nhưng phòng lệnh dồn trong 1 batch
|
|
342
|
+
await this.safeAck(channel, grant, command.id, "failed", "Thiết bị đang bận");
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
this.executing = true;
|
|
346
|
+
try {
|
|
347
|
+
await this.executeCommand(channel, grant, command);
|
|
348
|
+
} finally {
|
|
349
|
+
this.executing = false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private async executeCommand(
|
|
355
|
+
channel: DeviceControlChannel,
|
|
356
|
+
grant: DeviceControlSessionGrant,
|
|
357
|
+
command: DeviceCommand,
|
|
358
|
+
) {
|
|
359
|
+
if (command.expiresAt <= Date.now()) {
|
|
360
|
+
await this.safeAck(channel, grant, command.id, "failed", "Lệnh đã hết hạn");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
await this.safeAck(channel, grant, command.id, "acked");
|
|
364
|
+
try {
|
|
365
|
+
if (command.type === "start_livestream") {
|
|
366
|
+
await this.runStartLivestream(command);
|
|
367
|
+
const sessionId = this.options.engine.getSnapshot().sessionId;
|
|
368
|
+
await this.safeAck(
|
|
369
|
+
channel,
|
|
370
|
+
grant,
|
|
371
|
+
command.id,
|
|
372
|
+
"completed",
|
|
373
|
+
undefined,
|
|
374
|
+
sessionId ?? undefined,
|
|
375
|
+
);
|
|
376
|
+
if (sessionId) this.options.onRemoteSessionStarted?.(sessionId);
|
|
377
|
+
} else {
|
|
378
|
+
await this.runStopLivestream();
|
|
379
|
+
await this.safeAck(channel, grant, command.id, "completed");
|
|
380
|
+
}
|
|
381
|
+
} catch (error) {
|
|
382
|
+
await this.safeAck(
|
|
383
|
+
channel,
|
|
384
|
+
grant,
|
|
385
|
+
command.id,
|
|
386
|
+
"failed",
|
|
387
|
+
normalizeError(error).message,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private async runStartLivestream(command: DeviceCommand) {
|
|
393
|
+
const engine = this.options.engine;
|
|
394
|
+
const state = engine.getSnapshot().state;
|
|
395
|
+
if (state !== "idle" && state !== "ended" && state !== "preview") {
|
|
396
|
+
throw new Error(`Thiết bị không sẵn sàng (trạng thái ${state})`);
|
|
397
|
+
}
|
|
398
|
+
if (state !== "preview") {
|
|
399
|
+
await engine.prepare();
|
|
400
|
+
}
|
|
401
|
+
const remote = this.options.remoteStart;
|
|
402
|
+
await engine.start(
|
|
403
|
+
{
|
|
404
|
+
externalSessionId: `remote-${command.id}`,
|
|
405
|
+
title: command.payload.title?.trim() || remote.defaultTitle || "Livestream",
|
|
406
|
+
visibility: command.payload.visibility ?? remote.visibility ?? "public",
|
|
407
|
+
consentVersion: remote.consentVersion,
|
|
408
|
+
metadata: { ...remote.metadata, deviceCommandId: command.id },
|
|
409
|
+
standby: true,
|
|
410
|
+
},
|
|
411
|
+
{ autoPublish: false },
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private async runStopLivestream() {
|
|
416
|
+
const engine = this.options.engine;
|
|
417
|
+
if (engine.getSnapshot().sessionId) {
|
|
418
|
+
await engine.stop("user");
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private async safeAck(
|
|
423
|
+
channel: DeviceControlChannel,
|
|
424
|
+
grant: DeviceControlSessionGrant,
|
|
425
|
+
commandId: string,
|
|
426
|
+
status: "acked" | "completed" | "failed",
|
|
427
|
+
errorMessage?: string,
|
|
428
|
+
resultSessionId?: string,
|
|
429
|
+
) {
|
|
430
|
+
try {
|
|
431
|
+
await channel.ack({
|
|
432
|
+
controlSessionId: grant.id,
|
|
433
|
+
token: grant.token,
|
|
434
|
+
commandId,
|
|
435
|
+
status,
|
|
436
|
+
...(errorMessage !== undefined ? { errorMessage } : {}),
|
|
437
|
+
...(resultSessionId !== undefined ? { resultSessionId } : {}),
|
|
438
|
+
});
|
|
439
|
+
} catch {
|
|
440
|
+
/* mất kênh giữa chừng — server cron sẽ expire lệnh */
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|