@aiscene/aiserver 2.0.2 → 2.0.4
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/dist/api/device-api.d.ts.map +1 -1
- package/dist/api/device-api.js +2 -0
- package/dist/api/device-api.js.map +1 -1
- package/dist/core/types.d.ts +6 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/debug/websocket-server.d.ts.map +1 -1
- package/dist/debug/websocket-server.js +35 -23
- package/dist/debug/websocket-server.js.map +1 -1
- package/dist/device/detector.d.ts +2 -0
- package/dist/device/detector.d.ts.map +1 -1
- package/dist/device/detector.js +70 -3
- package/dist/device/detector.js.map +1 -1
- package/dist/device/heartbeat.d.ts.map +1 -1
- package/dist/device/heartbeat.js +14 -2
- package/dist/device/heartbeat.js.map +1 -1
- package/dist/device/types.d.ts +3 -0
- package/dist/device/types.d.ts.map +1 -1
- package/dist/executor/ios-executor.d.ts.map +1 -1
- package/dist/executor/ios-executor.js +29 -15
- package/dist/executor/ios-executor.js.map +1 -1
- package/dist/executor/ios-wda-manager.d.ts +21 -0
- package/dist/executor/ios-wda-manager.d.ts.map +1 -0
- package/dist/executor/ios-wda-manager.js +333 -0
- package/dist/executor/ios-wda-manager.js.map +1 -0
- package/dist/scrcpy/preview-status.js +2 -2
- package/dist/scrcpy/preview-status.js.map +1 -1
- package/dist/scrcpy/server.d.ts +14 -0
- package/dist/scrcpy/server.d.ts.map +1 -1
- package/dist/scrcpy/server.js +472 -15
- package/dist/scrcpy/server.js.map +1 -1
- package/dist/storage/repositories/device-repo.d.ts.map +1 -1
- package/dist/storage/repositories/device-repo.js +1 -0
- package/dist/storage/repositories/device-repo.js.map +1 -1
- package/dist/task/guada-online-batch-poller.d.ts +10 -0
- package/dist/task/guada-online-batch-poller.d.ts.map +1 -1
- package/dist/task/guada-online-batch-poller.js +137 -8
- package/dist/task/guada-online-batch-poller.js.map +1 -1
- package/dist/task/scheduler.d.ts.map +1 -1
- package/dist/task/scheduler.js +11 -6
- package/dist/task/scheduler.js.map +1 -1
- package/package.json +1 -1
package/dist/scrcpy/server.js
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
* (视频流走二进制帧,不走 JSON)
|
|
49
49
|
*/
|
|
50
50
|
import { createReadStream, existsSync } from 'node:fs';
|
|
51
|
-
import { createServer } from 'node:http';
|
|
51
|
+
import { createServer, get as httpGet, } from 'node:http';
|
|
52
52
|
import path from 'node:path';
|
|
53
53
|
import { createRequire } from 'node:module';
|
|
54
54
|
import { randomUUID } from 'node:crypto';
|
|
@@ -61,6 +61,8 @@ import { withTimeout } from './timeout.js';
|
|
|
61
61
|
import { androidRecordingManager } from '../recorder/android-recording-manager.js';
|
|
62
62
|
import { replayAndroidLoginScript } from '../recorder/android-login-replay.js';
|
|
63
63
|
import { captureAndroidElementState, captureAndroidScreenshotState, getAndroidScreenSize, runAdb, } from '../recorder/android-ui-hierarchy.js';
|
|
64
|
+
import { deviceDetector } from '../device/detector.js';
|
|
65
|
+
import { ensureIOSWda } from '../executor/ios-wda-manager.js';
|
|
64
66
|
const logger = createLogger('ScrcpyServer');
|
|
65
67
|
const requireCjs = createRequire(import.meta.url);
|
|
66
68
|
function resolveRequestedDeviceId(options, currentDeviceId) {
|
|
@@ -225,6 +227,7 @@ export class ScrcpyServer {
|
|
|
225
227
|
id: d.serial,
|
|
226
228
|
name: d.product || d.model || d.serial,
|
|
227
229
|
status: d.state || 'device',
|
|
230
|
+
platform: 'android',
|
|
228
231
|
}));
|
|
229
232
|
}
|
|
230
233
|
catch (error) {
|
|
@@ -232,6 +235,22 @@ export class ScrcpyServer {
|
|
|
232
235
|
return [];
|
|
233
236
|
}
|
|
234
237
|
}
|
|
238
|
+
async loadDevicesList() {
|
|
239
|
+
const [androidDevices, iosDevices] = await Promise.all([
|
|
240
|
+
this.loadDevicesListFromAdb(),
|
|
241
|
+
deviceDetector.detectIOSDevices().catch((error) => {
|
|
242
|
+
logger.debug(`Failed to detect iOS devices for projection: ${error.message}`);
|
|
243
|
+
return [];
|
|
244
|
+
}),
|
|
245
|
+
]);
|
|
246
|
+
const listedIOSDevices = iosDevices.map((device) => ({
|
|
247
|
+
id: device.udid,
|
|
248
|
+
name: device.name || device.model || device.udid,
|
|
249
|
+
status: device.status || 'device',
|
|
250
|
+
platform: 'ios',
|
|
251
|
+
}));
|
|
252
|
+
return [...androidDevices, ...listedIOSDevices];
|
|
253
|
+
}
|
|
235
254
|
async getDevicesList(forceRefresh = false) {
|
|
236
255
|
const now = Date.now();
|
|
237
256
|
const cacheTtlMs = 2000;
|
|
@@ -243,7 +262,7 @@ export class ScrcpyServer {
|
|
|
243
262
|
if (this.deviceListInFlight) {
|
|
244
263
|
return this.deviceListInFlight;
|
|
245
264
|
}
|
|
246
|
-
this.deviceListInFlight = this.
|
|
265
|
+
this.deviceListInFlight = this.loadDevicesList()
|
|
247
266
|
.then((devices) => {
|
|
248
267
|
this.lastDeviceList = devices;
|
|
249
268
|
this.lastDeviceListAt = Date.now();
|
|
@@ -443,6 +462,8 @@ export class ScrcpyServer {
|
|
|
443
462
|
videoWidth: 0,
|
|
444
463
|
videoHeight: 0,
|
|
445
464
|
streamReader: null,
|
|
465
|
+
platform: null,
|
|
466
|
+
iosProjection: null,
|
|
446
467
|
};
|
|
447
468
|
this.sessions.set(ws, state);
|
|
448
469
|
this.attachSocketHandlers(state);
|
|
@@ -490,8 +511,324 @@ export class ScrcpyServer {
|
|
|
490
511
|
}
|
|
491
512
|
state.scrcpyClient = null;
|
|
492
513
|
}
|
|
514
|
+
if (state.iosProjection) {
|
|
515
|
+
const projection = state.iosProjection;
|
|
516
|
+
projection.stopped = true;
|
|
517
|
+
try {
|
|
518
|
+
projection.cleanup?.();
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
// ignore
|
|
522
|
+
}
|
|
523
|
+
await this.deleteIOSWdaSession(projection);
|
|
524
|
+
state.iosProjection = null;
|
|
525
|
+
}
|
|
493
526
|
state.videoWidth = 0;
|
|
494
527
|
state.videoHeight = 0;
|
|
528
|
+
state.platform = null;
|
|
529
|
+
}
|
|
530
|
+
async connectIOSDeviceForSession(state, deviceId, emitStatus) {
|
|
531
|
+
const { ws } = state;
|
|
532
|
+
emitStatus('starting-service');
|
|
533
|
+
const wda = await ensureIOSWda({
|
|
534
|
+
udid: deviceId,
|
|
535
|
+
useXcodebuild: false,
|
|
536
|
+
log: (message) => logger.info(`[iOSProjection] ${message}`),
|
|
537
|
+
});
|
|
538
|
+
if (!this.isSessionOpen(state))
|
|
539
|
+
return;
|
|
540
|
+
const wdaSession = await this.createIOSWdaSession(wda.host, wda.port);
|
|
541
|
+
const projection = {
|
|
542
|
+
stopped: false,
|
|
543
|
+
wdaHost: wda.host,
|
|
544
|
+
wdaPort: wda.port,
|
|
545
|
+
sessionId: wdaSession.sessionId,
|
|
546
|
+
controlWidth: wdaSession.width,
|
|
547
|
+
controlHeight: wdaSession.height,
|
|
548
|
+
};
|
|
549
|
+
state.iosProjection = projection;
|
|
550
|
+
state.platform = 'ios';
|
|
551
|
+
state.deviceId = deviceId;
|
|
552
|
+
this.currentDeviceId = deviceId;
|
|
553
|
+
emitStatus('waiting-for-video');
|
|
554
|
+
logger.info(`iOS projection started for device=${deviceId},wda=http://${wda.host}:${wda.port},mjpeg=http://${wda.host}:${wda.mjpegPort},client=${state.id}`);
|
|
555
|
+
const initialFrame = await this.captureIOSWdaScreenshot(wda.host, wda.port);
|
|
556
|
+
if (!this.isSessionOpen(state) || state.iosProjection !== projection || projection.stopped)
|
|
557
|
+
return;
|
|
558
|
+
state.videoWidth = initialFrame.width;
|
|
559
|
+
state.videoHeight = initialFrame.height;
|
|
560
|
+
wsSend(ws, 'video-metadata', {
|
|
561
|
+
codec: 'mjpeg',
|
|
562
|
+
width: initialFrame.width,
|
|
563
|
+
height: initialFrame.height,
|
|
564
|
+
platform: 'ios',
|
|
565
|
+
});
|
|
566
|
+
this.sendIOSImageFrame(ws, {
|
|
567
|
+
...initialFrame,
|
|
568
|
+
mime: 'image/png',
|
|
569
|
+
timestamp: Date.now(),
|
|
570
|
+
source: 'screenshot',
|
|
571
|
+
});
|
|
572
|
+
const startScreenshotFallback = () => {
|
|
573
|
+
void this.startIOSWdaScreenshotLoop(state, projection, wda.host, wda.port);
|
|
574
|
+
};
|
|
575
|
+
const nativeStarted = await this.startIOSNativeMjpegStream(state, projection, wda.host, wda.mjpegPort, startScreenshotFallback);
|
|
576
|
+
if (!nativeStarted) {
|
|
577
|
+
logger.info(`iOS native MJPEG unavailable, falling back to WDA screenshots: device=${deviceId}`);
|
|
578
|
+
startScreenshotFallback();
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
sendIOSImageFrame(ws, frame) {
|
|
582
|
+
wsSend(ws, 'ios-screenshot-frame', frame);
|
|
583
|
+
}
|
|
584
|
+
async createIOSWdaSession(host, port) {
|
|
585
|
+
let sessionId = null;
|
|
586
|
+
try {
|
|
587
|
+
const response = await this.iosWdaRequest(host, port, 'POST', '/session', {
|
|
588
|
+
capabilities: {
|
|
589
|
+
alwaysMatch: {
|
|
590
|
+
platformName: 'iOS',
|
|
591
|
+
automationName: 'XCUITest',
|
|
592
|
+
shouldUseSingletonTestManager: false,
|
|
593
|
+
shouldUseTestManagerForVisibilityDetection: false,
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
sessionId = this.extractIOSWdaSessionId(response);
|
|
598
|
+
if (sessionId) {
|
|
599
|
+
logger.info(`iOS WDA session created for projection: ${sessionId}`);
|
|
600
|
+
await this.configureIOSWdaSession(host, port, sessionId);
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
logger.warn('iOS WDA session response did not include sessionId; touch control will be disabled');
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
logger.warn(`iOS WDA session creation failed, projection will be view-only: ${error.message}`);
|
|
608
|
+
}
|
|
609
|
+
const size = sessionId
|
|
610
|
+
? await this.getIOSWdaWindowSize(host, port, sessionId).catch((error) => {
|
|
611
|
+
logger.warn(`iOS WDA window size failed, using screenshot size for touch mapping: ${error.message}`);
|
|
612
|
+
return { width: 0, height: 0 };
|
|
613
|
+
})
|
|
614
|
+
: { width: 0, height: 0 };
|
|
615
|
+
return { sessionId, width: size.width, height: size.height };
|
|
616
|
+
}
|
|
617
|
+
async configureIOSWdaSession(host, port, sessionId) {
|
|
618
|
+
try {
|
|
619
|
+
await this.iosWdaRequest(host, port, 'POST', `/session/${sessionId}/appium/settings`, {
|
|
620
|
+
snapshotMaxDepth: 50,
|
|
621
|
+
elementResponseAttributes: 'type,label,name,value,rect,enabled,visible',
|
|
622
|
+
mjpegServerScreenshotQuality: 50,
|
|
623
|
+
mjpegServerFramerate: 30,
|
|
624
|
+
mjpegScalingFactor: 50,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
catch (error) {
|
|
628
|
+
logger.debug(`iOS WDA settings update skipped: ${error.message}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
async getIOSWdaWindowSize(host, port, sessionId) {
|
|
632
|
+
const response = await this.iosWdaRequest(host, port, 'GET', `/session/${sessionId}/window/size`);
|
|
633
|
+
const value = response?.value || response;
|
|
634
|
+
const width = Number(value?.width);
|
|
635
|
+
const height = Number(value?.height);
|
|
636
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
637
|
+
throw new Error(`invalid WDA window size: ${JSON.stringify(response)}`);
|
|
638
|
+
}
|
|
639
|
+
return { width, height };
|
|
640
|
+
}
|
|
641
|
+
extractIOSWdaSessionId(response) {
|
|
642
|
+
const candidates = [
|
|
643
|
+
response?.sessionId,
|
|
644
|
+
response?.value?.sessionId,
|
|
645
|
+
response?.value?.session_id,
|
|
646
|
+
];
|
|
647
|
+
const matched = candidates.find((item) => typeof item === 'string' && item.trim());
|
|
648
|
+
return matched ? matched.trim() : null;
|
|
649
|
+
}
|
|
650
|
+
async iosWdaRequest(host, port, method, endpoint, body) {
|
|
651
|
+
const controller = new AbortController();
|
|
652
|
+
const timer = setTimeout(() => controller.abort(), 8000);
|
|
653
|
+
try {
|
|
654
|
+
const response = await fetch(`http://${host}:${port}${endpoint}`, {
|
|
655
|
+
method,
|
|
656
|
+
signal: controller.signal,
|
|
657
|
+
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
|
658
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
659
|
+
});
|
|
660
|
+
const text = await response.text();
|
|
661
|
+
const data = text ? JSON.parse(text) : {};
|
|
662
|
+
if (!response.ok) {
|
|
663
|
+
throw new Error(`WDA ${method} ${endpoint} HTTP ${response.status}: ${text}`);
|
|
664
|
+
}
|
|
665
|
+
return data;
|
|
666
|
+
}
|
|
667
|
+
finally {
|
|
668
|
+
clearTimeout(timer);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
async deleteIOSWdaSession(projection) {
|
|
672
|
+
if (!projection.sessionId)
|
|
673
|
+
return;
|
|
674
|
+
try {
|
|
675
|
+
await this.iosWdaRequest(projection.wdaHost, projection.wdaPort, 'DELETE', `/session/${projection.sessionId}`);
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
logger.debug(`delete iOS WDA session skipped: ${error.message}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
async startIOSWdaScreenshotLoop(state, projection, host, port) {
|
|
682
|
+
const { ws } = state;
|
|
683
|
+
try {
|
|
684
|
+
while (this.isSessionOpen(state) && state.iosProjection === projection && !projection.stopped) {
|
|
685
|
+
try {
|
|
686
|
+
const frame = await this.captureIOSWdaScreenshot(host, port);
|
|
687
|
+
this.sendIOSImageFrame(ws, {
|
|
688
|
+
...frame,
|
|
689
|
+
mime: 'image/png',
|
|
690
|
+
timestamp: Date.now(),
|
|
691
|
+
source: 'screenshot',
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
logger.warn(`iOS screenshot frame failed: ${error.message}`);
|
|
696
|
+
wsSend(ws, 'error', { message: `iOS投屏截图失败:${error.message}` });
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
finally {
|
|
703
|
+
if (state.iosProjection === projection) {
|
|
704
|
+
state.iosProjection = null;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
startIOSNativeMjpegStream(state, projection, host, port, onFallback) {
|
|
709
|
+
const url = `http://${host}:${port}`;
|
|
710
|
+
const { ws } = state;
|
|
711
|
+
return new Promise((resolve) => {
|
|
712
|
+
let settled = false;
|
|
713
|
+
let sentFrames = 0;
|
|
714
|
+
let buffer = Buffer.alloc(0);
|
|
715
|
+
let startupTimer = null;
|
|
716
|
+
const settle = (started) => {
|
|
717
|
+
if (settled)
|
|
718
|
+
return;
|
|
719
|
+
settled = true;
|
|
720
|
+
if (startupTimer)
|
|
721
|
+
clearTimeout(startupTimer);
|
|
722
|
+
resolve(started);
|
|
723
|
+
};
|
|
724
|
+
const req = httpGet(url, (res) => {
|
|
725
|
+
const statusCode = res.statusCode || 0;
|
|
726
|
+
if (statusCode >= 400) {
|
|
727
|
+
res.resume();
|
|
728
|
+
settle(false);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
res.on('data', (chunk) => {
|
|
732
|
+
if (!this.isSessionOpen(state) || state.iosProjection !== projection || projection.stopped) {
|
|
733
|
+
req.destroy();
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
737
|
+
if (buffer.length > 8 * 1024 * 1024) {
|
|
738
|
+
const lastStart = buffer.lastIndexOf(Buffer.from([0xff, 0xd8]));
|
|
739
|
+
buffer = lastStart >= 0 ? buffer.subarray(lastStart) : Buffer.alloc(0);
|
|
740
|
+
}
|
|
741
|
+
while (true) {
|
|
742
|
+
const start = buffer.indexOf(Buffer.from([0xff, 0xd8]));
|
|
743
|
+
if (start < 0) {
|
|
744
|
+
if (buffer.length > 1024 * 1024)
|
|
745
|
+
buffer = Buffer.alloc(0);
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
const end = buffer.indexOf(Buffer.from([0xff, 0xd9]), start + 2);
|
|
749
|
+
if (end < 0) {
|
|
750
|
+
if (start > 0)
|
|
751
|
+
buffer = buffer.subarray(start);
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
const jpeg = buffer.subarray(start, end + 2);
|
|
755
|
+
buffer = buffer.subarray(end + 2);
|
|
756
|
+
sentFrames += 1;
|
|
757
|
+
if (sentFrames === 1) {
|
|
758
|
+
logger.info(`iOS projection streaming via native WDA MJPEG: ${url}`);
|
|
759
|
+
settle(true);
|
|
760
|
+
}
|
|
761
|
+
this.sendIOSImageFrame(ws, {
|
|
762
|
+
mime: 'image/jpeg',
|
|
763
|
+
base64: jpeg.toString('base64'),
|
|
764
|
+
width: state.videoWidth,
|
|
765
|
+
height: state.videoHeight,
|
|
766
|
+
timestamp: Date.now(),
|
|
767
|
+
source: 'mjpeg',
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
res.on('end', () => {
|
|
772
|
+
if (sentFrames === 0) {
|
|
773
|
+
settle(false);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (this.isSessionOpen(state) && state.iosProjection === projection && !projection.stopped) {
|
|
777
|
+
logger.warn(`iOS native MJPEG ended, falling back to WDA screenshots: ${url}`);
|
|
778
|
+
onFallback();
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
startupTimer = setTimeout(() => {
|
|
783
|
+
req.destroy();
|
|
784
|
+
settle(false);
|
|
785
|
+
}, 1500);
|
|
786
|
+
projection.cleanup = () => req.destroy();
|
|
787
|
+
req.on('error', (error) => {
|
|
788
|
+
if (sentFrames === 0) {
|
|
789
|
+
logger.debug(`iOS native MJPEG unavailable: ${error.message}`);
|
|
790
|
+
settle(false);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (this.isSessionOpen(state) && state.iosProjection === projection && !projection.stopped) {
|
|
794
|
+
logger.warn(`iOS native MJPEG failed, falling back to WDA screenshots: ${error.message}`);
|
|
795
|
+
onFallback();
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
async captureIOSWdaScreenshot(host, port) {
|
|
801
|
+
const controller = new AbortController();
|
|
802
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
803
|
+
try {
|
|
804
|
+
const response = await fetch(`http://${host}:${port}/screenshot`, { signal: controller.signal });
|
|
805
|
+
if (!response.ok) {
|
|
806
|
+
throw new Error(`WDA screenshot HTTP ${response.status}`);
|
|
807
|
+
}
|
|
808
|
+
const body = await response.json();
|
|
809
|
+
const raw = String(body.value || '').replace(/^data:image\/png;base64,/, '');
|
|
810
|
+
if (!raw)
|
|
811
|
+
throw new Error('empty WDA screenshot');
|
|
812
|
+
const buffer = Buffer.from(raw, 'base64');
|
|
813
|
+
const size = this.readPngSize(buffer);
|
|
814
|
+
return { base64: raw, ...size };
|
|
815
|
+
}
|
|
816
|
+
finally {
|
|
817
|
+
clearTimeout(timer);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
readPngSize(buffer) {
|
|
821
|
+
const isPng = buffer.length >= 24 &&
|
|
822
|
+
buffer[0] === 0x89 &&
|
|
823
|
+
buffer[1] === 0x50 &&
|
|
824
|
+
buffer[2] === 0x4e &&
|
|
825
|
+
buffer[3] === 0x47;
|
|
826
|
+
if (!isPng)
|
|
827
|
+
return { width: 0, height: 0 };
|
|
828
|
+
return {
|
|
829
|
+
width: buffer.readUInt32BE(16),
|
|
830
|
+
height: buffer.readUInt32BE(20),
|
|
831
|
+
};
|
|
495
832
|
}
|
|
496
833
|
async connectDeviceForSession(state, options = {}, emitStatus) {
|
|
497
834
|
const { ws } = state;
|
|
@@ -500,16 +837,16 @@ export class ScrcpyServer {
|
|
|
500
837
|
try {
|
|
501
838
|
emitStatus('connecting-device');
|
|
502
839
|
// 重复 connect-device 时,先释放旧资源避免 scrcpy 进程残留。
|
|
503
|
-
if (state.scrcpyClient) {
|
|
840
|
+
if (state.scrcpyClient || state.iosProjection) {
|
|
504
841
|
await this.releaseSession(state);
|
|
505
842
|
}
|
|
843
|
+
if (!this.isSessionOpen(state))
|
|
844
|
+
return;
|
|
845
|
+
let devices = await this.getDevicesList();
|
|
506
846
|
if (!this.isSessionOpen(state))
|
|
507
847
|
return;
|
|
508
848
|
let targetDeviceId = resolveRequestedDeviceId(safeOptions, this.currentDeviceId);
|
|
509
849
|
if (!targetDeviceId) {
|
|
510
|
-
const devices = await this.getDevicesList();
|
|
511
|
-
if (!this.isSessionOpen(state))
|
|
512
|
-
return;
|
|
513
850
|
targetDeviceId = this.selectAvailableDeviceId(devices) || undefined;
|
|
514
851
|
}
|
|
515
852
|
if (!targetDeviceId) {
|
|
@@ -533,6 +870,13 @@ export class ScrcpyServer {
|
|
|
533
870
|
this.deviceConnectLocks.set(connectLockKey, state.id);
|
|
534
871
|
this.currentDeviceId = targetDeviceId;
|
|
535
872
|
state.deviceId = targetDeviceId;
|
|
873
|
+
const targetDevice = devices.find((device) => device.id === targetDeviceId);
|
|
874
|
+
const targetPlatform = targetDevice?.platform === 'ios' ? 'ios' : 'android';
|
|
875
|
+
state.platform = targetPlatform;
|
|
876
|
+
if (targetPlatform === 'ios') {
|
|
877
|
+
await this.connectIOSDeviceForSession(state, targetDeviceId, emitStatus);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
536
880
|
state.adb = await this.getAdb(targetDeviceId);
|
|
537
881
|
if (!this.isSessionOpen(state)) {
|
|
538
882
|
await this.releaseSession(state);
|
|
@@ -740,6 +1084,89 @@ export class ScrcpyServer {
|
|
|
740
1084
|
metaState: 0,
|
|
741
1085
|
});
|
|
742
1086
|
};
|
|
1087
|
+
const normalizeControlPoint = (raw, width, height) => {
|
|
1088
|
+
let x = Number(raw.x);
|
|
1089
|
+
let y = Number(raw.y);
|
|
1090
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
1091
|
+
return null;
|
|
1092
|
+
if (raw.normalized === true) {
|
|
1093
|
+
x = Math.max(0, Math.min(1, x)) * width;
|
|
1094
|
+
y = Math.max(0, Math.min(1, y)) * height;
|
|
1095
|
+
}
|
|
1096
|
+
return {
|
|
1097
|
+
x: Math.round(Math.max(0, Math.min(width - 1, x))),
|
|
1098
|
+
y: Math.round(Math.max(0, Math.min(height - 1, y))),
|
|
1099
|
+
};
|
|
1100
|
+
};
|
|
1101
|
+
const ensureIOSProjection = () => {
|
|
1102
|
+
const projection = state.iosProjection;
|
|
1103
|
+
if (!projection || !projection.sessionId) {
|
|
1104
|
+
logger.debug('iOS WDA session not ready, ignoring control event');
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
return projection;
|
|
1108
|
+
};
|
|
1109
|
+
const handleIOSTouch = async (raw = {}) => {
|
|
1110
|
+
const projection = ensureIOSProjection();
|
|
1111
|
+
if (!projection)
|
|
1112
|
+
return;
|
|
1113
|
+
const width = projection.controlWidth || state.videoWidth || 390;
|
|
1114
|
+
const height = projection.controlHeight || state.videoHeight || 844;
|
|
1115
|
+
const point = normalizeControlPoint(raw, width, height);
|
|
1116
|
+
if (!point)
|
|
1117
|
+
return;
|
|
1118
|
+
const actionStr = String(raw.action || 'down').toLowerCase();
|
|
1119
|
+
if (actionStr === 'down') {
|
|
1120
|
+
projection.touchStart = { ...point, at: Date.now() };
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
if (actionStr !== 'up' && actionStr !== 'cancel') {
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
const start = projection.touchStart || { ...point, at: Date.now() };
|
|
1127
|
+
projection.touchStart = undefined;
|
|
1128
|
+
const distance = Math.hypot(point.x - start.x, point.y - start.y);
|
|
1129
|
+
if (actionStr === 'cancel')
|
|
1130
|
+
return;
|
|
1131
|
+
if (distance < 8) {
|
|
1132
|
+
try {
|
|
1133
|
+
await this.iosWdaRequest(projection.wdaHost, projection.wdaPort, 'POST', `/session/${projection.sessionId}/wda/tap`, { x: point.x, y: point.y });
|
|
1134
|
+
}
|
|
1135
|
+
catch {
|
|
1136
|
+
await this.iosWdaRequest(projection.wdaHost, projection.wdaPort, 'POST', `/session/${projection.sessionId}/wda/tap/0`, { x: point.x, y: point.y });
|
|
1137
|
+
}
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
const duration = Math.max(100, Math.min(1500, Date.now() - start.at));
|
|
1141
|
+
await this.iosWdaRequest(projection.wdaHost, projection.wdaPort, 'POST', `/session/${projection.sessionId}/actions`, {
|
|
1142
|
+
actions: [
|
|
1143
|
+
{
|
|
1144
|
+
type: 'pointer',
|
|
1145
|
+
id: 'finger1',
|
|
1146
|
+
parameters: { pointerType: 'touch' },
|
|
1147
|
+
actions: [
|
|
1148
|
+
{ type: 'pointerMove', duration: 0, x: start.x, y: start.y },
|
|
1149
|
+
{ type: 'pointerDown', button: 0 },
|
|
1150
|
+
{ type: 'pause', duration: 100 },
|
|
1151
|
+
{ type: 'pointerMove', duration, x: point.x, y: point.y },
|
|
1152
|
+
{ type: 'pointerUp', button: 0 },
|
|
1153
|
+
],
|
|
1154
|
+
},
|
|
1155
|
+
],
|
|
1156
|
+
});
|
|
1157
|
+
};
|
|
1158
|
+
const pressIOSButton = async (name) => {
|
|
1159
|
+
const projection = ensureIOSProjection();
|
|
1160
|
+
if (!projection)
|
|
1161
|
+
return;
|
|
1162
|
+
await this.iosWdaRequest(projection.wdaHost, projection.wdaPort, 'POST', `/session/${projection.sessionId}/wda/pressButton`, { name });
|
|
1163
|
+
};
|
|
1164
|
+
const typeIOSText = async (text) => {
|
|
1165
|
+
const projection = ensureIOSProjection();
|
|
1166
|
+
if (!projection || !text)
|
|
1167
|
+
return;
|
|
1168
|
+
await this.iosWdaRequest(projection.wdaHost, projection.wdaPort, 'POST', `/session/${projection.sessionId}/wda/keys`, { value: text.split('') });
|
|
1169
|
+
};
|
|
743
1170
|
const handlers = {
|
|
744
1171
|
ping: () => {
|
|
745
1172
|
wsSend(ws, 'pong');
|
|
@@ -796,22 +1223,20 @@ export class ScrcpyServer {
|
|
|
796
1223
|
},
|
|
797
1224
|
'control-touch': async (raw = {}) => {
|
|
798
1225
|
try {
|
|
1226
|
+
if (state.platform === 'ios') {
|
|
1227
|
+
await handleIOSTouch(raw);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
799
1230
|
const controller = ensureController();
|
|
800
1231
|
if (!controller)
|
|
801
1232
|
return;
|
|
802
1233
|
const videoWidth = state.videoWidth || 1080;
|
|
803
1234
|
const videoHeight = state.videoHeight || 1920;
|
|
804
1235
|
const normalized = raw.normalized === true;
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
1236
|
+
const point = normalizeControlPoint({ ...raw, normalized }, videoWidth, videoHeight);
|
|
1237
|
+
if (!point)
|
|
808
1238
|
return;
|
|
809
|
-
|
|
810
|
-
x = Math.max(0, Math.min(1, x)) * videoWidth;
|
|
811
|
-
y = Math.max(0, Math.min(1, y)) * videoHeight;
|
|
812
|
-
}
|
|
813
|
-
x = Math.round(Math.max(0, Math.min(videoWidth - 1, x)));
|
|
814
|
-
y = Math.round(Math.max(0, Math.min(videoHeight - 1, y)));
|
|
1239
|
+
const { x, y } = point;
|
|
815
1240
|
const actionStr = String(raw.action || 'down').toLowerCase();
|
|
816
1241
|
const action = ACTION_MAP[actionStr] ?? 0;
|
|
817
1242
|
const isUp = actionStr === 'up' || actionStr === 'cancel';
|
|
@@ -881,6 +1306,11 @@ export class ScrcpyServer {
|
|
|
881
1306
|
},
|
|
882
1307
|
'control-text': async (raw = {}) => {
|
|
883
1308
|
try {
|
|
1309
|
+
if (state.platform === 'ios') {
|
|
1310
|
+
const text = typeof raw === 'string' ? raw : String(raw.text ?? '');
|
|
1311
|
+
await typeIOSText(text);
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
884
1314
|
const controller = ensureController();
|
|
885
1315
|
if (!controller)
|
|
886
1316
|
return;
|
|
@@ -912,6 +1342,10 @@ export class ScrcpyServer {
|
|
|
912
1342
|
},
|
|
913
1343
|
'control-home': async () => {
|
|
914
1344
|
try {
|
|
1345
|
+
if (state.platform === 'ios') {
|
|
1346
|
+
await pressIOSButton('home');
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
915
1349
|
await pressKey(3);
|
|
916
1350
|
}
|
|
917
1351
|
catch (e) {
|
|
@@ -946,6 +1380,29 @@ export class ScrcpyServer {
|
|
|
946
1380
|
},
|
|
947
1381
|
'control-scroll': async (raw = {}) => {
|
|
948
1382
|
try {
|
|
1383
|
+
if (state.platform === 'ios') {
|
|
1384
|
+
const projection = ensureIOSProjection();
|
|
1385
|
+
if (!projection)
|
|
1386
|
+
return;
|
|
1387
|
+
const width = projection.controlWidth || state.videoWidth || 390;
|
|
1388
|
+
const height = projection.controlHeight || state.videoHeight || 844;
|
|
1389
|
+
const center = normalizeControlPoint({
|
|
1390
|
+
x: raw.x ?? 0.5,
|
|
1391
|
+
y: raw.y ?? 0.5,
|
|
1392
|
+
normalized: raw.normalized !== false,
|
|
1393
|
+
}, width, height);
|
|
1394
|
+
if (!center)
|
|
1395
|
+
return;
|
|
1396
|
+
const dx = Number(raw.scrollX ?? 0) * 120;
|
|
1397
|
+
const dy = Number(raw.scrollY ?? -1) * 160;
|
|
1398
|
+
await handleIOSTouch({ action: 'down', x: center.x, y: center.y });
|
|
1399
|
+
await handleIOSTouch({
|
|
1400
|
+
action: 'up',
|
|
1401
|
+
x: Math.max(0, Math.min(width - 1, center.x - dx)),
|
|
1402
|
+
y: Math.max(0, Math.min(height - 1, center.y - dy)),
|
|
1403
|
+
});
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
949
1406
|
const controller = ensureController();
|
|
950
1407
|
if (!controller)
|
|
951
1408
|
return;
|