appium-mac2-driver 4.3.6 → 4.5.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.
Files changed (106) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/WebDriverAgentMac/IntegrationTests/AMSessionTests.m +14 -0
  3. package/WebDriverAgentMac/IntegrationTests/AMWindowTests.m +427 -0
  4. package/WebDriverAgentMac/WebDriverAgentLib/Commands/AMWindowCommands.m +63 -0
  5. package/WebDriverAgentMac/WebDriverAgentLib/Commands/FBDebugCommands.m +2 -0
  6. package/WebDriverAgentMac/WebDriverAgentLib/Commands/FBSessionCommands.m +17 -5
  7. package/WebDriverAgentMac/WebDriverAgentLib/Routing/FBCommandStatus.h +3 -0
  8. package/WebDriverAgentMac/WebDriverAgentLib/Routing/FBCommandStatus.m +13 -0
  9. package/WebDriverAgentMac/WebDriverAgentLib/Routing/FBSession.m +5 -1
  10. package/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMScreenUtils.h +4 -0
  11. package/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMScreenUtils.m +23 -0
  12. package/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMSnapshotUtils.h +18 -0
  13. package/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMSnapshotUtils.m +43 -28
  14. package/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMWindowUtils.h +111 -0
  15. package/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMWindowUtils.m +780 -0
  16. package/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMXCTAccessibility.h +61 -0
  17. package/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMXCTAccessibility.m +108 -0
  18. package/WebDriverAgentMac/WebDriverAgentMac.xcodeproj/project.pbxproj +20 -0
  19. package/build/lib/commands/native-record-screen.d.ts +2 -0
  20. package/build/lib/commands/native-record-screen.d.ts.map +1 -1
  21. package/build/lib/commands/native-record-screen.js.map +1 -1
  22. package/build/lib/commands/windows.d.ts +22 -0
  23. package/build/lib/commands/windows.d.ts.map +1 -0
  24. package/build/lib/commands/windows.js +32 -0
  25. package/build/lib/commands/windows.js.map +1 -0
  26. package/build/lib/driver.d.ts +15 -0
  27. package/build/lib/driver.d.ts.map +1 -1
  28. package/build/lib/driver.js +55 -32
  29. package/build/lib/driver.js.map +1 -1
  30. package/build/lib/execute-method-map.d.ts +9 -0
  31. package/build/lib/execute-method-map.d.ts.map +1 -1
  32. package/build/lib/execute-method-map.js +9 -0
  33. package/build/lib/execute-method-map.js.map +1 -1
  34. package/build/lib/session-claim-handler.d.ts +41 -0
  35. package/build/lib/session-claim-handler.d.ts.map +1 -0
  36. package/build/lib/session-claim-handler.js +236 -0
  37. package/build/lib/session-claim-handler.js.map +1 -0
  38. package/build/lib/types.d.ts +26 -0
  39. package/build/lib/types.d.ts.map +1 -1
  40. package/build/lib/wda-mac.d.ts +6 -2
  41. package/build/lib/wda-mac.d.ts.map +1 -1
  42. package/build/lib/wda-mac.js +39 -9
  43. package/build/lib/wda-mac.js.map +1 -1
  44. package/lib/commands/native-record-screen.ts +2 -0
  45. package/lib/commands/windows.ts +44 -0
  46. package/lib/driver.ts +59 -31
  47. package/lib/execute-method-map.ts +9 -0
  48. package/lib/session-claim-handler.ts +306 -0
  49. package/lib/types.ts +28 -0
  50. package/lib/wda-mac.ts +41 -10
  51. package/node_modules/appium-xcode/CHANGELOG.md +7 -0
  52. package/node_modules/appium-xcode/package.json +4 -4
  53. package/node_modules/async-lock/AUTHORS +21 -0
  54. package/node_modules/async-lock/History.md +132 -0
  55. package/node_modules/async-lock/LICENSE +22 -0
  56. package/node_modules/async-lock/README.md +177 -0
  57. package/node_modules/async-lock/index.js +2 -0
  58. package/node_modules/async-lock/lib/index.js +312 -0
  59. package/node_modules/async-lock/package.json +48 -0
  60. package/node_modules/bare-fs/binding.c +1 -1
  61. package/node_modules/bare-fs/index.js +30 -6
  62. package/node_modules/bare-fs/package.json +1 -1
  63. package/node_modules/bare-fs/prebuilds/android-arm/bare-fs.bare +0 -0
  64. package/node_modules/bare-fs/prebuilds/android-arm64/bare-fs.bare +0 -0
  65. package/node_modules/bare-fs/prebuilds/android-ia32/bare-fs.bare +0 -0
  66. package/node_modules/bare-fs/prebuilds/android-x64/bare-fs.bare +0 -0
  67. package/node_modules/bare-fs/prebuilds/darwin-arm64/bare-fs.bare +0 -0
  68. package/node_modules/bare-fs/prebuilds/darwin-x64/bare-fs.bare +0 -0
  69. package/node_modules/bare-fs/prebuilds/ios-arm64/bare-fs.bare +0 -0
  70. package/node_modules/bare-fs/prebuilds/ios-arm64-simulator/bare-fs.bare +0 -0
  71. package/node_modules/bare-fs/prebuilds/ios-x64-simulator/bare-fs.bare +0 -0
  72. package/node_modules/bare-fs/prebuilds/linux-arm64/bare-fs.bare +0 -0
  73. package/node_modules/bare-fs/prebuilds/linux-x64/bare-fs.bare +0 -0
  74. package/node_modules/bare-fs/prebuilds/win32-arm64/bare-fs.bare +0 -0
  75. package/node_modules/bare-fs/prebuilds/win32-x64/bare-fs.bare +0 -0
  76. package/node_modules/bare-url/README.md +4 -0
  77. package/node_modules/bare-url/index.d.ts +3 -0
  78. package/node_modules/bare-url/index.js +42 -18
  79. package/node_modules/bare-url/lib/errors.js +4 -0
  80. package/node_modules/bare-url/package.json +1 -1
  81. package/node_modules/bare-url/prebuilds/android-arm/bare-url.bare +0 -0
  82. package/node_modules/bare-url/prebuilds/android-arm64/bare-url.bare +0 -0
  83. package/node_modules/bare-url/prebuilds/android-ia32/bare-url.bare +0 -0
  84. package/node_modules/bare-url/prebuilds/android-x64/bare-url.bare +0 -0
  85. package/node_modules/bare-url/prebuilds/darwin-arm64/bare-url.bare +0 -0
  86. package/node_modules/bare-url/prebuilds/darwin-x64/bare-url.bare +0 -0
  87. package/node_modules/bare-url/prebuilds/ios-arm64/bare-url.bare +0 -0
  88. package/node_modules/bare-url/prebuilds/ios-arm64-simulator/bare-url.bare +0 -0
  89. package/node_modules/bare-url/prebuilds/ios-x64-simulator/bare-url.bare +0 -0
  90. package/node_modules/bare-url/prebuilds/linux-arm64/bare-url.bare +0 -0
  91. package/node_modules/bare-url/prebuilds/linux-x64/bare-url.bare +0 -0
  92. package/node_modules/bare-url/prebuilds/win32-arm64/bare-url.bare +0 -0
  93. package/node_modules/bare-url/prebuilds/win32-x64/bare-url.bare +0 -0
  94. package/node_modules/readable-stream/lib/internal/shims/process.js +19 -0
  95. package/node_modules/readable-stream/lib/internal/streams/destroy.js +1 -1
  96. package/node_modules/readable-stream/lib/internal/streams/duplexify.js +1 -1
  97. package/node_modules/readable-stream/lib/internal/streams/end-of-stream.js +1 -1
  98. package/node_modules/readable-stream/lib/internal/streams/from.js +1 -1
  99. package/node_modules/readable-stream/lib/internal/streams/legacy.js +6 -1
  100. package/node_modules/readable-stream/lib/internal/streams/pipeline.js +1 -1
  101. package/node_modules/readable-stream/lib/internal/streams/readable.js +1 -1
  102. package/node_modules/readable-stream/lib/internal/streams/writable.js +1 -1
  103. package/node_modules/readable-stream/package.json +3 -2
  104. package/node_modules/tar-stream/package.json +7 -7
  105. package/package.json +6 -3
  106. package/scripts/setup-accessibility.mjs +175 -0
@@ -0,0 +1,44 @@
1
+ import type {Mac2Driver} from '../driver.js';
2
+ import type {AdjustedWindowInfo, WindowInfo, WindowState} from '../types.js';
3
+
4
+ /**
5
+ * Lists the windows of the current application
6
+ *
7
+ * @returns Information about each window, including its element identifier
8
+ */
9
+ export async function macosListWindows(this: Mac2Driver): Promise<WindowInfo[]> {
10
+ return (await this.wda.proxy.command('/wda/windows', 'GET')) as WindowInfo[];
11
+ }
12
+
13
+ /**
14
+ * Changes the state and/or geometry of an application window, optionally moving it to another display
15
+ *
16
+ * @param windowId - Identifier of the window element. The main window is used by default
17
+ * @param state - The state to put the window into
18
+ * @param x - Horizontal origin. Global, or relative to the display if `displayId` is set
19
+ * @param y - Vertical origin. Global, or relative to the display if `displayId` is set
20
+ * @param width - Window width
21
+ * @param height - Window height
22
+ * @param displayId - Identifier of the display to move the window to
23
+ * @returns The updated window information. `windowId` is null if a minimized window was adjusted
24
+ */
25
+ export async function macosAdjustWindow(
26
+ this: Mac2Driver,
27
+ windowId?: string,
28
+ state?: WindowState,
29
+ x?: number,
30
+ y?: number,
31
+ width?: number,
32
+ height?: number,
33
+ displayId?: number,
34
+ ): Promise<AdjustedWindowInfo> {
35
+ return (await this.wda.proxy.command('/wda/window/adjust', 'POST', {
36
+ windowId,
37
+ state,
38
+ x,
39
+ y,
40
+ width,
41
+ height,
42
+ displayId,
43
+ })) as AdjustedWindowInfo;
44
+ }
package/lib/driver.ts CHANGED
@@ -11,7 +11,7 @@ import type {
11
11
  DriverOpts,
12
12
  W3CDriverCaps,
13
13
  } from '@appium/types';
14
- import {BaseDriver, DeviceSettings} from 'appium/driver.js';
14
+ import {BaseDriver, DeviceSettings, errors} from 'appium/driver.js';
15
15
 
16
16
  import * as appManagemenetCommands from './commands/app-management.js';
17
17
  import * as appleScriptCommands from './commands/applescript.js';
@@ -25,10 +25,12 @@ import * as navigationCommands from './commands/navigation.js';
25
25
  import * as recordScreenCommands from './commands/record-screen.js';
26
26
  import * as screenshotCommands from './commands/screenshots.js';
27
27
  import * as sourceCommands from './commands/source.js';
28
+ import * as windowCommands from './commands/windows.js';
28
29
  import MAC2_CONSTRAINTS, {type Mac2Constraints} from './constraints.js';
29
30
  import {executeMethodMap} from './execute-method-map.js';
30
31
  import log from './logger.js';
31
32
  import {newMethodMap} from './method-map.js';
33
+ import {sessionClaimHandler} from './session-claim-handler.js';
32
34
  import {WDA_MAC_SERVER, type WDAMacServer} from './wda-mac.js';
33
35
 
34
36
  const NO_PROXY: RouteMatcher[] = [
@@ -110,12 +112,17 @@ export class Mac2Driver
110
112
 
111
113
  macosSource = sourceCommands.macosSource;
112
114
 
115
+ macosListWindows = windowCommands.macosListWindows;
116
+ macosAdjustWindow = windowCommands.macosAdjustWindow;
117
+
113
118
  _videoChunksBroadcaster!: nativeScreenRecordingCommands.NativeVideoChunksBroadcaster;
114
119
  _screenRecorder: recordScreenCommands.ScreenRecorder | null = null;
115
120
  public proxyReqRes!: (...args: any) => any;
116
121
 
117
122
  private isProxyActive: boolean = false;
118
123
  private _wda: WDAMacServer | null = null;
124
+ private _wdaSessionId: string | null = null;
125
+ private _hasClaimedHost: boolean = false;
119
126
 
120
127
  constructor(opts: InitialOpts = {} as InitialOpts) {
121
128
  super(opts);
@@ -143,6 +150,13 @@ export class Mac2Driver
143
150
  if (!this._wda) {
144
151
  throw new Error('WDA server is not initialized');
145
152
  }
153
+ // The WDA server is shared and only supports a single session, so a newer
154
+ // session replaces this one. Never send commands on its behalf after that.
155
+ if (this._wdaSessionId && !this._wda.isSessionActive(this._wdaSessionId)) {
156
+ throw new errors.NoSuchDriverError(
157
+ 'This session has been replaced by a newer one, because this driver only supports a single session',
158
+ );
159
+ }
146
160
  return this._wda;
147
161
  }
148
162
 
@@ -150,7 +164,7 @@ export class Mac2Driver
150
164
  if (!this._wda) {
151
165
  return;
152
166
  }
153
- return await this._wda.proxy.command('/appium/settings', 'POST', {
167
+ return await this.wda.proxy.command('/appium/settings', 'POST', {
154
168
  settings: {[key]: value},
155
169
  });
156
170
  }
@@ -170,10 +184,7 @@ export class Mac2Driver
170
184
  }
171
185
 
172
186
  async proxyCommand(url: string, method: HTTPMethod, body: HTTPBody = null): Promise<any> {
173
- if (!this._wda) {
174
- throw new Error('WDA server is not initialized');
175
- }
176
- return await this._wda.proxy.command(url, method, body);
187
+ return await this.wda.proxy.command(url, method, body);
177
188
  }
178
189
 
179
190
  override async getStatus(): Promise<any> {
@@ -185,10 +196,7 @@ export class Mac2Driver
185
196
 
186
197
  // needed to make image plugin work
187
198
  async getWindowRect(): Promise<any> {
188
- if (!this._wda) {
189
- throw new Error('WDA server is not initialized');
190
- }
191
- return await this._wda.proxy.command('/window/rect', 'GET');
199
+ return await this.wda.proxy.command('/window/rect', 'GET');
192
200
  }
193
201
 
194
202
  override async createSession(
@@ -202,34 +210,51 @@ export class Mac2Driver
202
210
  this.caps = caps as Mac2DriverCaps;
203
211
  // oxlint-disable-next-line no-self-assign -- narrows this.opts' type for the rest of the method
204
212
  this.opts = this.opts as Mac2DriverOpts;
205
- try {
206
- const prerun = caps.prerun as PrerunCapability | undefined;
207
- if (prerun) {
208
- if (typeof prerun.command !== 'string' && typeof prerun.script !== 'string') {
209
- throw new Error(`'prerun' capability value must either contain 'script' or 'command' entry of string type`);
210
- }
211
- log.info('Executing prerun AppleScript');
212
- const output = await this.macosExecAppleScript(prerun.script, undefined, prerun.command);
213
- if (output.trim()) {
214
- log.info(`Prerun script output: ${output}`);
213
+ // The host supports a single session: the previous one has to quit first
214
+ await sessionClaimHandler.runExclusive(async () => {
215
+ // The session might have been deleted while it was waiting for its turn
216
+ if (!this._wda || !this.sessionId) {
217
+ throw new errors.NoSuchDriverError('The session has been deleted before it could start');
218
+ }
219
+ try {
220
+ await sessionClaimHandler.registerActiveSession(this);
221
+ await sessionClaimHandler.claimHost(this);
222
+ this._hasClaimedHost = true;
223
+ const prerun = caps.prerun as PrerunCapability | undefined;
224
+ if (prerun) {
225
+ if (typeof prerun.command !== 'string' && typeof prerun.script !== 'string') {
226
+ throw new Error(`'prerun' capability value must either contain 'script' or 'command' entry of string type`);
227
+ }
228
+ log.info('Executing prerun AppleScript');
229
+ const output = await this.macosExecAppleScript(prerun.script, undefined, prerun.command);
230
+ if (output.trim()) {
231
+ log.info(`Prerun script output: ${output}`);
232
+ }
215
233
  }
234
+ this._wdaSessionId = await this.wda.startSession(caps, {
235
+ reqBasePath: this.basePath,
236
+ });
237
+ } catch (e: any) {
238
+ await this.deleteSession();
239
+ throw e;
216
240
  }
217
- await this._wda.startSession(caps, {
218
- reqBasePath: this.basePath,
219
- });
220
- } catch (e: any) {
221
- await this.deleteSession();
222
- throw e;
223
- }
224
- this.proxyReqRes = this.wda.proxy.proxyReqRes.bind(this._wda.proxy);
241
+ });
242
+ this.proxyReqRes = (...args: any[]) =>
243
+ (this.wda.proxy.proxyReqRes as (...a: any[]) => any).apply(this.wda.proxy, args);
225
244
  this.isProxyActive = true;
226
245
  return [sessionId, caps];
227
246
  }
228
247
 
229
248
  override async deleteSession(): Promise<void> {
249
+ sessionClaimHandler.unregisterActiveSession(this);
250
+ // A new session waits for the teardown to complete before it starts
251
+ await sessionClaimHandler.trackTeardown(async () => await this.teardownSession());
252
+ }
253
+
254
+ private async teardownSession(): Promise<void> {
230
255
  await this._screenRecorder?.stop(true);
231
256
  if (this._videoChunksBroadcaster.hasPublishers) {
232
- if (this._wda) {
257
+ if (this._wda?.isSessionActive(this._wdaSessionId)) {
233
258
  try {
234
259
  await this.wda.proxy.command('/wda/video/stop', 'POST', {});
235
260
  } catch {}
@@ -237,10 +262,11 @@ export class Mac2Driver
237
262
  await this._videoChunksBroadcaster.shutdown(5000);
238
263
  }
239
264
  if (this._wda) {
240
- await this.wda.stopSession();
265
+ await this._wda.stopSession(this._wdaSessionId);
241
266
  }
242
267
 
243
- const postrun = this.opts.postrun as PostrunCapability | undefined;
268
+ // Skip postrun if the host was never acquired, as the previous session might still be using it
269
+ const postrun = this._hasClaimedHost ? (this.opts.postrun as PostrunCapability | undefined) : undefined;
244
270
  if (postrun) {
245
271
  if (typeof postrun.command !== 'string' && typeof postrun.script !== 'string') {
246
272
  log.error(`'postrun' capability value must either contain 'script' or 'command' entry of string type`);
@@ -264,6 +290,8 @@ export class Mac2Driver
264
290
 
265
291
  private resetState(): void {
266
292
  this._wda = null;
293
+ this._wdaSessionId = null;
294
+ this._hasClaimedHost = false;
267
295
  this.isProxyActive = false;
268
296
  this._videoChunksBroadcaster = new nativeScreenRecordingCommands.NativeVideoChunksBroadcaster(
269
297
  this.eventEmitter,
@@ -130,6 +130,15 @@ export const executeMethodMap = {
130
130
  optional: ['displayId'],
131
131
  },
132
132
  },
133
+ 'macos: listWindows': {
134
+ command: 'macosListWindows',
135
+ },
136
+ 'macos: adjustWindow': {
137
+ command: 'macosAdjustWindow',
138
+ params: {
139
+ optional: ['windowId', 'state', 'x', 'y', 'width', 'height', 'displayId'],
140
+ },
141
+ },
133
142
  'macos: appleScript': {
134
143
  command: 'macosExecAppleScript',
135
144
  params: {
@@ -0,0 +1,306 @@
1
+ import {setTimeout as delay} from 'node:timers/promises';
2
+
3
+ import type {AppiumLogger, IAppiumIpc, IIpcSubscription, IpcMessage} from '@appium/types';
4
+ import {errors} from 'appium/driver.js';
5
+ import {node, util} from 'appium/support.js';
6
+ import AsyncLock from 'async-lock';
7
+ import {waitForCondition} from 'asyncbox';
8
+
9
+ import type {Mac2Driver} from './driver.js';
10
+
11
+ export type SessionIpcMessage = {
12
+ sessionId: string;
13
+ };
14
+
15
+ type AppiumIpcConstructor = new () => IAppiumIpc;
16
+ type IpcProvider = () => Promise<IAppiumIpc | undefined>;
17
+
18
+ /** Lets a new session make the previous one quit, over a driver-wide IPC (per-session IPCs are isolated). */
19
+ export class SessionClaimHandler {
20
+ static readonly CLAIMED_TOPIC = 'mac2:sessionClaimed';
21
+ static readonly CONTENDED_TOPIC = 'mac2:sessionContended';
22
+ static readonly RELEASED_TOPIC = 'mac2:sessionReleased';
23
+
24
+ private static readonly STARTUP_LOCK_KEY = 'mac2:sessionStartup';
25
+
26
+ private static readonly CONTENTION_PROBE_MS = 10;
27
+ private static readonly DEFAULT_RELEASE_WAIT_MS = 15000;
28
+
29
+ private readonly startupLock = new AsyncLock();
30
+ private readonly pendingTeardowns = new Set<Promise<void>>();
31
+ private readonly subscriptionsBySessionId = new Map<string, IIpcSubscription<SessionIpcMessage>>();
32
+
33
+ constructor(
34
+ private readonly getIpc: IpcProvider,
35
+ private readonly releaseWaitMs: number = SessionClaimHandler.DEFAULT_RELEASE_WAIT_MS,
36
+ ) {}
37
+
38
+ /** Serializes session startups so the newest wins. Enter before registering the session. */
39
+ async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
40
+ return await this.startupLock.acquire(SessionClaimHandler.STARTUP_LOCK_KEY, fn);
41
+ }
42
+
43
+ /** Subscribe the session to claim messages from newer sessions. */
44
+ async registerActiveSession(driver: Mac2Driver): Promise<void> {
45
+ const ipc = await this.getIpc();
46
+ const sessionId = driver.sessionId;
47
+ if (!ipc || !sessionId) {
48
+ return;
49
+ }
50
+
51
+ this.unregisterActiveSession(driver);
52
+
53
+ const subscription = ipc.subscribe<SessionIpcMessage>(
54
+ SessionClaimHandler.CLAIMED_TOPIC,
55
+ this.getPublisherId(driver),
56
+ );
57
+ subscription.on('message', (message) => {
58
+ void this.dispatchClaimMessage(driver, sessionId, message);
59
+ });
60
+ this.subscriptionsBySessionId.set(sessionId, subscription);
61
+ }
62
+
63
+ /** Unsubscribe the session from claim messages. */
64
+ unregisterActiveSession(driver: Mac2Driver): void {
65
+ const sessionId = driver.sessionId;
66
+ if (!sessionId) {
67
+ return;
68
+ }
69
+
70
+ this.subscriptionsBySessionId.get(sessionId)?.unsubscribe();
71
+ this.subscriptionsBySessionId.delete(sessionId);
72
+ }
73
+
74
+ /** Tracks a session teardown, so that a new session does not start before it is complete. */
75
+ async trackTeardown(teardown: () => Promise<void>): Promise<void> {
76
+ const promise = teardown();
77
+ const tracked = (async () => {
78
+ try {
79
+ await promise;
80
+ } catch {
81
+ // the failure is reported to the caller of trackTeardown()
82
+ }
83
+ })();
84
+ this.pendingTeardowns.add(tracked);
85
+ try {
86
+ await promise;
87
+ } finally {
88
+ this.pendingTeardowns.delete(tracked);
89
+ }
90
+ }
91
+
92
+ /** Make any other active session quit and wait until it has released the host. */
93
+ async claimHost(driver: Mac2Driver): Promise<void> {
94
+ await this.requestRelease(driver);
95
+ await this.waitForPendingTeardowns(driver);
96
+ }
97
+
98
+ private async waitForPendingTeardowns(driver: Mac2Driver): Promise<void> {
99
+ if (this.pendingTeardowns.size === 0) {
100
+ return;
101
+ }
102
+
103
+ driver.log.debug(`Waiting for ${util.pluralize('session', this.pendingTeardowns.size, true)} to finish quitting`);
104
+ const timer = new AbortController();
105
+ const timedOut = Symbol('timedOut');
106
+ const timeout = (async () => {
107
+ try {
108
+ return await delay(this.releaseWaitMs, timedOut, {signal: timer.signal});
109
+ } catch {
110
+ return undefined; // aborted
111
+ }
112
+ })();
113
+ try {
114
+ if ((await Promise.race([Promise.all(this.pendingTeardowns), timeout])) === timedOut) {
115
+ // Starting now could let the old teardown (e.g. postrun) interfere with this session
116
+ throw new errors.SessionNotCreatedError(
117
+ `The previous session did not finish quitting within ${this.releaseWaitMs}ms`,
118
+ );
119
+ }
120
+ } finally {
121
+ timer.abort();
122
+ }
123
+ }
124
+
125
+ private async requestRelease(driver: Mac2Driver): Promise<void> {
126
+ const ipc = await this.getIpc();
127
+ if (!ipc) {
128
+ driver.log.debug('Driver-instance IPC is unavailable. Skipping the session claim.');
129
+ return;
130
+ }
131
+
132
+ const sessionId = driver.sessionId;
133
+ if (!sessionId) {
134
+ return;
135
+ }
136
+
137
+ const contendingSessionIds = new Set<string>();
138
+ const releasedSessionIds = new Set<string>();
139
+ const subscriptions: IIpcSubscription<SessionIpcMessage>[] = [];
140
+
141
+ try {
142
+ const contendedSubscription = ipc.subscribe<SessionIpcMessage>(
143
+ SessionClaimHandler.CONTENDED_TOPIC,
144
+ this.getPublisherId(driver),
145
+ );
146
+ subscriptions.push(contendedSubscription);
147
+ const releasedSubscription = ipc.subscribe<SessionIpcMessage>(
148
+ SessionClaimHandler.RELEASED_TOPIC,
149
+ this.getPublisherId(driver),
150
+ );
151
+ subscriptions.push(releasedSubscription);
152
+ contendedSubscription.on('message', (message) => {
153
+ if (message.data.sessionId !== sessionId) {
154
+ contendingSessionIds.add(message.data.sessionId);
155
+ }
156
+ });
157
+ releasedSubscription.on('message', (message) => {
158
+ if (message.data.sessionId !== sessionId) {
159
+ releasedSessionIds.add(message.data.sessionId);
160
+ }
161
+ });
162
+
163
+ await ipc.publish<SessionIpcMessage>(SessionClaimHandler.CLAIMED_TOPIC, this.getPublisherId(driver), {
164
+ sessionId,
165
+ });
166
+ // Older sessions reply synchronously to the claim, so a short wait is enough to learn about them
167
+ await delay(SessionClaimHandler.CONTENTION_PROBE_MS);
168
+
169
+ if (contendingSessionIds.size === 0) {
170
+ return;
171
+ }
172
+
173
+ try {
174
+ await waitForCondition(() => [...contendingSessionIds].every((id) => releasedSessionIds.has(id)), {
175
+ waitMs: this.releaseWaitMs,
176
+ intervalMs: 50,
177
+ });
178
+ driver.log.debug(
179
+ `Received release confirmation from ${util.pluralize('session', contendingSessionIds.size, true)}`,
180
+ );
181
+ } catch {
182
+ const pendingSessionIds = [...contendingSessionIds].filter((id) => !releasedSessionIds.has(id));
183
+ // Starting now could let the old session interfere with this one
184
+ throw new errors.SessionNotCreatedError(
185
+ `${util.pluralize('session', pendingSessionIds.length, true)} [${pendingSessionIds.join(', ')}] ` +
186
+ `did not quit within ${this.releaseWaitMs}ms`,
187
+ );
188
+ }
189
+ } finally {
190
+ subscriptions.forEach((sub) => sub.unsubscribe());
191
+ }
192
+ }
193
+
194
+ /** @internal Exposed for unit tests. */
195
+ resetForTesting(): void {
196
+ this.pendingTeardowns.clear();
197
+ for (const subscription of this.subscriptionsBySessionId.values()) {
198
+ subscription.unsubscribe();
199
+ }
200
+ this.subscriptionsBySessionId.clear();
201
+ }
202
+
203
+ private async dispatchClaimMessage(
204
+ driver: Mac2Driver,
205
+ sessionId: string,
206
+ message: IpcMessage<SessionIpcMessage>,
207
+ ): Promise<void> {
208
+ try {
209
+ await this.handleClaimMessage(driver, sessionId, message);
210
+ } catch (err) {
211
+ const msg = err instanceof Error ? err.message : String(err);
212
+ driver.log.warn(`Could not handle the session claim IPC message for session '${sessionId}': ${msg}`);
213
+ }
214
+ }
215
+
216
+ private async handleClaimMessage(
217
+ driver: Mac2Driver,
218
+ sessionId: string,
219
+ message: IpcMessage<SessionIpcMessage>,
220
+ ): Promise<void> {
221
+ if (message.data.sessionId === sessionId || driver.sessionId !== sessionId) {
222
+ return;
223
+ }
224
+
225
+ // Tell the claimer that it has to wait for us
226
+ await this.publish(driver, SessionClaimHandler.CONTENDED_TOPIC, sessionId);
227
+
228
+ driver.log.warn(
229
+ `Session '${message.data.sessionId}' is starting, while this session ('${sessionId}') is still active. ` +
230
+ `Only one session at a time is supported, since XCTest is single-threaded. ` +
231
+ `Consider enabling the Appium server's '--session-override' flag and make sure to properly ` +
232
+ `quit the previous session before starting a new one. Terminating the obsolete session.`,
233
+ );
234
+ await this.terminateSession(driver, sessionId);
235
+ }
236
+
237
+ private async terminateSession(driver: Mac2Driver, sessionId: string): Promise<void> {
238
+ const publisherId = this.getPublisherId(driver);
239
+ const {log} = driver;
240
+
241
+ try {
242
+ // Unlike deleteSession(), this also makes the server drop the session from its list
243
+ await driver.startUnexpectedShutdown(
244
+ new errors.NoSuchDriverError(
245
+ 'This session has been replaced by a newer one, because only a single session is supported',
246
+ ),
247
+ );
248
+ } catch (err) {
249
+ const msg = err instanceof Error ? err.message : String(err);
250
+ log.warn(`Could not terminate session '${sessionId}' on IPC request: ${msg}`);
251
+ }
252
+
253
+ await this.publish(driver, SessionClaimHandler.RELEASED_TOPIC, sessionId, publisherId, log);
254
+ }
255
+
256
+ private async publish(
257
+ driver: Mac2Driver,
258
+ topic: string,
259
+ sessionId: string,
260
+ publisherId: string = this.getPublisherId(driver),
261
+ log: AppiumLogger = driver.log,
262
+ ): Promise<void> {
263
+ try {
264
+ const ipc = await this.getIpc();
265
+ await ipc?.publish<SessionIpcMessage>(topic, publisherId, {sessionId});
266
+ } catch (err) {
267
+ const msg = err instanceof Error ? err.message : String(err);
268
+ log.warn(`Could not publish '${topic}' message for session '${sessionId}': ${msg}`);
269
+ }
270
+ }
271
+
272
+ private getPublisherId(driver: Mac2Driver): string {
273
+ return node.getObjectId(driver);
274
+ }
275
+ }
276
+
277
+ let sharedIpc: Promise<IAppiumIpc | undefined> | undefined;
278
+
279
+ async function loadSharedIpc(): Promise<IAppiumIpc | undefined> {
280
+ sharedIpc ??= (async () => {
281
+ try {
282
+ const {AppiumIpc} = (await import('appium/driver.js')) as {AppiumIpc?: AppiumIpcConstructor};
283
+ return AppiumIpc ? new AppiumIpc() : undefined;
284
+ } catch {
285
+ return undefined;
286
+ }
287
+ })();
288
+ return await sharedIpc;
289
+ }
290
+
291
+ export const sessionClaimHandler = new SessionClaimHandler(loadSharedIpc);
292
+
293
+ /**
294
+ * @internal Exposed for unit tests.
295
+ */
296
+ export function setSharedIpcForTesting(ipc: IAppiumIpc | undefined): void {
297
+ sharedIpc = Promise.resolve(ipc);
298
+ }
299
+
300
+ /**
301
+ * @internal Exposed for unit tests.
302
+ */
303
+ export function resetSharedIpcForTesting(): void {
304
+ sessionClaimHandler.resetForTesting();
305
+ sharedIpc = undefined;
306
+ }
package/lib/types.ts CHANGED
@@ -26,3 +26,31 @@ export interface ScreenshotInfo {
26
26
 
27
27
  /** A dictionary where each key contains a unique display identifier */
28
28
  export type ScreenshotsInfo = StringRecord<ScreenshotInfo>;
29
+
30
+ export interface Rect {
31
+ x: number;
32
+ y: number;
33
+ width: number;
34
+ height: number;
35
+ }
36
+
37
+ export type WindowState = 'normal' | 'minimized' | 'maximized' | 'fullscreen';
38
+
39
+ export interface WindowInfo {
40
+ /** Identifier of the window element */
41
+ windowId: string;
42
+ title: string;
43
+ rect: Rect;
44
+ /** The display that contains most of the window, if any */
45
+ displayId: number | null;
46
+ /** Traits below are null if the Accessibility permission is not granted */
47
+ isMain: boolean | null;
48
+ isFocused: boolean | null;
49
+ isMinimized: boolean | null;
50
+ isFullscreen: boolean | null;
51
+ isMovable: boolean | null;
52
+ isResizable: boolean | null;
53
+ }
54
+
55
+ /** `windowId` is null if a minimized window has been adjusted */
56
+ export type AdjustedWindowInfo = Omit<WindowInfo, 'windowId'> & {windowId: string | null};
package/lib/wda-mac.ts CHANGED
@@ -301,7 +301,8 @@ export class WDAMacServer {
301
301
  return this._proxy;
302
302
  }
303
303
 
304
- async startSession(caps: StartSessionCapabilities, opts: SessionOptions = {}): Promise<void> {
304
+ /** Starts a WDA session, replacing the previous one. Returns the new WDA session id. */
305
+ async startSession(caps: StartSessionCapabilities, opts: SessionOptions = {}): Promise<string> {
305
306
  this._serverStartupTimeoutMs = caps.serverStartupTimeout ?? this._serverStartupTimeoutMs;
306
307
 
307
308
  this._isProxyingToRemoteServer = !!caps.webDriverAgentMacUrl;
@@ -381,23 +382,53 @@ export class WDAMacServer {
381
382
  log.info('The host process has already been listening. Proceeding with session creation');
382
383
  }
383
384
 
384
- await this._proxy.command('/session', 'POST', {
385
- capabilities: {
386
- firstMatch: [{}],
387
- alwaysMatch: caps,
388
- },
389
- });
385
+ // WDA drops the previous session as soon as it gets this request,
386
+ // so a failure must not leave the proxy pointing at the replaced one
387
+ try {
388
+ await this._proxy.command('/session', 'POST', {
389
+ capabilities: {
390
+ firstMatch: [{}],
391
+ alwaysMatch: caps,
392
+ },
393
+ });
394
+ } catch (e) {
395
+ this._proxy.sessionId = null;
396
+ throw e;
397
+ }
398
+ const sessionId = this._proxy.sessionId;
399
+ if (!sessionId) {
400
+ throw new errors.SessionNotCreatedError('WebDriverAgentMac did not return a session id');
401
+ }
402
+ return sessionId;
403
+ }
404
+
405
+ /** Whether the WDA session has not been replaced by a newer one. */
406
+ isSessionActive(sessionId: string | null | undefined): boolean {
407
+ return !!sessionId && this._proxy?.sessionId === sessionId;
390
408
  }
391
409
 
392
- async stopSession(): Promise<void> {
410
+ /** Stops the WDA session. A replaced one is skipped, so the newer one is never affected. */
411
+ async stopSession(sessionId: string | null | undefined): Promise<void> {
393
412
  if (!this._isProxyingToRemoteServer && !this._process?.isRunning) {
394
413
  log.info(`Mac2Driver session cannot be stopped, because the server is not running`);
395
414
  return;
396
415
  }
397
416
 
398
- if (this._proxy?.sessionId) {
417
+ if (!this.isSessionActive(sessionId)) {
418
+ if (sessionId) {
419
+ log.info(`Mac2Driver session '${sessionId}' is not active anymore. Nothing to stop`);
420
+ }
421
+ return;
422
+ }
423
+
424
+ const proxy = this._proxy;
425
+ if (proxy) {
399
426
  try {
400
- await this._proxy.command(`/session/${this._proxy.sessionId}`, 'DELETE');
427
+ await proxy.command(`/session/${sessionId}`, 'DELETE');
428
+ // A newer session might have been created while the DELETE was pending
429
+ if (proxy.sessionId === sessionId) {
430
+ proxy.sessionId = null;
431
+ }
401
432
  } catch (e: any) {
402
433
  log.info(`Mac2Driver session cannot be deleted. Original error: ${e.message}`);
403
434
  }
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [7.2.3](https://github.com/appium/appium-ios/compare/appium-xcode@7.2.2...appium-xcode@7.2.3) (2026-10-02)
7
+
8
+ ### Miscellaneous Chores
9
+
10
+ * ensure Dependabot preserves version ranges ([#24](https://github.com/appium/appium-ios/issues/24)) ([95e4e1a](https://github.com/appium/appium-ios/commit/95e4e1a4703f8ca833f01ecf2af75f4696d98043))
11
+
12
+
6
13
  ## [7.2.2](https://github.com/appium/appium-ios/compare/appium-xcode@7.2.1...appium-xcode@7.2.2) (2026-10-01)
7
14
 
8
15
  **Note:** Version bump only for package appium-xcode