diff --git a/lib/common/bootstrap.ts b/lib/common/bootstrap.ts index ba51125a76..007afba0a7 100644 --- a/lib/common/bootstrap.ts +++ b/lib/common/bootstrap.ts @@ -200,6 +200,10 @@ injector.require( "iOSSimulatorDiscovery", "./mobile/mobile-core/ios-simulator-discovery", ); +injector.require( + "iOSMacDeviceDiscovery", + "./mobile/mobile-core/ios-mac-device-discovery", +); injector.require( "androidDeviceDiscovery", "./mobile/mobile-core/android-device-discovery", diff --git a/lib/common/mobile/ios/mac/ios-mac-application-manager.ts b/lib/common/mobile/ios/mac/ios-mac-application-manager.ts new file mode 100644 index 0000000000..5b2e828583 --- /dev/null +++ b/lib/common/mobile/ios/mac/ios-mac-application-manager.ts @@ -0,0 +1,175 @@ +import * as path from "path"; +import { ApplicationManagerBase } from "../../application-manager-base"; +import { hook, sleep } from "../../../helpers"; +import { cache } from "../../../decorators"; +import { IOS_LOG_PREDICATE } from "../../../constants"; +import { + IChildProcess, + IDictionary, + IFileSystem, + IHooksService, + IPlistParser, +} from "../../../declarations"; +import { IOptions } from "../../../../declarations"; + +interface IIOSMacApplicationHost extends Mobile.IiOSDevice { + readonly wrapperPath: string; + readonly applicationBundlePath: string; + readonly executableName: string; +} + +export class IOSMacApplicationManager extends ApplicationManagerBase { + constructor( + private device: IIOSMacApplicationHost, + private $childProcess: IChildProcess, + private $fs: IFileSystem, + private $options: IOptions, + private $plistParser: IPlistParser, + protected $deviceLogProvider: Mobile.IDeviceLogProvider, + $logger: ILogger, + $hooksService: IHooksService, + ) { + super($logger, $hooksService, $deviceLogProvider); + } + + public async getInstalledApplications(): Promise { + const infoPlistPath = path.join( + this.device.applicationBundlePath, + "Info.plist", + ); + if (!this.$fs.exists(infoPlistPath)) { + return []; + } + + const infoPlist = await this.$plistParser.parseFile(infoPlistPath); + return infoPlist?.CFBundleIdentifier ? [infoPlist.CFBundleIdentifier] : []; + } + + @hook("install") + public async installApplication(packageFilePath: string): Promise { + const wrapperPath = this.device.wrapperPath; + this.$fs.deleteDirectory(wrapperPath); + this.$fs.ensureDirectoryExists(path.join(wrapperPath, "Wrapper")); + // ditto keeps the bundle's symlinks and extended attributes, which its + // code signature covers. + await this.$childProcess.spawnFromEvent( + "ditto", + [packageFilePath, this.device.applicationBundlePath], + "close", + ); + this.$fs.symlink( + path.join("Wrapper", path.basename(this.device.applicationBundlePath)), + path.join(wrapperPath, "WrappedBundle"), + ); + } + + public async uninstallApplication(appIdentifier: string): Promise { + await this.stopApplication({ + appId: appIdentifier, + projectName: null, + projectDir: null, + }); + this.$fs.deleteDirectory(this.device.wrapperPath); + } + + public async startApplication( + appData: Mobile.IStartApplicationData, + ): Promise { + await this.setDeviceLogData(appData); + + const args = ["-n", this.device.wrapperPath]; + if (appData.waitForDebugger) { + args.push("--args", "--nativescript-debug-brk"); + } + await this.$childProcess.spawnFromEvent("open", args, "close"); + + const [pid] = await this.getRunningPids(); + if (pid) { + this.$deviceLogProvider.setApplicationPidForDevice( + this.device.deviceInfo.identifier, + pid, + ); + } + } + + public async stopApplication( + appData: Mobile.IApplicationData, + ): Promise { + await this.device.destroyDebugSocket(appData.appId); + + for (const signal of ["TERM", "KILL"]) { + const pids = await this.getRunningPids(); + if (!pids.length) { + return; + } + + await this.$childProcess + .spawnFromEvent("kill", [`-${signal}`, ...pids], "close") + .catch((err) => this.$logger.trace(err)); + + // `open -n` while the old instance is still exiting starts a second copy. + for (let attempt = 0; attempt < 40; attempt++) { + if (!(await this.getRunningPids()).length) { + return; + } + await sleep(50); + } + } + } + + public async getDebuggableApps(): Promise< + Mobile.IDeviceApplicationInformation[] + > { + return []; + } + + public async getDebuggableAppViews( + appIdentifiers: string[], + ): Promise> { + return null; + } + + /** + * macOS runs the app from a copy of the wrapper under /private/var/folders, + * so match the bundle layout rather than the path the CLI installed to. + */ + private async getRunningPids(): Promise { + const name = this.device.executableName.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ); + try { + const result = await this.$childProcess.spawnFromEvent( + "pgrep", + ["-f", `/Wrapper/${name}\\.app/${name}( |$)`], + "close", + ); + return result.stdout.split(/\s+/).filter(Boolean); + } catch (err) { + // pgrep exits with 1 when nothing matches. + return []; + } + } + + private async setDeviceLogData( + appData: Mobile.IApplicationData, + ): Promise { + this.$deviceLogProvider.setProjectNameForDevice( + this.device.deviceInfo.identifier, + appData.projectName, + ); + this.$deviceLogProvider.setProjectDirForDevice( + this.device.deviceInfo.identifier, + appData.projectDir, + ); + + if (!this.$options.justlaunch) { + await this.startDeviceLog(); + } + } + + @cache() + private startDeviceLog(): Promise { + return this.device.openDeviceLogStream({ predicate: IOS_LOG_PREDICATE }); + } +} diff --git a/lib/common/mobile/ios/mac/ios-mac-constants.ts b/lib/common/mobile/ios/mac/ios-mac-constants.ts new file mode 100644 index 0000000000..9f50924419 --- /dev/null +++ b/lib/common/mobile/ios/mac/ios-mac-constants.ts @@ -0,0 +1,40 @@ +import * as semver from "semver"; + +/** + * `--device mac` selects this Mac; it is also the device's imageIdentifier. + */ +export const IOS_MAC_DEVICE_ALIAS = "mac"; + +/** + * Builds for the Mac go to platforms/ios/build/mac so they never mix with the + * archive and .ipa of a phone build, which share the iphoneos SDK. + */ +export const IOS_MAC_BUILD_DIR_NAME = "mac"; + +export function isIOSMacDevice(device: Mobile.IDevice): boolean { + return device?.deviceInfo?.imageIdentifier === IOS_MAC_DEVICE_ALIAS; +} + +/** + * Whether this @nativescript/ios version loads UIKit from /System/iOSSupport + * on a Mac (NativeScript/ios#551). Older runtimes stop at launch with + * "UIApplicationDidFinishLaunchingNotification is not defined". + * Returns true for versions it can't parse, so only a known-bad one warns. + */ +export function iOSRuntimeCanRunOnMac(version: string): boolean { + const next = /^\d+\.\d+\.\d+-next\.(\d{4}-\d{2}-\d{2})-(\d+)$/.exec( + version ?? "", + ); + if (next) { + const [, date, runId] = next; + const firstDate = "2026-11-11"; + const firstRunId = 43080423883; + return date > firstDate || (date === firstDate && +runId >= firstRunId); + } + + if (!semver.valid(version)) { + return true; + } + + return semver.gte(version, "9.1.1"); +} diff --git a/lib/common/mobile/ios/mac/ios-mac-device.ts b/lib/common/mobile/ios/mac/ios-mac-device.ts new file mode 100644 index 0000000000..9b0de3fa62 --- /dev/null +++ b/lib/common/mobile/ios/mac/ios-mac-device.ts @@ -0,0 +1,176 @@ +import { ChildProcess } from "child_process"; +import * as net from "net"; +import * as path from "path"; +import { IOSDeviceBase } from "../ios-device-base"; +import { IOSMacApplicationManager } from "./ios-mac-application-manager"; +import { IOSMacFileSystem } from "./ios-mac-file-system"; +import * as constants from "../../../constants"; +import * as helpers from "../../../helpers"; +import { cache } from "../../../decorators"; +import { BUILD_DIR, DeviceConnectionType } from "../../../../constants"; +import { IiOSNotification } from "../../../../declarations"; +import { IChildProcess, IErrors } from "../../../declarations"; +import { IInjector } from "../../../definitions/yok"; +import { IPlatformsDataService } from "../../../../definitions/platform"; +import { IProjectDataService } from "../../../../definitions/project"; +import { + IOS_MAC_BUILD_DIR_NAME, + IOS_MAC_DEVICE_ALIAS, +} from "./ios-mac-constants"; + +export interface IIOSMacHardware { + provisioningUDID: string; + model: string; + osVersion: string; +} + +/** + * This Apple silicon Mac, running the iOS app as "Designed for iPad". + * + * The app is the signed iphoneos build, but it runs on this machine, so + * deploy, sync, logs and the debugger all work locally the way they do for a + * simulator; isEmulator is true for that reason. + */ +export class IOSMacDevice extends IOSDeviceBase { + public applicationManager: IOSMacApplicationManager; + public fileSystem: Mobile.IDeviceFileSystem; + public deviceInfo: Mobile.IDeviceInfo; + + private logProcess: ChildProcess; + + constructor( + hardware: IIOSMacHardware, + private $childProcess: IChildProcess, + private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, + protected $deviceLogProvider: Mobile.IDeviceLogProvider, + protected $errors: IErrors, + protected $lockService: ILockService, + private $injector: IInjector, + protected $iOSDebuggerPortService: IIOSDebuggerPortService, + private $iOSNotification: IiOSNotification, + private $platformsDataService: IPlatformsDataService, + private $projectDataService: IProjectDataService, + protected $logger: ILogger, + ) { + super(); + this.applicationManager = this.$injector.resolve( + IOSMacApplicationManager, + { device: this }, + ); + this.fileSystem = this.$injector.resolve(IOSMacFileSystem); + this.deviceInfo = { + imageIdentifier: IOS_MAC_DEVICE_ALIAS, + identifier: hardware.provisioningUDID, + displayName: "My Mac (Designed for iPad)", + model: hardware.model, + version: hardware.osVersion, + vendor: "Apple", + platform: this.$devicePlatformsConstants.iOS, + status: constants.CONNECTED_STATUS, + errorHelp: null, + isTablet: true, + type: constants.DeviceTypes.Device, + connectionTypes: [DeviceConnectionType.Local], + }; + } + + public get isEmulator(): boolean { + return true; + } + + public get isOnlyWiFiConnected(): boolean { + return false; + } + + /** + * macOS only launches an iOS app from the layout it gives installed iPad + * apps: .app/Wrapper/.app plus a WrappedBundle symlink. + */ + public get wrapperPath(): string { + const projectData = this.$projectDataService.getProjectData(); + const platformData = this.$platformsDataService.getPlatformData( + this.$devicePlatformsConstants.iOS.toLowerCase(), + projectData, + ); + return path.join( + platformData.projectRoot, + BUILD_DIR, + IOS_MAC_BUILD_DIR_NAME, + `${projectData.projectName}.app`, + ); + } + + public get applicationBundlePath(): string { + return path.join( + this.wrapperPath, + "Wrapper", + path.basename(this.wrapperPath), + ); + } + + public get executableName(): string { + return path.basename(this.wrapperPath, ".app"); + } + + @cache() + public async openDeviceLogStream( + options?: Mobile.IiOSLogStreamOptions, + ): Promise { + const predicate = + options && options.hasOwnProperty("predicate") + ? options.predicate + : constants.IOS_LOG_PREDICATE; + const processPredicate = `process == "${this.executableName}"`; + this.logProcess = this.$childProcess.spawn("/usr/bin/log", [ + "stream", + "--style", + "syslog", + "--predicate", + predicate ? `${processPredicate} AND ${predicate}` : processPredicate, + ]); + + const action = (data: Buffer | string) => { + this.$deviceLogProvider.logData( + data.toString(), + this.$devicePlatformsConstants.iOS, + this.deviceInfo.identifier, + ); + }; + this.logProcess.stdout?.on("data", action); + this.logProcess.stderr?.on("data", action); + this.logProcess.once("error", (err) => + this.$logger.trace(`Unable to stream the Mac log: ${err.message}`), + ); + } + + public dispose(): void { + this.logProcess?.kill(); + } + + protected async getDebugSocketCore(appId: string): Promise { + const attachRequest = this.$iOSNotification.getAttachRequest( + appId, + this.deviceInfo.identifier, + ); + const postAttachRequest = () => + this.$childProcess + .spawnFromEvent("notifyutil", ["-p", attachRequest], "close") + .catch((err) => this.$logger.trace(err)); + + await postAttachRequest(); + // The app may not have registered for the notification yet. + const retry = setInterval(postAttachRequest, 5e3); + const port = await super + .getDebuggerPort(appId) + .finally(() => clearInterval(retry)); + + try { + return await helpers.connectEventuallyUntilTimeout( + async () => net.connect(port, "127.0.0.1"), + constants.SOCKET_CONNECTION_TIMEOUT_MS, + ); + } catch (err) { + this.$logger.warn(err); + } + } +} diff --git a/lib/common/mobile/ios/mac/ios-mac-file-system.ts b/lib/common/mobile/ios/mac/ios-mac-file-system.ts new file mode 100644 index 0000000000..6ba2f35ca9 --- /dev/null +++ b/lib/common/mobile/ios/mac/ios-mac-file-system.ts @@ -0,0 +1,17 @@ +import * as fs from "fs"; +import { IOSSimulatorFileSystem } from "../simulator/ios-simulator-file-system"; +import { IFileSystem } from "../../../declarations"; + +/** + * The installed app is a directory on this machine, so every transfer is a + * local copy, the same as on a simulator. + */ +export class IOSMacFileSystem extends IOSSimulatorFileSystem { + constructor($fs: IFileSystem, $logger: ILogger) { + super(null, $fs, $logger); + } + + public async listFiles(devicePath: string): Promise { + return fs.readdirSync(devicePath); + } +} diff --git a/lib/common/mobile/mobile-core/devices-service.ts b/lib/common/mobile/mobile-core/devices-service.ts index 15bf11787f..9bb4ec89d6 100644 --- a/lib/common/mobile/mobile-core/devices-service.ts +++ b/lib/common/mobile/mobile-core/devices-service.ts @@ -41,6 +41,7 @@ export class DevicesService private $errors: IErrors, private $iOSSimulatorDiscovery: Mobile.IiOSSimulatorDiscovery, private $iOSDeviceDiscovery: Mobile.IDeviceDiscovery, + private $iOSMacDeviceDiscovery: Mobile.IDeviceDiscovery, private $androidDeviceDiscovery: Mobile.IDeviceDiscovery, private $staticConfig: Config.IStaticConfig, private $messages: IMessages, @@ -64,6 +65,7 @@ export class DevicesService this.$iOSDeviceDiscovery, this.$androidDeviceDiscovery, this.$iOSSimulatorDiscovery, + this.$iOSMacDeviceDiscovery, ]; } @@ -324,6 +326,7 @@ export class DevicesService this.$iOSSimulatorDiscovery, this.$iOSDeviceDiscovery, this.$androidDeviceDiscovery, + this.$iOSMacDeviceDiscovery, ].forEach(this.attachToDeviceDiscoveryEvents.bind(this)); } @@ -823,7 +826,8 @@ export class DevicesService const activeDeviceInstance = _.find( deviceInstances, (device: Mobile.IDevice) => - device.deviceInfo.identifier === data.deviceId, + device.deviceInfo.identifier === data.deviceId || + device.deviceInfo.imageIdentifier === data.deviceId, ); if (!activeDeviceInstance) { return this.startEmulatorCore(data); diff --git a/lib/common/mobile/mobile-core/ios-mac-device-discovery.ts b/lib/common/mobile/mobile-core/ios-mac-device-discovery.ts new file mode 100644 index 0000000000..53bf6ce7ef --- /dev/null +++ b/lib/common/mobile/mobile-core/ios-mac-device-discovery.ts @@ -0,0 +1,105 @@ +import { DeviceDiscovery } from "./device-discovery"; +import { IIOSMacHardware, IOSMacDevice } from "../ios/mac/ios-mac-device"; +import { IOS_MAC_DEVICE_ALIAS } from "../ios/mac/ios-mac-constants"; +import { IChildProcess, IHostInfo } from "../../declarations"; +import { IInjector } from "../../definitions/yok"; +import { IOptions } from "../../../declarations"; +import { injector } from "../../yok"; + +/** + * Offers this Apple silicon Mac as an iOS device ("My Mac (Designed for + * iPad)"). It is listed by `ns devices` and used only when `--device` names + * it, so a plain `ns run ios` never launches the app on the Mac as well. + */ +export class IOSMacDeviceDiscovery extends DeviceDiscovery { + private hardware: IIOSMacHardware; + private isDeviceAdded = false; + + constructor( + private $childProcess: IChildProcess, + private $hostInfo: IHostInfo, + private $injector: IInjector, + private $logger: ILogger, + private $mobileHelper: Mobile.IMobileHelper, + private $options: IOptions, + ) { + super(); + } + + public async startLookingForDevices( + options?: Mobile.IDeviceLookingOptions, + ): Promise { + if (this.isDeviceAdded || !this.$hostInfo.isDarwin) { + return; + } + + if ( + options?.platform && + !this.$mobileHelper.isiOSPlatform(options.platform) + ) { + return; + } + + if (options?.emulator) { + return; + } + + const hardware = await this.getHardware(); + if (!hardware) { + return; + } + + const requested = this.$options.device; + const isRequested = + requested === IOS_MAC_DEVICE_ALIAS || + requested === hardware.provisioningUDID; + if (!options?.fullDiscovery && !isRequested) { + return; + } + + this.addDevice(this.$injector.resolve(IOSMacDevice, { hardware })); + this.isDeviceAdded = true; + } + + private async getHardware(): Promise { + if (this.hardware !== undefined) { + return this.hardware; + } + + this.hardware = null; + try { + // Intel Macs can't run iOS apps; Rosetta hides arm64 from process.arch. + const arm64 = await this.$childProcess.exec("sysctl -n hw.optional.arm64"); + if (arm64.toString().trim() !== "1") { + return this.hardware; + } + + const profile = JSON.parse( + ( + await this.$childProcess.exec( + "system_profiler SPHardwareDataType -json", + ) + ).toString(), + ); + const info = profile?.SPHardwareDataType?.[0]; + const osVersion = ( + await this.$childProcess.exec("sw_vers -productVersion") + ) + .toString() + .trim(); + if (info?.provisioning_UDID) { + this.hardware = { + provisioningUDID: info.provisioning_UDID, + model: info.machine_model || "Mac", + osVersion, + }; + } + } catch (err) { + this.$logger.trace(`Unable to read this Mac's hardware info: ${err}`); + } + + return this.hardware; + } +} + +injector.register("iOSMacDeviceDiscovery", IOSMacDeviceDiscovery); diff --git a/lib/common/test/unit-tests/mobile/devices-service.ts b/lib/common/test/unit-tests/mobile/devices-service.ts index ae319ce0b9..5689dd79b5 100644 --- a/lib/common/test/unit-tests/mobile/devices-service.ts +++ b/lib/common/test/unit-tests/mobile/devices-service.ts @@ -201,6 +201,7 @@ function createTestInjector(): IInjector { testInjector.register("errors", ErrorsStub); testInjector.register("iOSDeviceDiscovery", IOSDeviceDiscoveryStub); testInjector.register("iOSSimulatorDiscovery", IOSSimulatorDiscoveryStub); + testInjector.register("iOSMacDeviceDiscovery", IOSDeviceDiscoveryStub); testInjector.register("androidDeviceDiscovery", AndroidDeviceDiscoveryStub); testInjector.register("staticConfig", { CLIENT_NAME: "unit-tests" }); testInjector.register("devicePlatformsConstants", DevicePlatformsConstants); diff --git a/lib/common/test/unit-tests/mobile/ios-mac-device-discovery.ts b/lib/common/test/unit-tests/mobile/ios-mac-device-discovery.ts new file mode 100644 index 0000000000..8f8bf4684d --- /dev/null +++ b/lib/common/test/unit-tests/mobile/ios-mac-device-discovery.ts @@ -0,0 +1,143 @@ +import { assert } from "chai"; +import { IOSMacDeviceDiscovery } from "../../../mobile/mobile-core/ios-mac-device-discovery"; +import { + iOSRuntimeCanRunOnMac, + isIOSMacDevice, +} from "../../../mobile/ios/mac/ios-mac-constants"; +import { Yok } from "../../../yok"; +import { DeviceDiscoveryEventNames } from "../../../constants"; +import { DevicePlatformsConstants } from "../../../mobile/device-platforms-constants"; +import { + CommonLoggerStub, + ErrorsStub, + HooksServiceStub, + LockServiceStub, +} from "../stubs"; +import { FileSystemStub } from "../../../../../test/stubs"; +import { IInjector } from "../../../definitions/yok"; + +const provisioningUDID = "00006031-0012716E2262001C"; + +function createTestInjector(opts: { + arm64?: boolean; + isDarwin?: boolean; + device?: string; +}): IInjector { + const injector = new Yok(); + injector.register("injector", injector); + injector.register("fs", FileSystemStub); + injector.register("plistParser", {}); + injector.register("errors", ErrorsStub); + injector.register("logger", CommonLoggerStub); + injector.register("hooksService", HooksServiceStub); + injector.register("lockService", LockServiceStub); + injector.register("iOSDebuggerPortService", {}); + injector.register("iOSNotification", {}); + injector.register("deviceLogProvider", {}); + injector.register("platformsDataService", {}); + injector.register("projectDataService", {}); + injector.register("devicePlatformsConstants", DevicePlatformsConstants); + injector.register("hostInfo", { isDarwin: opts.isDarwin ?? true }); + injector.register("options", { device: opts.device }); + injector.register("mobileHelper", { + isiOSPlatform: (platform: string) => platform.toLowerCase() === "ios", + }); + injector.register("childProcess", { + exec: async (command: string) => { + if (command.startsWith("sysctl")) { + return opts.arm64 === false ? "0\n" : "1\n"; + } + if (command.startsWith("system_profiler")) { + return JSON.stringify({ + SPHardwareDataType: [ + { provisioning_UDID: provisioningUDID, machine_model: "Mac15,8" }, + ], + }); + } + return "26.6.2\n"; + }, + }); + injector.register("iOSMacDeviceDiscovery", IOSMacDeviceDiscovery); + return injector; +} + +async function discover( + injector: IInjector, + options: Partial, +): Promise { + const discovery = injector.resolve( + "iOSMacDeviceDiscovery", + ); + const found: Mobile.IDevice[] = []; + discovery.on(DeviceDiscoveryEventNames.DEVICE_FOUND, (d: Mobile.IDevice) => + found.push(d), + ); + await discovery.startLookingForDevices(options); + return found; +} + +describe("iOSMacDeviceDiscovery", () => { + it("lists the Mac for `ns devices`", async () => { + const [device] = await discover(createTestInjector({}), { + platform: "ios", + fullDiscovery: true, + }); + + assert.isOk(device); + assert.isTrue(isIOSMacDevice(device)); + assert.equal(device.deviceInfo.identifier, provisioningUDID); + assert.equal(device.deviceInfo.platform, "iOS"); + }); + + it("adds the Mac when --device is mac or its UDID", async () => { + for (const deviceOption of ["mac", provisioningUDID]) { + const found = await discover( + createTestInjector({ device: deviceOption }), + { platform: "ios" }, + ); + assert.lengthOf(found, 1, deviceOption); + } + }); + + it("leaves the Mac out of a run that doesn't name it", async () => { + const found = await discover(createTestInjector({}), { platform: "ios" }); + assert.lengthOf(found, 0); + }); + + it("skips Intel Macs, other hosts, other platforms and --emulator", async () => { + const cases: [Parameters[0], any][] = [ + [{ device: "mac", arm64: false }, { platform: "ios" }], + [{ device: "mac", isDarwin: false }, { platform: "ios" }], + [{ device: "mac" }, { platform: "android" }], + [{ device: "mac" }, { platform: "ios", emulator: true }], + ]; + for (const [injectorOptions, lookingOptions] of cases) { + const found = await discover( + createTestInjector(injectorOptions), + lookingOptions, + ); + assert.lengthOf(found, 0, JSON.stringify([injectorOptions, lookingOptions])); + } + }); +}); + +describe("iOSRuntimeCanRunOnMac", () => { + it("accepts releases from 9.1.1 and next builds from NativeScript/ios#551 on", () => { + assert.isTrue(iOSRuntimeCanRunOnMac("9.1.1")); + assert.isTrue(iOSRuntimeCanRunOnMac("9.2.0")); + assert.isTrue(iOSRuntimeCanRunOnMac("9.1.0-next.2026-11-11-43080423883")); + assert.isTrue(iOSRuntimeCanRunOnMac("9.1.0-next.2026-11-12-43100000000")); + }); + + it("rejects 9.1.0 and next builds before NativeScript/ios#551", () => { + assert.isFalse(iOSRuntimeCanRunOnMac("9.1.0")); + assert.isFalse(iOSRuntimeCanRunOnMac("8.9.2")); + assert.isFalse(iOSRuntimeCanRunOnMac("9.1.0-next.2026-11-11-43075498690")); + assert.isFalse(iOSRuntimeCanRunOnMac("9.1.0-next.2026-11-10-42915841411")); + }); + + it("doesn't warn about versions it can't parse", () => { + assert.isTrue(iOSRuntimeCanRunOnMac("file:../ios")); + assert.isTrue(iOSRuntimeCanRunOnMac(undefined)); + }); +}); diff --git a/lib/controllers/deploy-controller.ts b/lib/controllers/deploy-controller.ts index 68607f2a54..77a17a141e 100644 --- a/lib/controllers/deploy-controller.ts +++ b/lib/controllers/deploy-controller.ts @@ -1,5 +1,6 @@ import * as _ from "lodash"; import { injector } from "../common/yok"; +import { isIOSMacDevice } from "../common/mobile/ios/mac/ios-mac-constants"; export class DeployController implements IDeployController { constructor( @@ -26,7 +27,11 @@ export class DeployController implements IDeployController { const packageFilePath = await deviceDescriptor.buildAction(); await this.$deviceInstallAppService.installOnDevice( device, - { ...deviceDescriptor.buildData, buildForDevice: !device.isEmulator }, + { + ...deviceDescriptor.buildData, + buildForDevice: !device.isEmulator, + buildForMac: isIOSMacDevice(device), + }, packageFilePath, ); }; diff --git a/lib/controllers/run-controller.ts b/lib/controllers/run-controller.ts index 232f3799d6..4a1b808377 100644 --- a/lib/controllers/run-controller.ts +++ b/lib/controllers/run-controller.ts @@ -25,6 +25,7 @@ import { import { IInjector } from "../common/definitions/yok"; import { ViteHmrPortService } from "../contracts/vite-hmr-port-service"; import { injector } from "../common/yok"; +import { isIOSMacDevice } from "../common/mobile/ios/mac/ios-mac-constants"; export class RunController extends EventEmitter implements IRunController { private prepareReadyEventHandler: any = null; @@ -635,6 +636,7 @@ export class RunController extends EventEmitter implements IRunController { const buildData = { ...deviceDescriptor.buildData, buildForDevice: !device.isEmulator, + buildForMac: isIOSMacDevice(device), }; const platformData = this.$platformsDataService.getPlatformData( device.deviceInfo.platform, diff --git a/lib/data/build-data.ts b/lib/data/build-data.ts index 2dc9ba1546..09370fb7e5 100644 --- a/lib/data/build-data.ts +++ b/lib/data/build-data.ts @@ -7,6 +7,7 @@ export class BuildData extends PrepareData implements IBuildData { public emulator?: boolean; public clean: boolean; public buildForDevice?: boolean; + public buildForMac?: boolean; public buildOutputStdio?: string; public outputPath?: string; public copyTo?: string; @@ -19,6 +20,7 @@ export class BuildData extends PrepareData implements IBuildData { this.emulator = data.emulator; this.clean = data.clean; this.buildForDevice = data.buildForDevice || data.forDevice; + this.buildForMac = data.buildForMac; this.buildOutputStdio = data.buildOutputStdio; this.outputPath = data.outputPath; this.copyTo = data.copyTo; diff --git a/lib/definitions/build.d.ts b/lib/definitions/build.d.ts index 391cb672c1..b5db685882 100644 --- a/lib/definitions/build.d.ts +++ b/lib/definitions/build.d.ts @@ -13,6 +13,7 @@ interface IBuildData extends IPrepareData { emulator?: boolean; clean: boolean; buildForDevice?: boolean; + buildForMac?: boolean; buildForAppStore?: boolean; buildOutputStdio?: string; outputPath?: string; diff --git a/lib/definitions/ios.d.ts b/lib/definitions/ios.d.ts index 60c3430bbb..67fbeae21f 100644 --- a/lib/definitions/ios.d.ts +++ b/lib/definitions/ios.d.ts @@ -24,6 +24,11 @@ declare global { } interface IXcodebuildService { + buildForMac( + platformData: IPlatformData, + projectData: IProjectData, + buildConfig: IBuildConfig, + ): Promise; buildForSimulator( platformData: IPlatformData, projectData: IProjectData, @@ -100,6 +105,11 @@ declare global { } interface IXcodebuildArgsService { + getBuildForMacArgs( + platformData: IPlatformData, + projectData: IProjectData, + buildConfig: IBuildConfig, + ): Promise; getBuildForSimulatorArgs( platformData: IPlatformData, projectData: IProjectData, diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index cd2a931617..659149c4bb 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -465,6 +465,10 @@ interface IPlatformProjectServiceBase { interface IBuildForDevice { buildForDevice: boolean; + /** + * Build the iOS app for this Mac ("My Mac (Designed for iPad)"). + */ + buildForMac?: boolean; } interface IiCloudContainerEnvironment { diff --git a/lib/device-path-provider.ts b/lib/device-path-provider.ts index 6995c41b31..20c00406a6 100644 --- a/lib/device-path-provider.ts +++ b/lib/device-path-provider.ts @@ -4,6 +4,8 @@ import { LiveSyncPaths } from "./common/constants"; import * as path from "path"; import { IErrors } from "./common/declarations"; import { injector } from "./common/yok"; +import { isIOSMacDevice } from "./common/mobile/ios/mac/ios-mac-constants"; +import type { IOSMacDevice } from "./common/mobile/ios/mac/ios-mac-device"; export class DevicePathProvider implements IDevicePathProvider { constructor( @@ -17,7 +19,14 @@ export class DevicePathProvider implements IDevicePathProvider { options: IDeviceProjectRootOptions ): Promise { let projectRoot = ""; - if (this.$mobileHelper.isApplePlatform(device.deviceInfo.platform)) { + if (isIOSMacDevice(device)) { + projectRoot = (device).applicationBundlePath; + if (!options.getDirname) { + projectRoot = path.join(projectRoot, APP_FOLDER_NAME); + } + } else if ( + this.$mobileHelper.isApplePlatform(device.deviceInfo.platform) + ) { projectRoot = device.isEmulator ? await this.$iOSSimResolver.iOSSim.getApplicationPath( device.deviceInfo.identifier, diff --git a/lib/helpers/deploy-command-helper.ts b/lib/helpers/deploy-command-helper.ts index ee2a33473a..f9763f0f2d 100644 --- a/lib/helpers/deploy-command-helper.ts +++ b/lib/helpers/deploy-command-helper.ts @@ -4,6 +4,7 @@ import { IAndroidBundleValidatorHelper, IOptions } from "../declarations"; import { IBuildDataService } from "../definitions/build"; import { IProjectData } from "../definitions/project"; import { injector } from "../common/yok"; +import { isIOSMacDevice } from "../common/mobile/ios/mac/ios-mac-constants"; export class DeployCommandHelper { constructor( @@ -54,6 +55,7 @@ export class DeployCommandHelper { ...this.$options.argv, outputPath, buildForDevice: !d.isEmulator, + buildForMac: isIOSMacDevice(d), skipWatcher: !this.$options.watch, nativePrepare: { skipNativePrepare: diff --git a/lib/helpers/livesync-command-helper.ts b/lib/helpers/livesync-command-helper.ts index fc1745a64e..6ab147b424 100644 --- a/lib/helpers/livesync-command-helper.ts +++ b/lib/helpers/livesync-command-helper.ts @@ -14,6 +14,7 @@ import { IBuildController, IBuildDataService } from "../definitions/build"; import { ICleanupService } from "../definitions/cleanup-service"; import { IPlatformsDataService } from "../definitions/platform"; import { IProjectData, IValidatePlatformOutput } from "../definitions/project"; +import { isIOSMacDevice } from "../common/mobile/ios/mac/ios-mac-constants"; export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { constructor( @@ -100,6 +101,7 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { ...this.$options.argv, outputPath, buildForDevice: !d.isEmulator, + buildForMac: isIOSMacDevice(d), watch: !this.$options.release && this.$options.watch, nativePrepare: { forceRebuildNativeApp: additionalOptions.forceRebuildNativeApp, diff --git a/lib/services/device/device-install-app-service.ts b/lib/services/device/device-install-app-service.ts index 16bffcb65f..c071d562c0 100644 --- a/lib/services/device/device-install-app-service.ts +++ b/lib/services/device/device-install-app-service.ts @@ -13,6 +13,7 @@ import { } from "../../definitions/platform"; import { IAnalyticsService, IFileSystem } from "../../common/declarations"; import { injector } from "../../common/yok"; +import { isIOSMacDevice } from "../../common/mobile/ios/mac/ios-mac-constants"; export class DeviceInstallAppService implements IDeviceInstallAppService { constructor( @@ -127,7 +128,11 @@ export class DeviceInstallAppService implements IDeviceInstallAppService { await this.$buildInfoFileService.getDeviceBuildInfo(device, projectData); const localBuildInfo = this.$buildInfoFileService.getLocalBuildInfo( platformData, - { ...buildData, buildForDevice: !device.isEmulator }, + { + ...buildData, + buildForDevice: !device.isEmulator, + buildForMac: isIOSMacDevice(device), + }, ); return ( diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index 7ac6f30c0e..c6a9273024 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -58,6 +58,10 @@ import { injector } from "../common/yok"; import { INotConfiguredEnvOptions } from "../common/definitions/commands"; import { IProjectChangesInfo } from "../definitions/project-changes"; import { ITempService } from "../definitions/temp-service"; +import { + IOS_MAC_BUILD_DIR_NAME, + iOSRuntimeCanRunOnMac, +} from "../common/mobile/ios/mac/ios-mac-constants"; interface INativeSourceCodeGroup { name: string; @@ -74,7 +78,10 @@ const FRAMEWORK_EXTENSIONS = [".framework", ".xcframework"]; const getPlatformSdkName = (buildData: IBuildData): string => { const forDevice = - !buildData || buildData.buildForDevice || buildData.buildForAppStore; + !buildData || + buildData.buildForDevice || + buildData.buildForMac || + buildData.buildForAppStore; const isvisionOS = injector .resolve("devicePlatformsConstants") .isvisionOS(buildData.platform); @@ -179,12 +186,17 @@ export class IOSProjectService return path.join( projectRoot, constants.BUILD_DIR, + ...(options?.buildForMac ? [IOS_MAC_BUILD_DIR_NAME] : []), `${config}-${getPlatformSdkName(options)}`, ); }, getValidBuildOutputData: ( buildOptions: IBuildData, ): IValidBuildOutputData => { + if (buildOptions?.buildForMac) { + return { packageNames: [`${projectData.projectName}.app`] }; + } + const forDevice = !buildOptions || !!buildOptions.buildForDevice || @@ -450,7 +462,24 @@ export class IOSProjectService this.emit(constants.BUILD_OUTPUT_EVENT_NAME, data); }; - if (buildData.buildForDevice) { + if (buildData.buildForMac) { + this.warnIfRuntimeCannotRunOnMac(projectData); + await this.$iOSSigningService.setupSigningForDevice( + projectRoot, + projectData, + buildData, + ); + await attachAwaitDetach( + constants.BUILD_OUTPUT_EVENT_NAME, + this.$childProcess, + handler, + this.$xcodebuildService.buildForMac( + platformData, + projectData, + buildData, + ), + ); + } else if (buildData.buildForDevice) { await this.$iOSSigningService.setupSigningForDevice( projectRoot, projectData, @@ -493,6 +522,18 @@ export class IOSProjectService this.validateApplicationIdentifier(projectData); } + private warnIfRuntimeCannotRunOnMac(projectData: IProjectData): void { + const { version } = this.$projectDataService.getRuntimePackage( + projectData.projectDir, + constants.PlatformTypes.ios, + ); + if (!iOSRuntimeCanRunOnMac(version)) { + this.$logger.warn( + `@nativescript/ios ${version} stops at launch on a Mac. Use @nativescript/ios 9.1.1 or later, or @nativescript/ios@next.`, + ); + } + } + public isPlatformPrepared( projectRoot: string, projectData: IProjectData, diff --git a/lib/services/ios/xcodebuild-args-service.ts b/lib/services/ios/xcodebuild-args-service.ts index fbc5a2ebe4..156418e5a2 100644 --- a/lib/services/ios/xcodebuild-args-service.ts +++ b/lib/services/ios/xcodebuild-args-service.ts @@ -12,6 +12,7 @@ import { IPlatformData } from "../../definitions/platform"; import { IFileSystem } from "../../common/declarations"; import { injector } from "../../common/yok"; import * as _ from "lodash"; +import { IOS_MAC_BUILD_DIR_NAME } from "../../common/mobile/ios/mac/ios-mac-constants"; import { DevicePlatformSdkName, @@ -79,6 +80,51 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { return args; } + /** + * A plain build, not an archive: the app runs from Xcode's products folder + * the way it does when you pick "My Mac (Designed for iPad)" in Xcode. + * Naming the Mac as the destination is what lets automatic signing + * register it and add it to the development profile. + */ + public async getBuildForMacArgs( + platformData: IPlatformData, + projectData: IProjectData, + buildConfig: IBuildConfig, + ): Promise { + const destination = [ + "platform=macOS", + "arch=arm64", + "variant=Designed for iPad", + ...(buildConfig._device + ? [`id=${buildConfig._device.deviceInfo.identifier}`] + : []), + ].join(","); + + return [ + "-destination", + destination, + "build", + "-configuration", + buildConfig.release ? Configurations.Release : Configurations.Debug, + "-allowProvisioningUpdates", + "-allowProvisioningDeviceRegistration", + ] + .concat(this.getXcodeProjectArgs(platformData, projectData)) + .concat( + this.getBuildCommonArgs( + platformData, + projectData, + DevicePlatformSdkName, + path.join( + platformData.projectRoot, + constants.BUILD_DIR, + IOS_MAC_BUILD_DIR_NAME, + ), + ), + ) + .concat(this.getBuildLoggingArgs()); + } + public async getBuildForDeviceArgs( platformData: IPlatformData, projectData: IProjectData, @@ -271,6 +317,7 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { platformData: IPlatformData, projectData: IProjectData, platformSdkName: string, + buildDir = path.join(platformData.projectRoot, constants.BUILD_DIR), ): string[] { let args: string[] = []; @@ -279,9 +326,8 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { } args = args.concat([ - "BUILD_DIR=" + path.join(platformData.projectRoot, constants.BUILD_DIR), - "SHARED_PRECOMPS_DIR=" + - path.join(platformData.projectRoot, constants.BUILD_DIR, "sharedpch"), + "BUILD_DIR=" + buildDir, + "SHARED_PRECOMPS_DIR=" + path.join(buildDir, "sharedpch"), ]); return args; diff --git a/lib/services/ios/xcodebuild-service.ts b/lib/services/ios/xcodebuild-service.ts index 4f8308dfb5..b69c4b57d1 100644 --- a/lib/services/ios/xcodebuild-service.ts +++ b/lib/services/ios/xcodebuild-service.ts @@ -32,6 +32,22 @@ export class XcodebuildService implements IXcodebuildService { return archivePath; } + public async buildForMac( + platformData: IPlatformData, + projectData: IProjectData, + buildConfig: IBuildConfig + ): Promise { + const args = await this.$xcodebuildArgsService.getBuildForMacArgs( + platformData, + projectData, + buildConfig + ); + await this.$xcodebuildCommandService.executeCommand(args, { + cwd: platformData.projectRoot, + stdio: buildConfig.buildOutputStdio, + }); + } + public async buildForSimulator( platformData: IPlatformData, projectData: IProjectData, diff --git a/test/ios-project-service.ts b/test/ios-project-service.ts index e55c28f3f2..27898d9c23 100644 --- a/test/ios-project-service.ts +++ b/test/ios-project-service.ts @@ -126,6 +126,7 @@ function createTestInjector( testInjector.register("devicesService", DevicesService); testInjector.register("iOSDeviceDiscovery", IOSDeviceDiscovery); testInjector.register("iOSSimulatorDiscovery", IOSSimulatorDiscoveryMock); + testInjector.register("iOSMacDeviceDiscovery", IOSSimulatorDiscoveryMock); testInjector.register("iOSSimResolver", {}); testInjector.register("androidDeviceDiscovery", AndroidDeviceDiscovery); testInjector.register("messages", Messages);