Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5a5f60a
house arrest migration to use remotexpc when iOS>=18
navin772 Jan 5, 2026
8d7c4e7
use remotexpc for AFC commands
navin772 Jan 5, 2026
e93a71d
refactor afc type checks
navin772 Jan 6, 2026
f6d1805
Merge branch 'master' into house-arrest-remotexpc
navin772 Jan 6, 2026
f4b241e
Merge branch 'master' into house-arrest-remotexpc
navin772 Jan 7, 2026
ab9d0b8
adapt as per https://github.com/appium/appium-ios-remotexpc/pull/128
navin772 Jan 7, 2026
d831fd0
refactor to afc-client.ts and utils
navin772 Jan 9, 2026
6e699e0
Merge branch 'master' into house-arrest-remotexpc
navin772 Jan 9, 2026
315b7c6
change to `onEntry`
navin772 Jan 9, 2026
f354672
update as per - https://github.com/appium/appium-xcuitest-driver/pull…
navin772 Jan 12, 2026
974bec2
Merge branch 'master' into house-arrest-remotexpc
navin772 Jan 12, 2026
fc68bbd
address review comments
navin772 Jan 12, 2026
07e912c
Merge branch 'master' into house-arrest-remotexpc
navin772 Jan 12, 2026
e649c93
merge branch master
navin772 Jan 14, 2026
71461ea
Merge branch 'master' into house-arrest-remotexpc
navin772 Jan 15, 2026
f7b60c2
encapsulate all switching logic to afc-client.ts and handle OOM for r…
navin772 Jan 15, 2026
efcb621
change to onEntry
navin772 Jan 15, 2026
8730111
refactor to `withRemoteXPCConnection` and convert internal methods to…
navin772 Jan 15, 2026
cb88040
Merge branch 'master' into house-arrest-remotexpc
navin772 Jan 16, 2026
18c478b
add type explicitly with bind
navin772 Jan 16, 2026
4bb6455
create helper `pullFileWithChecks` and address review comments
navin772 Jan 16, 2026
68808d4
import types from remotexpc
navin772 Jan 16, 2026
716321b
import types from ios-device
navin772 Jan 16, 2026
6989804
add parallel pull support for ios-device pulls
navin772 Jan 16, 2026
8513fde
Merge branch 'master' into house-arrest-remotexpc
navin772 Jan 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 146 additions & 34 deletions lib/commands/file-movement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,83 @@ import {errors} from 'appium/driver';
import type {Simulator} from 'appium-ios-simulator';
import type {XCUITestDriver} from '../driver';
import type {ContainerObject, ContainerRootSupplier} from './types';
import {isIos18OrNewer} from '../utils';

let RemoteXPCServices: any;
async function getRemoteXPCServices() {
if (!RemoteXPCServices) {
const remotexpc = await import('appium-ios-remotexpc');
RemoteXPCServices = remotexpc.Services;
}
return RemoteXPCServices;
}

interface AfcClientResult {
afcService: any;
cleanup: () => Promise<void>;
useIos18: boolean;
}

interface AfcServiceWrapper {
service: any;
tunnelConnection?: {host: string; port: number};
useIos18: boolean;
}

/**
* Get the appropriate AFC service based on iOS version
* @param udid Device UDID
* @param useIos18 Whether to use iOS 18+ remotexpc
* @returns AFC service instance and optional tunnel connection to cleanup
*/
async function getAfcService(udid: string, useIos18: boolean): Promise<AfcServiceWrapper> {
if (useIos18) {
const Services = await getRemoteXPCServices();
const {tunnelConnection} = await Services.createRemoteXPCConnection(udid);
const afcService = await Services.startAfcService(udid);
return {service: afcService, tunnelConnection, useIos18};
}
return {service: await services.startAfcService(udid), useIos18};
}

/**
* Get House Arrest AFC service
* @param udid Device UDID
* @param bundleId App bundle ID
* @param containerType Container type (null for app container, 'documents' for documents)
* @param useIos18 Whether to use iOS 18+ remotexpc
* @param skipDocumentsCheck Skip checking for documents container
* @returns AFC service instance and optional tunnel connection to cleanup
*/
async function getHouseArrestAfcService(
udid: string,
bundleId: string,
containerType: string | null,
useIos18: boolean,
skipDocumentsCheck: boolean = false,
): Promise<AfcServiceWrapper> {
const isDocuments = !skipDocumentsCheck && isDocumentsContainer(containerType);

if (useIos18) {
const Services = await getRemoteXPCServices();
const {tunnelConnection} = await Services.createRemoteXPCConnection(udid);
const {houseArrestService} = await Services.startHouseArrestService(udid);
const afcService = isDocuments
? await houseArrestService.vendDocuments(bundleId)
: await houseArrestService.vendContainer(bundleId);
return {service: afcService, tunnelConnection, useIos18};
}
// iOS <18: use appium-ios-device
const houseArrestService = await services.startHouseArrestService(udid);
const afcService = isDocuments
? await houseArrestService.vendDocuments(bundleId)
: await houseArrestService.vendContainer(bundleId);
return {service: afcService, useIos18};
}

function isDocumentsContainer(containerType?: string | null): boolean {
return _.toLower(containerType ?? '') === _.toLower(CONTAINER_DOCUMENTS_PATH);
}

const CONTAINER_PATH_MARKER = '@';
// https://regex101.com/r/PLdB0G/2
Expand Down Expand Up @@ -44,9 +121,12 @@ export async function parseContainerPath(
const typeSeparatorPos = bundleId.indexOf(CONTAINER_TYPE_SEPARATOR);
// We only consider container type exists if its length is greater than zero
// not counting the colon
if (typeSeparatorPos > 0 && typeSeparatorPos < bundleId.length - 1) {
containerType = bundleId.substring(typeSeparatorPos + 1);
this.log.debug(`Parsed container type: ${containerType}`);
if (typeSeparatorPos > 0) {
if (typeSeparatorPos < bundleId.length - 1) {
containerType = bundleId.substring(typeSeparatorPos + 1);
this.log.debug(`Parsed container type: ${containerType}`);
}
// Always strip the colon and everything after it
bundleId = bundleId.substring(0, typeSeparatorPos);
}
if (_.isNil(containerRootSupplier)) {
Expand Down Expand Up @@ -211,49 +291,66 @@ function verifyIsSubPath(originalPath: string, root: string): void {
}
}

interface AfcClientResult {
/** The AFC service instance */
afcService: any;
/** Cleanup function to close connections (for remotexpc) */
cleanup: () => Promise<void>;
}

async function createAfcClient(
this: XCUITestDriver,
bundleId?: string | null,
containerType?: string | null,
): Promise<any> {
): Promise<AfcClientResult> {
const udid = this.device.udid as string;
const useIos18 = isIos18OrNewer(this.opts);

if (!bundleId) {
return await services.startAfcService(udid);
}
const service = await services.startHouseArrestService(udid);
const {service: afcService} = bundleId
? await getHouseArrestAfcService(
udid,
bundleId,
containerType ?? null,
useIos18,
this.settings.getSettings().skipDocumentsContainerCheck ?? false,
)
: await getAfcService(udid, useIos18);

const {
skipDocumentsContainerCheck = false,
} = await this.settings.getSettings();
// Tunnel connections stay open for the session, no cleanup needed
const cleanup = async () => {};

if (skipDocumentsContainerCheck) {
return service.vendContainer(bundleId);
}

return isDocumentsContainer(containerType)
? await service.vendDocuments(bundleId)
: await service.vendContainer(bundleId);
return {afcService, cleanup, useIos18};
}

function isDocumentsContainer(containerType?: string | null): boolean {
return _.toLower(containerType ?? '') === _.toLower(CONTAINER_DOCUMENTS_PATH);
interface ServiceResult {
/** The AFC service instance */
service: any;
/** The relative path to the file/folder */
relativePath: string;
/** Cleanup function to close connections */
cleanup: () => Promise<void>;
/** Whether using iOS 18+ remotexpc */
useIos18: boolean;
}

async function createService(
this: XCUITestDriver,
remotePath: string,
): Promise<{service: any; relativePath: string}> {
): Promise<ServiceResult> {
if (CONTAINER_PATH_PATTERN.test(remotePath)) {
const {bundleId, pathInContainer, containerType} = await parseContainerPath.bind(this)(remotePath);
const service = await createAfcClient.bind(this)(bundleId, containerType);
const relativePath = isDocumentsContainer(containerType)
const { afcService, cleanup, useIos18 } = await createAfcClient.bind(this)(bundleId, containerType);
let relativePath = isDocumentsContainer(containerType)
? path.join(CONTAINER_DOCUMENTS_PATH, pathInContainer)
: pathInContainer;
return {service, relativePath};
// Ensure path starts with / for AFC operations
if (!relativePath.startsWith('/')) {
relativePath = `/${relativePath}`;
}
return {service: afcService, relativePath, cleanup, useIos18};
} else {
const service = await createAfcClient.bind(this)();
return {service, relativePath: remotePath};
const { afcService, cleanup, useIos18 } = await createAfcClient.bind(this)();
return {service: afcService, relativePath: remotePath, cleanup, useIos18};
}
}

Expand Down Expand Up @@ -312,14 +409,15 @@ async function pushFileToRealDevice(
remotePath: string,
base64Data: string,
): Promise<void> {
const {service, relativePath} = await createService.bind(this)(remotePath);
const {service, relativePath, cleanup} = await createService.bind(this)(remotePath);
try {
await realDevicePushFile(service, Buffer.from(base64Data, 'base64'), relativePath);
} catch (e) {
this.log.debug(e.stack);
throw new Error(`Could not push the file to '${remotePath}'. Original error: ${e.message}`);
} finally {
service.close();
await cleanup();
}
}

Expand Down Expand Up @@ -402,21 +500,30 @@ async function pullFromRealDevice(
remotePath: string,
isFile: boolean,
): Promise<string> {
const {service, relativePath} = await createService.bind(this)(remotePath);
const {service, relativePath, cleanup, useIos18} = await createService.bind(this)(remotePath);
try {
const fileInfo = await service.getFileInfo(relativePath);
if (isFile && fileInfo.isDirectory()) {
// Check if path is a directory
let isDirectory: boolean;
if (useIos18) {
isDirectory = await service.isdir(relativePath);
} else {
const fileInfo = await service.getFileInfo(relativePath);
isDirectory = fileInfo.isDirectory();
}

if (isFile && isDirectory) {
throw new Error(`The requested path is not a file. Path: '${remotePath}'`);
}
if (!isFile && !fileInfo.isDirectory()) {
if (!isFile && !isDirectory) {
throw new Error(`The requested path is not a folder. Path: '${remotePath}'`);
}

return fileInfo.isFile()
return !isDirectory
? (await realDevicePullFile(service, relativePath)).toString('base64')
: (await realDevicePullFolder(service, relativePath)).toString();
} finally {
service.close();
await cleanup();
}
}

Expand Down Expand Up @@ -476,16 +583,21 @@ async function deleteFromSimulator(this: XCUITestDriver, remotePath: string): Pr
* @returns Nothing
*/
async function deleteFromRealDevice(this: XCUITestDriver, remotePath: string): Promise<void> {
const {service, relativePath} = await createService.bind(this)(remotePath);
const {service, relativePath, cleanup, useIos18} = await createService.bind(this)(remotePath);
try {
await service.deleteDirectory(relativePath);
if (useIos18) {
await service.rm(relativePath, true);
} else {
await service.deleteDirectory(relativePath);
}
} catch (e) {
if (e.message.includes(OBJECT_NOT_FOUND_ERROR_MESSAGE)) {
throw new Error(`Path '${remotePath}' does not exist on the device`);
}
throw e;
} finally {
service.close();
await cleanup();
}
}

Loading