@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.
Files changed (41) hide show
  1. package/dist/api/device-api.d.ts.map +1 -1
  2. package/dist/api/device-api.js +2 -0
  3. package/dist/api/device-api.js.map +1 -1
  4. package/dist/core/types.d.ts +6 -0
  5. package/dist/core/types.d.ts.map +1 -1
  6. package/dist/debug/websocket-server.d.ts.map +1 -1
  7. package/dist/debug/websocket-server.js +35 -23
  8. package/dist/debug/websocket-server.js.map +1 -1
  9. package/dist/device/detector.d.ts +2 -0
  10. package/dist/device/detector.d.ts.map +1 -1
  11. package/dist/device/detector.js +70 -3
  12. package/dist/device/detector.js.map +1 -1
  13. package/dist/device/heartbeat.d.ts.map +1 -1
  14. package/dist/device/heartbeat.js +14 -2
  15. package/dist/device/heartbeat.js.map +1 -1
  16. package/dist/device/types.d.ts +3 -0
  17. package/dist/device/types.d.ts.map +1 -1
  18. package/dist/executor/ios-executor.d.ts.map +1 -1
  19. package/dist/executor/ios-executor.js +29 -15
  20. package/dist/executor/ios-executor.js.map +1 -1
  21. package/dist/executor/ios-wda-manager.d.ts +21 -0
  22. package/dist/executor/ios-wda-manager.d.ts.map +1 -0
  23. package/dist/executor/ios-wda-manager.js +333 -0
  24. package/dist/executor/ios-wda-manager.js.map +1 -0
  25. package/dist/scrcpy/preview-status.js +2 -2
  26. package/dist/scrcpy/preview-status.js.map +1 -1
  27. package/dist/scrcpy/server.d.ts +14 -0
  28. package/dist/scrcpy/server.d.ts.map +1 -1
  29. package/dist/scrcpy/server.js +472 -15
  30. package/dist/scrcpy/server.js.map +1 -1
  31. package/dist/storage/repositories/device-repo.d.ts.map +1 -1
  32. package/dist/storage/repositories/device-repo.js +1 -0
  33. package/dist/storage/repositories/device-repo.js.map +1 -1
  34. package/dist/task/guada-online-batch-poller.d.ts +10 -0
  35. package/dist/task/guada-online-batch-poller.d.ts.map +1 -1
  36. package/dist/task/guada-online-batch-poller.js +137 -8
  37. package/dist/task/guada-online-batch-poller.js.map +1 -1
  38. package/dist/task/scheduler.d.ts.map +1 -1
  39. package/dist/task/scheduler.js +11 -6
  40. package/dist/task/scheduler.js.map +1 -1
  41. package/package.json +1 -1
@@ -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.loadDevicesListFromAdb()
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
- let x = Number(raw.x);
806
- let y = Number(raw.y);
807
- if (!Number.isFinite(x) || !Number.isFinite(y))
1236
+ const point = normalizeControlPoint({ ...raw, normalized }, videoWidth, videoHeight);
1237
+ if (!point)
808
1238
  return;
809
- if (normalized) {
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;