diff --git a/lib/common/definitions/mobile.d.ts b/lib/common/definitions/mobile.d.ts index 0d4b58638b..e5db0a23c1 100644 --- a/lib/common/definitions/mobile.d.ts +++ b/lib/common/definitions/mobile.d.ts @@ -258,8 +258,7 @@ declare global { * Describes different options for filtering device logs. */ interface IDeviceLogOptions - extends IDictionary, - Partial { + extends IDictionary, Partial { /** * Process id of the application on the device. */ @@ -284,8 +283,7 @@ declare global { * Describes required methods for getting iOS Simulator's logs. */ interface IiOSSimulatorLogProvider - extends NodeJS.EventEmitter, - IShouldDispose { + extends NodeJS.EventEmitter, IShouldDispose { /** * Starts the process for getting simulator logs and emits and DEVICE_LOG_EVENT_NAME event. * @param {string} deviceId The unique identifier of the device. @@ -429,6 +427,16 @@ declare global { interface IDeviceFileSystem { listFiles(devicePath: string, appIdentifier?: string): Promise; + /** + * Returns the entries of a directory inside the application's + * sandbox, or null when the directory cannot be read. Currently + * implemented only for physical iOS devices (AFC), where it backs + * the post-transfer livesync verification. + */ + getDirectoryEntries?( + devicePath: string, + appIdentifier: string, + ): Promise; getFile( deviceFilePath: string, appIdentifier: string, @@ -533,8 +541,7 @@ declare global { /** * Describes options that can be passed to devices service's initialization method. */ - interface IDevicesServicesInitializationOptions - extends Partial { + interface IDevicesServicesInitializationOptions extends Partial { /** * If passed will start an emulator if necesasry. */ @@ -1261,8 +1268,7 @@ declare global { } interface IDeviceLookingOptions - extends IHasEmulatorOption, - IHasDetectionInterval { + extends IHasEmulatorOption, IHasDetectionInterval { shouldReturnImmediateResult: boolean; platform: string; fullDiscovery?: boolean; @@ -1388,8 +1394,7 @@ declare global { /** * Describes information about application on device. */ - interface IDeviceApplicationInformation - extends IDeviceApplicationInformationBase { + interface IDeviceApplicationInformation extends IDeviceApplicationInformationBase { /** * The framework of the project (Cordova or NativeScript). */ diff --git a/lib/common/mobile/ios/device/ios-device-file-system.ts b/lib/common/mobile/ios/device/ios-device-file-system.ts index 705ba0d889..8c507dd0a1 100644 --- a/lib/common/mobile/ios/device/ios-device-file-system.ts +++ b/lib/common/mobile/ios/device/ios-device-file-system.ts @@ -9,12 +9,12 @@ export class IOSDeviceFileSystem implements Mobile.IDeviceFileSystem { private device: Mobile.IDevice, private $logger: ILogger, private $iosDeviceOperations: IIOSDeviceOperations, - private $fs: IFileSystem + private $fs: IFileSystem, ) {} public async listFiles( devicePath: string, - appIdentifier: string + appIdentifier: string, ): Promise { if (!devicePath) { devicePath = "."; @@ -31,10 +31,33 @@ export class IOSDeviceFileSystem implements Mobile.IDeviceFileSystem { this.$logger.info(children.join(EOL)); } + public async getDirectoryEntries( + devicePath: string, + appIdentifier: string, + ): Promise { + try { + const result = await this.$iosDeviceOperations.listDirectory([ + { + deviceId: this.device.deviceInfo.identifier, + path: devicePath, + appId: appIdentifier, + }, + ]); + const entries = + result?.[this.device.deviceInfo.identifier]?.[0]?.response; + return Array.isArray(entries) ? entries : null; + } catch (err) { + this.$logger.trace( + `Unable to list directory '${devicePath}' for application ${appIdentifier}: ${err.message}`, + ); + return null; + } + } + public async getFile( deviceFilePath: string, appIdentifier: string, - outputFilePath?: string + outputFilePath?: string, ): Promise { if (outputFilePath) { await this.$iosDeviceOperations.downloadFiles([ @@ -50,14 +73,14 @@ export class IOSDeviceFileSystem implements Mobile.IDeviceFileSystem { const fileContent = await this.getFileContent( deviceFilePath, - appIdentifier + appIdentifier, ); this.$logger.info(fileContent); } public async getFileContent( deviceFilePath: string, - appIdentifier: string + appIdentifier: string, ): Promise { const result = await this.$iosDeviceOperations.readFiles([ { @@ -73,7 +96,7 @@ export class IOSDeviceFileSystem implements Mobile.IDeviceFileSystem { public async putFile( localFilePath: string, deviceFilePath: string, - appIdentifier: string + appIdentifier: string, ): Promise { await this.uploadFilesCore([ { @@ -86,7 +109,7 @@ export class IOSDeviceFileSystem implements Mobile.IDeviceFileSystem { public async deleteFile( deviceFilePath: string, - appIdentifier: string + appIdentifier: string, ): Promise { await this.$iosDeviceOperations.deleteFiles( [ @@ -98,25 +121,25 @@ export class IOSDeviceFileSystem implements Mobile.IDeviceFileSystem { ], (err: IOSDeviceLib.IDeviceError) => { this.$logger.trace( - `Error while deleting file: ${deviceFilePath}: ${err.message} with code: ${err.code}` + `Error while deleting file: ${deviceFilePath}: ${err.message} with code: ${err.code}`, ); if (err.code !== IOSDeviceFileSystem.AFC_DELETE_FILE_NOT_FOUND_ERROR) { this.$logger.warn( - `Cannot delete file: ${deviceFilePath}. Reason: ${err.message}` + `Cannot delete file: ${deviceFilePath}. Reason: ${err.message}`, ); } - } + }, ); } public async transferFiles( deviceAppData: Mobile.IDeviceAppData, - localToDevicePaths: Mobile.ILocalToDevicePathData[] + localToDevicePaths: Mobile.ILocalToDevicePathData[], ): Promise { const filesToUpload: Mobile.ILocalToDevicePathData[] = _.filter( localToDevicePaths, - (l) => this.$fs.getFsStats(l.getLocalPath()).isFile() + (l) => this.$fs.getFsStats(l.getLocalPath()).isFile(), ); const files: IOSDeviceLib.IFileData[] = filesToUpload.map((l) => ({ source: l.getLocalPath(), @@ -137,7 +160,7 @@ export class IOSDeviceFileSystem implements Mobile.IDeviceFileSystem { public async transferDirectory( deviceAppData: Mobile.IDeviceAppData, localToDevicePaths: Mobile.ILocalToDevicePathData[], - projectFilesPath: string + projectFilesPath: string, ): Promise { await this.transferFiles(deviceAppData, localToDevicePaths); return localToDevicePaths; @@ -145,21 +168,35 @@ export class IOSDeviceFileSystem implements Mobile.IDeviceFileSystem { public async updateHashesOnDevice( hashes: IStringDictionary, - appIdentifier: string + appIdentifier: string, ): Promise { return; } private async uploadFilesCore( - filesToUpload: IOSDeviceLib.IUploadFilesData[] + filesToUpload: IOSDeviceLib.IUploadFilesData[], ): Promise { await this.$iosDeviceOperations.uploadFiles( filesToUpload, (err: IOSDeviceLib.IDeviceError) => { - if (err.deviceId === this.device.deviceInfo.identifier) { + // Previously an error whose deviceId did not exactly match was + // dropped on the floor — including errors with NO deviceId at + // all (some ios-device-lib error paths don't attribute one). + // That left "Successfully synced" printed over a failed + // transfer and the app silently running stale JavaScript. + // Rethrow unless the error is positively attributed to a + // DIFFERENT device; surface even those at warn level so a + // failed upload is never invisible. + if ( + !err.deviceId || + err.deviceId === this.device.deviceInfo.identifier + ) { throw err; } - } + this.$logger.warn( + `File upload error reported for another device (${err.deviceId}): ${err.message}`, + ); + }, ); } } diff --git a/lib/services/livesync/ios-livesync-service.ts b/lib/services/livesync/ios-livesync-service.ts index 2ead95e8ac..cedcf2b5c2 100644 --- a/lib/services/livesync/ios-livesync-service.ts +++ b/lib/services/livesync/ios-livesync-service.ts @@ -3,6 +3,7 @@ import * as path from "path"; import { IOSDeviceLiveSyncService } from "./ios-device-livesync-service"; import { PlatformLiveSyncServiceBase } from "./platform-livesync-service-base"; import { APP_FOLDER_NAME } from "../../constants"; +import { LiveSyncPaths } from "../../common/constants"; import { performanceLog } from "../../common/decorators"; import { IPlatformsDataService } from "../../definitions/platform"; import { @@ -27,7 +28,7 @@ export class IOSLiveSyncService private $tempService: ITempService, $devicePathProvider: IDevicePathProvider, $logger: ILogger, - $options: IOptions + $options: IOptions, ) { super( $fs, @@ -35,7 +36,7 @@ export class IOSLiveSyncService $platformsDataService, $projectFilesManager, $devicePathProvider, - $options + $options, ); } @@ -49,12 +50,12 @@ export class IOSLiveSyncService const projectData = syncInfo.projectData; const platformData = this.$platformsDataService.getPlatformData( device.deviceInfo.platform, - projectData + projectData, ); const deviceAppData = await this.getAppData(syncInfo); const projectFilesPath = path.join( platformData.appDestinationDirectoryPath, - APP_FOLDER_NAME + APP_FOLDER_NAME, ); const tempZip = await this.$tempService.path({ @@ -70,17 +71,155 @@ export class IOSLiveSyncService return path.join(APP_FOLDER_NAME, path.relative(projectFilesPath, res)); }); - await device.fileSystem.transferFiles(deviceAppData, [ - { - getLocalPath: () => tempZip, - getDevicePath: () => deviceAppData.deviceSyncZipPath, - getRelativeToProjectBasePath: () => "../sync.zip", - deviceProjectRootPath: await deviceAppData.getDeviceProjectRootPath(), - }, - ]); + const deviceProjectRootPath = + await deviceAppData.getDeviceProjectRootPath(); + const transferSyncZip = () => + device.fileSystem.transferFiles(deviceAppData, [ + { + getLocalPath: () => tempZip, + getDevicePath: () => deviceAppData.deviceSyncZipPath, + getRelativeToProjectBasePath: () => "../sync.zip", + deviceProjectRootPath, + }, + ]); + + // ── Fail-closed delivery verification ────────────────────────── + // + // The AFC transfer has been observed to fail without surfacing an + // error, leaving the app to boot the stale JavaScript baked into + // the installed .app payload with no indication anywhere that the + // sync was lost. After the transfer we therefore confirm the zip + // is actually present in the app sandbox; one retry covers + // transient AFC hiccups, and an unconfirmed delivery fails the + // sync loudly instead of printing "Successfully synced" over + // stale code (the run-controller surfaces the error; --clean + // reinstalls the full package). + // + // Presence alone is NOT sufficient evidence: a previous run that + // transferred the zip but aborted before the app restarted leaves + // a LEFTOVER sync.zip behind (the runtime only consumes it at + // boot), which would satisfy the check even when THIS upload + // failed. So any pre-existing zip is deleted up front — after + // that, post-transfer presence can only be produced by this run's + // upload. + // + // NOTE: deviceProjectRootPath is `.../LiveSync/app` (the extracted + // app folder); the zip is uploaded one level up, at the LiveSync + // root — the listing targets THAT directory. + // + // Escape hatch: NS_SKIP_IOS_SYNC_VERIFICATION=1 disables the + // whole verification for exotic setups where directory listing + // misbehaves but uploads are known-good. + const syncZipDevicePath = deviceAppData.deviceSyncZipPath; + const verificationSupported = + !!device.fileSystem.getDirectoryEntries && + process.env.NS_SKIP_IOS_SYNC_VERIFICATION !== "1"; + const listLiveSyncRoot = (): Promise => + device.fileSystem.getDirectoryEntries( + LiveSyncPaths.IOS_DEVICE_PROJECT_ROOT_PATH, + deviceAppData.appIdentifier, + ); + // Entry shape: ios-device-lib's native `read_dir` recursively joins + // `/` and returns FULL paths rooted at the + // requested directory (verified against IOSDeviceLib.cpp and live + // device output), so the canonical match is exact equality with + // `Library/Application Support/LiveSync/sync.zip`. The bare + // `"sync.zip"` equality is defensive cover for listing + // implementations that return root-relative names. Deliberately NO + // suffix matching — `endsWith("/sync.zip")` would false-positively + // accept a nested app asset like `.../LiveSync/app/sync.zip`. + const containsSyncZip = (entries: string[]): boolean => + entries.some( + (entry) => entry === syncZipDevicePath || entry === "sync.zip", + ); + // "delivered" / "missing" are definitive listings; "unknown" means + // the listing itself could not be read (after one retry). + const checkDelivery = async (): Promise< + "delivered" | "missing" | "unknown" + > => { + let entries = await listLiveSyncRoot(); + if (entries === null) { + entries = await listLiveSyncRoot(); + } + if (entries === null) { + return "unknown"; + } + return containsSyncZip(entries) ? "delivered" : "missing"; + }; + + let preListingAvailable = false; + let leftoverZipPresent = false; + if (verificationSupported) { + // Clear any leftover zip so the post-transfer check attributes + // presence to this run. Best-effort: AFC "file not found" is + // tolerated inside deleteFile. + await device.fileSystem.deleteFile( + syncZipDevicePath, + deviceAppData.appIdentifier, + ); + const preEntries = await listLiveSyncRoot(); + preListingAvailable = Array.isArray(preEntries); + leftoverZipPresent = preListingAvailable && containsSyncZip(preEntries); + } + + await transferSyncZip(); + + if (verificationSupported) { + if (leftoverZipPresent) { + // The pre-transfer delete did not take effect, so presence + // can no longer be attributed to this run. Most likely the + // upload succeeded too, but say so explicitly rather than + // claim verification. + this.$logger.warn( + "A leftover sync.zip from a previous run could not be removed — delivery verification for this sync is inconclusive. " + + "If the app runs stale code, re-run the command or use a clean rebuild (--clean).", + ); + } else { + let state = await checkDelivery(); + if (state === "missing") { + this.$logger.warn( + "sync.zip was not found on the device after transfer — retrying once...", + ); + await transferSyncZip(); + state = await checkDelivery(); + if (state === "delivered") { + this.$logger.info("sync.zip delivered on retry."); + } + } + if (state === "missing") { + throw new Error( + `Unable to deliver the application payload (sync.zip) to device ${device.deviceInfo.identifier}. ` + + `The app would run stale JavaScript without it. ` + + `Re-run the command, or use a clean rebuild (--clean) to reinstall the full application package.`, + ); + } + if (state === "unknown") { + if (preListingAvailable) { + // The listing worked moments before the upload and + // broke right after it — the AFC session is + // misbehaving at exactly the point where the upload + // itself is suspect. Fail closed. + throw new Error( + `Unable to confirm delivery of the application payload (sync.zip) to device ${device.deviceInfo.identifier}: ` + + `the device directory listing failed right after the transfer. ` + + `Re-run the command, or use a clean rebuild (--clean) to reinstall the full application package. ` + + `(Set NS_SKIP_IOS_SYNC_VERIFICATION=1 to bypass delivery verification.)`, + ); + } + // Listing was unavailable both before and after the + // transfer — verification is unsupported for this + // device/session. This is the single fail-open path, + // and it is loud rather than silent. + this.$logger.warn( + "Could not verify sync.zip delivery (device directory listing unavailable). " + + "If the transfer failed, the app will run stale JavaScript — re-run the command or use a clean rebuild (--clean).", + ); + } + } + } await deviceAppData.device.applicationManager.setTransferredAppFiles( - filesToTransfer + filesToTransfer, ); return { @@ -93,7 +232,7 @@ export class IOSLiveSyncService public async syncAfterInstall( device: Mobile.IDevice, - liveSyncInfo: ILiveSyncWatchInfo + liveSyncInfo: ILiveSyncWatchInfo, ): Promise { if (!device.isEmulator) { // In this case we should execute fullsync because iOS Runtime requires the full content of app dir to be extracted in the root of sync dir. @@ -109,11 +248,11 @@ export class IOSLiveSyncService protected _getDeviceLiveSyncService( device: Mobile.IDevice, - data: IProjectDir + data: IProjectDir, ): INativeScriptDeviceLiveSyncService { const service = this.$injector.resolve( IOSDeviceLiveSyncService, - { device, data } + { device, data }, ); return service; }