From 719a870d349ff255d3888f3cfa5f10189b88d26b Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 17:23:45 +0100 Subject: [PATCH 01/11] fix(ios): launch the app once when restarting it on a device restartApplication launched the app and then connected to its debug socket with ensureAppStarted, which launched it again with --terminate-existing and killed the first process. The socket now connects to the running instance. A launch waiting for a debugger keeps the second start, since that instance cannot answer the attach request. --- .../livesync/ios-device-livesync-service.ts | 13 ++-- .../livesync/ios-device-livesync-service.ts | 62 +++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 test/services/livesync/ios-device-livesync-service.ts diff --git a/lib/services/livesync/ios-device-livesync-service.ts b/lib/services/livesync/ios-device-livesync-service.ts index 78c3150ff9..8182663889 100644 --- a/lib/services/livesync/ios-device-livesync-service.ts +++ b/lib/services/livesync/ios-device-livesync-service.ts @@ -58,7 +58,8 @@ export class IOSDeviceLiveSyncService } private async setupSocketIfNeeded( - projectData: IProjectData + projectData: IProjectData, + ensureAppStarted: boolean = true ): Promise { // TODO: persist the sockets per app in order to support LiveSync on multiple apps on the same device if (this.socket) { @@ -67,8 +68,6 @@ export class IOSDeviceLiveSyncService const appId = projectData.projectIdentifiers.ios; try { - // TODO: temp workaround till we setup the sockets along with the app start - const ensureAppStarted = true; this.socket = await this.device.getDebugSocket( appId, projectData.projectName, @@ -217,7 +216,13 @@ export class IOSDeviceLiveSyncService if (liveSyncInfo.useHotModuleReload) { // enable HOT updates - await this.setupSocketIfNeeded(projectData); + // Starting the app again here would terminate the instance launched + // above. Only a launch that waits for a debugger still needs it, as + // that instance cannot answer the attach request. + await this.setupSocketIfNeeded( + projectData, + !!liveSyncInfo.waitForDebugger + ); } } diff --git a/test/services/livesync/ios-device-livesync-service.ts b/test/services/livesync/ios-device-livesync-service.ts new file mode 100644 index 0000000000..5ac2b57985 --- /dev/null +++ b/test/services/livesync/ios-device-livesync-service.ts @@ -0,0 +1,62 @@ +import * as assert from "assert"; +import { IOSDeviceLiveSyncService } from "../../../lib/services/livesync/ios-device-livesync-service"; + +describe("IOSDeviceLiveSyncService", () => { + const projectData: any = { + projectName: "app", + projectDir: "/app", + projectIdentifiers: { ios: "org.app" }, + }; + + const restart = async (waitForDebugger: boolean) => { + const calls: string[] = []; + const ensureAppStartedValues: boolean[] = []; + const device: any = { + applicationManager: { + restartApplication: async () => { + calls.push("restart"); + }, + }, + getDebugSocket: async ( + _appId: string, + _projectName: string, + _projectDir: string, + ensureAppStarted: boolean + ) => { + calls.push("socket"); + ensureAppStartedValues.push(ensureAppStarted); + return { on: () => {}, removeAllListeners: () => {} }; + }, + }; + const service = new (IOSDeviceLiveSyncService)( + { trace: () => {} }, + {}, + {}, + {}, + {}, + {}, + device + ); + + await service.restartApplication(projectData, { + deviceAppData: { appIdentifier: "org.app" }, + useHotModuleReload: true, + waitForDebugger, + }); + + return { calls, ensureAppStartedValues }; + }; + + it("connects to the app it restarted without starting it again", async () => { + const { calls, ensureAppStartedValues } = await restart(false); + + assert.deepStrictEqual(calls, ["restart", "socket"]); + assert.deepStrictEqual(ensureAppStartedValues, [false]); + }); + + it("starts the app again when the restarted instance waits for a debugger", async () => { + const { ensureAppStartedValues } = await restart(true); + + assert.deepStrictEqual(ensureAppStartedValues, [true]); + }); +}); From aa9454721b745371744681331ac3828c7038dd80 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 22:27:34 +0100 Subject: [PATCH 02/11] fix(ios): keep an app that waits for a debugger running The socket setup started the app a second time for debug-brk, replacing the instance waiting for the debugger with one that ran straight on. --- lib/services/livesync/ios-device-livesync-service.ts | 8 ++------ test/services/livesync/ios-device-livesync-service.ts | 7 ++++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/services/livesync/ios-device-livesync-service.ts b/lib/services/livesync/ios-device-livesync-service.ts index 8182663889..8668c0fca0 100644 --- a/lib/services/livesync/ios-device-livesync-service.ts +++ b/lib/services/livesync/ios-device-livesync-service.ts @@ -217,12 +217,8 @@ export class IOSDeviceLiveSyncService if (liveSyncInfo.useHotModuleReload) { // enable HOT updates // Starting the app again here would terminate the instance launched - // above. Only a launch that waits for a debugger still needs it, as - // that instance cannot answer the attach request. - await this.setupSocketIfNeeded( - projectData, - !!liveSyncInfo.waitForDebugger - ); + // above. + await this.setupSocketIfNeeded(projectData, false); } } diff --git a/test/services/livesync/ios-device-livesync-service.ts b/test/services/livesync/ios-device-livesync-service.ts index 5ac2b57985..97e03af8f3 100644 --- a/test/services/livesync/ios-device-livesync-service.ts +++ b/test/services/livesync/ios-device-livesync-service.ts @@ -54,9 +54,10 @@ describe("IOSDeviceLiveSyncService", () => { assert.deepStrictEqual(ensureAppStartedValues, [false]); }); - it("starts the app again when the restarted instance waits for a debugger", async () => { - const { ensureAppStartedValues } = await restart(true); + it("keeps an instance that waits for a debugger", async () => { + const { calls, ensureAppStartedValues } = await restart(true); - assert.deepStrictEqual(ensureAppStartedValues, [true]); + assert.deepStrictEqual(calls, ["restart", "socket"]); + assert.deepStrictEqual(ensureAppStartedValues, [false]); }); }); From 54bb71978b01abc58095d7a1a8fe783c7dd6a83e Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 17:23:45 +0100 Subject: [PATCH 03/11] fix(debug-ios): make the CDP proxy pick a free port and survive reconnects The port check bound localhost while the server binds every interface, and on macOS those do not conflict, so a taken port passed the check and the listen crashed the CLI. The check now tests all four addresses and the proxy moves on if the bind still fails. The connection lock was released on the connection event, which never fires for a client that drops during the handshake, so every later client timed out until the CLI exited. A late close from a replaced client could also destroy the socket its successor had just received. Connections now run through an in-process queue, and each session only cleans up after itself; a replaced client is closed with a reason. Also clears the port service's timers, whose number type check never matched in Node, which produced false port warnings. --- lib/common/services/net-service.ts | 43 ++-- .../test/unit-tests/services/net-service.ts | 34 +++ .../ios/app-debug-socket-proxy-factory.ts | 177 ++++++++------ lib/services/ios-debugger-port-service.ts | 4 +- .../app-debug-socket-proxy-factory.ts | 219 ++++++++++++++++++ 5 files changed, 384 insertions(+), 93 deletions(-) create mode 100644 test/device-sockets/app-debug-socket-proxy-factory.ts diff --git a/lib/common/services/net-service.ts b/lib/common/services/net-service.ts index d519265ab6..2c08ee218c 100644 --- a/lib/common/services/net-service.ts +++ b/lib/common/services/net-service.ts @@ -48,35 +48,32 @@ export class Net implements INet { } public async isPortAvailable(port: number): Promise { - return new Promise((resolve, reject) => { - let isResolved = false; - const server = net.createServer(); + // A listener on one address does not stop a bind on another (e.g. the + // wildcard next to the loopback), so each of them has to be free. + for (const host of ["127.0.0.1", "::1", "0.0.0.0", "::"]) { + if (!(await this.canListen(port, host))) { + return false; + } + } - server.on("error", (err: Error) => { - if (!isResolved) { - isResolved = true; - resolve(false); - } - }); + return true; + } - server.once("close", () => { - if (!isResolved) { - // "close" will be emitted right after "error" - isResolved = true; - resolve(true); - } - }); + private canListen(port: number, host: string): Promise { + return new Promise((resolve) => { + const server = net.createServer(); - server.on("listening", (err: Error) => { - if (err && !isResolved) { - isResolved = true; - resolve(true); - } + server.once("error", (err: NodeJS.ErrnoException) => { + // The host has no such address (e.g. IPv6 is disabled), so nothing + // can be listening on it either. + resolve(err.code === "EADDRNOTAVAIL" || err.code === "EAFNOSUPPORT"); + }); - server.close(); + server.once("listening", () => { + server.close(() => resolve(true)); }); - server.listen(port, "localhost"); + server.listen(port, host); }); } diff --git a/lib/common/test/unit-tests/services/net-service.ts b/lib/common/test/unit-tests/services/net-service.ts index 9d35773158..0714270003 100644 --- a/lib/common/test/unit-tests/services/net-service.ts +++ b/lib/common/test/unit-tests/services/net-service.ts @@ -1,5 +1,6 @@ import { Net } from "../../../services/net-service"; import { assert } from "chai"; +import * as net from "net"; import { Yok } from "../../../yok"; import { ErrorsStub, CommonLoggerStub } from "../stubs"; import { EOL } from "os"; @@ -25,6 +26,39 @@ describe("net", () => { return testInjector; }; + describe("isPortAvailable", () => { + const hosts = [undefined, "127.0.0.1", "::1", "0.0.0.0", "::"]; + + for (const host of hosts) { + it(`returns false for a port taken on ${host || "the default address"}`, async () => { + const service = createTestInjector("darwin").resolve(Net); + const server = net.createServer(); + await new Promise((resolve) => + host + ? server.listen(0, host, () => resolve()) + : server.listen(0, () => resolve()), + ); + const { port } = server.address(); + + try { + assert.isFalse(await service.isPortAvailable(port)); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + } + + it("returns true for a free port", async () => { + const service = createTestInjector("darwin").resolve(Net); + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, () => resolve())); + const { port } = server.address(); + await new Promise((resolve) => server.close(resolve)); + + assert.isTrue(await service.isPortAvailable(port)); + }); + }); + describe("waitForPortToListen", () => { let execCalledCount = 0; beforeEach(() => { diff --git a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts index 10ddd50209..fe0f832a50 100644 --- a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts +++ b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts @@ -8,6 +8,13 @@ import { IDictionary, IErrors, INet } from "../../common/declarations"; import { injector } from "../../common/yok"; import { ITempService } from "../../definitions/temp-service"; +const REPLACED_CLOSE_REASON = "Replaced by a newer debugger client"; + +interface IDebugProxySession { + appDebugSocket: net.Socket; + webSocket?: ws; +} + export class AppDebugSocketProxyFactory extends EventEmitter implements IAppDebugSocketProxyFactory @@ -18,7 +25,6 @@ export class AppDebugSocketProxyFactory constructor( private $logger: ILogger, private $errors: IErrors, - private $lockService: ILockService, private $options: IOptions, private $tempService: ITempService, private $net: INet, @@ -132,7 +138,6 @@ export class AppDebugSocketProxyFactory projectName: string, projectDir: string, ): Promise { - let clientConnectionLockRelease: () => void; const cacheKey = `${device.deviceInfo.identifier}-${appId}`; const existingServer = this.deviceWebServers[cacheKey]; if (existingServer) { @@ -141,76 +146,83 @@ export class AppDebugSocketProxyFactory ); } - // NOTE: We will try to provide command line options to select ports, at least on the localhost. - const localPort = await this.$net.getAvailablePortInRange(41000); - this.$logger.info( "\nSetting up debugger proxy...\nPress Ctrl + C to terminate, or disconnect.\n", ); // NB: When the inspector frontend connects we might not have connected to the inspector backend yet. // That's why we use the verifyClient callback of the websocket server to stall the upgrade request until we connect. - // We store the socket that connects us to the device in the upgrade request object itself and later on retrieve it - // in the connection callback. - - let currentAppSocket: net.Socket = null; - let currentWebSocket: ws = null; - const server = new ws.Server({ - port: localPort, - verifyClient: async ( + // We store the session in the upgrade request object itself and later on retrieve it in the connection callback. + + // The runtime serves a single frontend, so the newest client takes over. + // Handlers of a replaced session must leave the device socket alone, as + // by then it belongs to the session that replaced it. + let currentSession: IDebugProxySession = null; + let pendingConnect: Promise = Promise.resolve(); + + const connectToApp = async (): Promise => { + const previousSession = currentSession; + currentSession = null; + if (previousSession) { + previousSession.webSocket?.close(1000, REPLACED_CLOSE_REASON); + await device.destroyDebugSocket(appId); + } + + const appDebugSocket = await device.getDebugSocket( + appId, + projectName, + projectDir, + ); + appDebugSocket.on("error", (err: Error) => { + this.$logger.trace("Error on debugger deviceSocket", err); + }); + this.$logger.info("Backend socket created."); + + currentSession = { appDebugSocket }; + return currentSession; + }; + + const server = await this.listenOnAvailablePort(41000, { + verifyClient: ( info: any, callback: (res: boolean, code?: number, message?: string) => void, ) => { - let acceptHandshake = true; - clientConnectionLockRelease = null; - - try { - clientConnectionLockRelease = await this.$lockService.lock( - `debug-connection-${device.deviceInfo.identifier}-${appId}.lock`, - ); - - this.$logger.info("Frontend client connected."); - let appDebugSocket; - if (currentAppSocket) { - currentAppSocket.removeAllListeners(); - currentAppSocket = null; - if (currentWebSocket) { - currentWebSocket.removeAllListeners(); - currentWebSocket.close(); - currentWebSocket = null; - } - await device.destroyDebugSocket(appId); + this.$logger.info("Frontend client connected."); + + // Not released on "connection": that never fires for a client that + // drops during the handshake. + pendingConnect = pendingConnect.then(async () => { + try { + info.req["__session"] = await connectToApp(); + callback(true); + } catch (err) { + err.deviceIdentifier = device.deviceInfo.identifier; + this.$logger.trace(err); + this.emit(CONNECTION_ERROR_EVENT_NAME, err); + this.$logger.warn( + `Cannot connect to device socket. The error message is '${err.message}'.`, + ); + callback(false); } - appDebugSocket = await device.getDebugSocket( - appId, - projectName, - projectDir, - ); - currentAppSocket = appDebugSocket; - this.$logger.info("Backend socket created."); - info.req["__deviceSocket"] = appDebugSocket; - } catch (err) { - if (clientConnectionLockRelease) { - clientConnectionLockRelease(); - } - err.deviceIdentifier = device.deviceInfo.identifier; - this.$logger.trace(err); - this.emit(CONNECTION_ERROR_EVENT_NAME, err); - acceptHandshake = false; - this.$logger.warn( - `Cannot connect to device socket. The error message is '${err.message}'.`, - ); - } - - callback(acceptHandshake); + }); }, }); this.deviceWebServers[cacheKey] = server; server.on("connection", (webSocket, req) => { - currentWebSocket = webSocket; const encoding = "utf16le"; - const appDebugSocket: net.Socket = (req)["__deviceSocket"]; + webSocket.on("error", (err) => { + this.$logger.trace("Error on debugger websocket", err); + }); + + const session: IDebugProxySession = (req)["__session"]; + if (session !== currentSession || session.appDebugSocket.destroyed) { + webSocket.close(1000, REPLACED_CLOSE_REASON); + return; + } + + session.webSocket = webSocket; + const appDebugSocket = session.appDebugSocket; const packets = new MessageUnpackStream(); appDebugSocket.pipe(packets); @@ -230,14 +242,6 @@ export class AppDebugSocketProxyFactory } }); - webSocket.on("error", (err) => { - this.$logger.trace("Error on debugger websocket", err); - }); - - appDebugSocket.on("error", (err) => { - this.$logger.trace("Error on debugger deviceSocket", err); - }); - webSocket.on("message", (message) => { const msg = message.toString(); if (process.env.DEBUG_DEVTOOLS_SOCKETS) { @@ -253,28 +257,65 @@ export class AppDebugSocketProxyFactory }); appDebugSocket.on("close", () => { - currentAppSocket = null; this.$logger.trace("Backend socket closed!"); + if (currentSession === session) { + currentSession = null; + } webSocket.close(); }); webSocket.on("close", async () => { - currentWebSocket = null; this.$logger.trace("Frontend socket closed!"); appDebugSocket.unpipe(packets); packets.destroy(); + if (currentSession !== session) { + return; + } + + currentSession = null; await device.destroyDebugSocket(appId); if (!this.$options.watch) { process.exit(0); } }); - - clientConnectionLockRelease(); }); return server; } + private async listenOnAvailablePort( + startPort: number, + options: ws.ServerOptions, + ): Promise { + // The port can still get taken after the check, by another CLI for example. + let port = await this.$net.getAvailablePortInRange(startPort); + while (true) { + const result = await new Promise( + (resolve) => { + const server = new ws.Server({ ...options, port }); + server.once("listening", () => { + server.removeAllListeners("error"); + server.on("error", (err: Error) => { + this.$logger.trace("Error on debugger web socket server", err); + }); + resolve(server); + }); + server.once("error", resolve); + }, + ); + + if (result instanceof ws.Server) { + return result; + } + + if (result.code !== "EADDRINUSE") { + throw result; + } + + port = await this.$net.getAvailablePortInRange(port + 1); + } + } + public removeAllProxies() { let deviceId; for (deviceId in this.deviceWebServers) { diff --git a/lib/services/ios-debugger-port-service.ts b/lib/services/ios-debugger-port-service.ts index ca1c1c5a40..4146453219 100644 --- a/lib/services/ios-debugger-port-service.ts +++ b/lib/services/ios-debugger-port-service.ts @@ -143,8 +143,8 @@ export class IOSDebuggerPortService implements IIOSDebuggerPortService { private clearTimeout(data: IIOSDebuggerPortData): void { const storedData = this.mapDebuggerPortData[`${data.deviceId}${data.appId}`]; - if (storedData && typeof storedData.timer === "number") { - clearTimeout(storedData.timer); + if (storedData && storedData.timer) { + clearTimeout(storedData.timer); } } } diff --git a/test/device-sockets/app-debug-socket-proxy-factory.ts b/test/device-sockets/app-debug-socket-proxy-factory.ts new file mode 100644 index 0000000000..91e01426b6 --- /dev/null +++ b/test/device-sockets/app-debug-socket-proxy-factory.ts @@ -0,0 +1,219 @@ +import { assert } from "chai"; +import * as net from "net"; +import * as ws from "ws"; +import { MessageUnpackStream } from "ios-device-lib"; +import { AppDebugSocketProxyFactory } from "../../lib/device-sockets/ios/app-debug-socket-proxy-factory"; + +const REPLACED_CLOSE_REASON = "Replaced by a newer debugger client"; + +const encode = (message: string): Buffer => { + const length = Buffer.byteLength(message, "utf16le"); + const payload = Buffer.allocUnsafe(length + 4); + payload.writeInt32BE(length, 0); + payload.write(message, 4, length, "utf16le"); + return payload; +}; + +describe("AppDebugSocketProxyFactory", () => { + let backend: net.Server; + let backendPort: number; + let backendConnections: number; + let proxy: any; + let proxies: ws.Server[]; + let device: any; + + // Echoes every framed message, as the runtime answers each CDP request. + const startBackend = () => + new Promise((resolve) => { + backend = net.createServer((socket) => { + backendConnections++; + const packets = new MessageUnpackStream(); + socket.pipe(packets); + packets.on("data", (buffer: Buffer) => { + socket.write(encode(buffer.toString("utf16le"))); + }); + socket.on("error", () => {}); + }); + backend.listen(0, "127.0.0.1", () => { + backendPort = (backend.address()).port; + resolve(); + }); + }); + + const createDevice = () => { + const cachedSockets: { [appId: string]: net.Socket } = {}; + return { + deviceInfo: { identifier: "device" }, + getDebugSocket: async (appId: string) => { + if (!cachedSockets[appId]) { + cachedSockets[appId] = net.connect(backendPort, "127.0.0.1"); + } + return cachedSockets[appId]; + }, + destroyDebugSocket: async (appId: string) => { + cachedSockets[appId]?.destroy(); + cachedSockets[appId] = null; + }, + }; + }; + + const createProxy = (getAvailablePortInRange?: () => Promise) => + new AppDebugSocketProxyFactory( + { info: () => {}, trace: () => {}, warn: () => {} }, + { fail: (message: string) => assert.fail(message) }, + { watch: true }, + {}, + { + getAvailablePortInRange: + getAvailablePortInRange || + (async () => { + const server = net.createServer(); + await new Promise((r) => server.listen(0, () => r())); + const { port } = server.address(); + await new Promise((r) => server.close(r)); + return port; + }), + }, + ); + + const startProxy = async (factory: any = proxy) => { + const server: ws.Server = await factory.ensureWebSocketProxy( + device, + "app", + "project", + "/project", + ); + proxies.push(server); + return (server.address()).port; + }; + + const open = (port: number) => + new Promise((resolve, reject) => { + const client = new ws(`ws://127.0.0.1:${port}`); + client.once("open", () => resolve(client)); + client.once("error", reject); + }); + + const roundTrip = (client: ws, message: string) => + new Promise((resolve, reject) => { + client.once("message", (data) => resolve(data.toString())); + client.once("close", () => reject(new Error("closed before the reply"))); + client.send(message); + }); + + const closeReason = (client: ws) => + new Promise((resolve) => { + client.once("close", (_code, reason) => resolve(reason.toString())); + }); + + beforeEach(async () => { + backendConnections = 0; + proxies = []; + await startBackend(); + device = createDevice(); + proxy = createProxy(); + }); + + afterEach(async () => { + await device.destroyDebugSocket("app"); + proxies.forEach((server) => { + server.clients.forEach((client) => client.terminate()); + server.close(); + }); + await new Promise((resolve) => backend.close(resolve)); + }); + + it("moves to the next port when another server takes the checked one", async () => { + const blocker = net.createServer(); + await new Promise((resolve) => blocker.listen(0, () => resolve())); + const blockedPort = (blocker.address()).port; + const ports = [blockedPort]; + const factory = createProxy(async () => { + if (ports.length) { + return ports.shift(); + } + const server = net.createServer(); + await new Promise((r) => server.listen(0, () => r())); + const { port } = server.address(); + await new Promise((r) => server.close(r)); + return port; + }); + + const port = await startProxy(factory); + await new Promise((resolve) => blocker.close(resolve)); + + assert.notEqual(port, blockedPort); + const client = await open(port); + assert.equal(await roundTrip(client, "hello"), "hello"); + }); + + it("serves a client after the previous one disconnected", async () => { + const port = await startProxy(); + for (let i = 0; i < 3; i++) { + const client = await open(port); + assert.equal(await roundTrip(client, `message ${i}`), `message ${i}`); + client.close(); + } + + const client = await open(port); + assert.equal(await roundTrip(client, "last"), "last"); + }); + + it("still serves clients after one drops during the handshake", async () => { + const port = await startProxy(); + await new Promise((resolve) => { + const socket = net.connect(port, "127.0.0.1", () => { + socket.write( + "GET / HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", + ); + setImmediate(() => { + socket.destroy(); + resolve(); + }); + }); + }); + + const client = await open(port); + assert.equal(await roundTrip(client, "hello"), "hello"); + }); + + it("gives the backend to the newest client and tells the previous one why", async () => { + const port = await startProxy(); + const first = await open(port); + const firstClosed = closeReason(first); + + const second = await open(port); + + assert.equal(await firstClosed, REPLACED_CLOSE_REASON); + assert.equal(await roundTrip(second, "hello"), "hello"); + assert.equal(backendConnections, 2); + }); + + it("keeps the new client's backend when the replaced client closes late", async () => { + const port = await startProxy(); + const first = await open(port); + const opening = open(port); + first.terminate(); + + const second = await opening; + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.equal(second.readyState, ws.OPEN); + assert.equal(await roundTrip(second, "hello"), "hello"); + }); + + it("leaves only the newest of simultaneous clients connected", async () => { + const port = await startProxy(); + const clients = await Promise.all( + [0, 1, 2, 3].map(() => open(port).catch((): ws => null)), + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const connected = clients.filter( + (client) => client && client.readyState === ws.OPEN, + ); + assert.lengthOf(connected, 1); + assert.equal(await roundTrip(connected[0], "hello"), "hello"); + }); +}); From 88cb51d2c18029a70d04d4e7632b8e7f32c1fded Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 18:39:43 +0100 Subject: [PATCH 04/11] fix(debug-ios): only accept debugger connections from this machine and DevTools The proxy listened on every interface with no origin check, while the debugger can run any code in the app. Anything on the network, or any web page, could connect. It now binds the two loopback addresses and rejects browser origins other than DevTools and localhost. --- .../ios/app-debug-socket-proxy-factory.ts | 108 ++++++++++++++---- .../app-debug-socket-proxy-factory.ts | 79 ++++++++++++- 2 files changed, 158 insertions(+), 29 deletions(-) diff --git a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts index fe0f832a50..912b8dada3 100644 --- a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts +++ b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts @@ -10,6 +10,29 @@ import { ITempService } from "../../definitions/temp-service"; const REPLACED_CLOSE_REASON = "Replaced by a newer debugger client"; +/** + * The debugger can run any code in the app, and any web page can try to open a + * WebSocket to this port. Clients that aren't browsers, like VS Code's debug + * adapter, send no origin. + */ +export function isAllowedDebuggerOrigin(origin?: string): boolean { + if (!origin) { + return true; + } + + try { + const { protocol, hostname, origin: urlOrigin } = new URL(origin); + return ( + protocol === "devtools:" || + protocol === "chrome-devtools:" || + urlOrigin === "https://chrome-devtools-frontend.appspot.com" || + ["localhost", "127.0.0.1", "[::1]"].includes(hostname) + ); + } catch { + return false; + } +} + interface IDebugProxySession { appDebugSocket: net.Socket; webSocket?: ws; @@ -182,11 +205,19 @@ export class AppDebugSocketProxyFactory return currentSession; }; - const server = await this.listenOnAvailablePort(41000, { + const servers = await this.listenOnAvailablePort(41000, { verifyClient: ( info: any, callback: (res: boolean, code?: number, message?: string) => void, ) => { + if (!isAllowedDebuggerOrigin(info.origin)) { + this.$logger.warn( + `Rejected a debugger connection from origin '${info.origin}'.`, + ); + callback(false, 403, "Origin not allowed"); + return; + } + this.$logger.info("Frontend client connected."); // Not released on "connection": that never fires for a client that @@ -207,8 +238,9 @@ export class AppDebugSocketProxyFactory }); }, }); + const server = servers[0]; this.deviceWebServers[cacheKey] = server; - server.on("connection", (webSocket, req) => { + const onConnection = (webSocket: ws, req: any) => { const encoding = "utf16le"; webSocket.on("error", (err) => { @@ -278,6 +310,12 @@ export class AppDebugSocketProxyFactory process.exit(0); } }); + }; + for (const listeningServer of servers) { + listeningServer.on("connection", onConnection); + } + server.once("close", () => { + servers.slice(1).forEach((other) => other.close()); }); return server; @@ -286,36 +324,56 @@ export class AppDebugSocketProxyFactory private async listenOnAvailablePort( startPort: number, options: ws.ServerOptions, - ): Promise { + ): Promise { // The port can still get taken after the check, by another CLI for example. let port = await this.$net.getAvailablePortInRange(startPort); while (true) { - const result = await new Promise( - (resolve) => { - const server = new ws.Server({ ...options, port }); - server.once("listening", () => { - server.removeAllListeners("error"); - server.on("error", (err: Error) => { - this.$logger.trace("Error on debugger web socket server", err); - }); - resolve(server); - }); - server.once("error", resolve); - }, - ); - - if (result instanceof ws.Server) { - return result; - } - - if (result.code !== "EADDRINUSE") { - throw result; + const servers: ws.Server[] = []; + try { + // Only reachable from this machine. Clients told to use "localhost" + // may end up on either loopback address. + servers.push(await this.listen("127.0.0.1", port, options)); + const ipv6Server = await this.listen("::1", port, options).catch( + (err: NodeJS.ErrnoException): ws.Server => { + if (err.code === "EADDRINUSE") { + throw err; + } + this.$logger.trace("Not listening on ::1", err); + return null; + }, + ); + if (ipv6Server) { + servers.push(ipv6Server); + } + return servers; + } catch (err) { + servers.forEach((server) => server.close()); + if (err.code !== "EADDRINUSE") { + throw err; + } + port = await this.$net.getAvailablePortInRange(port + 1); } - - port = await this.$net.getAvailablePortInRange(port + 1); } } + private listen( + host: string, + port: number, + options: ws.ServerOptions, + ): Promise { + return new Promise((resolve, reject) => { + const server = new ws.Server({ ...options, host, port }); + server.once("listening", () => { + server.removeAllListeners("error"); + server.on("error", (err: Error) => { + this.$logger.trace("Error on debugger web socket server", err); + }); + resolve(server); + }); + server.once("error", reject); + }); + } + public removeAllProxies() { let deviceId; for (deviceId in this.deviceWebServers) { diff --git a/test/device-sockets/app-debug-socket-proxy-factory.ts b/test/device-sockets/app-debug-socket-proxy-factory.ts index 91e01426b6..9497f914e0 100644 --- a/test/device-sockets/app-debug-socket-proxy-factory.ts +++ b/test/device-sockets/app-debug-socket-proxy-factory.ts @@ -1,8 +1,12 @@ import { assert } from "chai"; import * as net from "net"; +import * as os from "os"; import * as ws from "ws"; import { MessageUnpackStream } from "ios-device-lib"; -import { AppDebugSocketProxyFactory } from "../../lib/device-sockets/ios/app-debug-socket-proxy-factory"; +import { + AppDebugSocketProxyFactory, + isAllowedDebuggerOrigin, +} from "../../lib/device-sockets/ios/app-debug-socket-proxy-factory"; const REPLACED_CLOSE_REASON = "Replaced by a newer debugger client"; @@ -87,13 +91,18 @@ describe("AppDebugSocketProxyFactory", () => { return (server.address()).port; }; - const open = (port: number) => + const open = (port: number, options?: ws.ClientOptions, host = "127.0.0.1") => new Promise((resolve, reject) => { - const client = new ws(`ws://127.0.0.1:${port}`); + const client = new ws(`ws://${host}:${port}`, options); client.once("open", () => resolve(client)); client.once("error", reject); }); + const assertRejects = async (promise: Promise) => { + const client = await promise.catch((): ws => null); + assert.isNull(client); + }; + const roundTrip = (client: ws, message: string) => new Promise((resolve, reject) => { client.once("message", (data) => resolve(data.toString())); @@ -125,7 +134,9 @@ describe("AppDebugSocketProxyFactory", () => { it("moves to the next port when another server takes the checked one", async () => { const blocker = net.createServer(); - await new Promise((resolve) => blocker.listen(0, () => resolve())); + await new Promise((resolve) => + blocker.listen(0, "127.0.0.1", () => resolve()), + ); const blockedPort = (blocker.address()).port; const ports = [blockedPort]; const factory = createProxy(async () => { @@ -147,6 +158,34 @@ describe("AppDebugSocketProxyFactory", () => { assert.equal(await roundTrip(client, "hello"), "hello"); }); + it("listens on both loopback addresses and nothing else", async () => { + const port = await startProxy(); + + for (const host of ["127.0.0.1", "[::1]"]) { + const client = await open(port, undefined, host); + assert.equal(await roundTrip(client, host), host); + } + + const addresses = Object.values(os.networkInterfaces()) + .flat() + .filter((address) => !address.internal && address.family === "IPv4"); + for (const { address } of addresses) { + await assertRejects(open(port, undefined, address)); + } + }); + + it("turns away web pages but not DevTools or clients without an origin", async () => { + const port = await startProxy(); + + await assertRejects(open(port, { origin: "https://evil.example" })); + assert.equal(backendConnections, 0); + + const devtools = await open(port, { origin: "devtools://devtools" }); + assert.equal(await roundTrip(devtools, "hello"), "hello"); + const noOrigin = await open(port); + assert.equal(await roundTrip(noOrigin, "hello"), "hello"); + }); + it("serves a client after the previous one disconnected", async () => { const port = await startProxy(); for (let i = 0; i < 3; i++) { @@ -216,4 +255,36 @@ describe("AppDebugSocketProxyFactory", () => { assert.lengthOf(connected, 1); assert.equal(await roundTrip(connected[0], "hello"), "hello"); }); + + describe("isAllowedDebuggerOrigin", () => { + const allowed = [ + undefined, + "", + "devtools://devtools", + "chrome-devtools://devtools", + "https://chrome-devtools-frontend.appspot.com", + "http://localhost:5173", + "http://127.0.0.1:8080", + "http://[::1]:3000", + ]; + for (const origin of allowed) { + it(`allows ${origin || "no origin"}`, () => { + assert.isTrue(isAllowedDebuggerOrigin(origin)); + }); + } + + const rejected = [ + "https://evil.example", + "http://localhost.evil.example", + "http://127.0.0.1.evil.example", + "https://chrome-devtools-frontend.appspot.com.evil.example", + "null", + "file://", + ]; + for (const origin of rejected) { + it(`rejects ${origin}`, () => { + assert.isFalse(isAllowedDebuggerOrigin(origin)); + }); + } + }); }); From 1aa7b02f6ba86f6fb1e65a9ac7957617baeb9f25 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 20:24:33 +0100 Subject: [PATCH 05/11] fix(debug-ios): clean up and exit when the app's socket closes The backend close handler cleared the session before closing the WebSocket, so the WebSocket's close handler no longer saw it as current and skipped destroying the socket and exiting without --watch. --- .../test/unit-tests/services/net-service.ts | 16 +++++--- .../ios/app-debug-socket-proxy-factory.ts | 3 -- .../app-debug-socket-proxy-factory.ts | 40 +++++++++++++++++-- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/lib/common/test/unit-tests/services/net-service.ts b/lib/common/test/unit-tests/services/net-service.ts index 0714270003..35e0553982 100644 --- a/lib/common/test/unit-tests/services/net-service.ts +++ b/lib/common/test/unit-tests/services/net-service.ts @@ -30,14 +30,18 @@ describe("net", () => { const hosts = [undefined, "127.0.0.1", "::1", "0.0.0.0", "::"]; for (const host of hosts) { - it(`returns false for a port taken on ${host || "the default address"}`, async () => { + it(`returns false for a port taken on ${host || "the default address"}`, async function () { const service = createTestInjector("darwin").resolve(Net); const server = net.createServer(); - await new Promise((resolve) => - host - ? server.listen(0, host, () => resolve()) - : server.listen(0, () => resolve()), - ); + const listening = await new Promise((resolve) => { + server.once("error", () => resolve(false)); + const onListening = () => resolve(true); + host ? server.listen(0, host, onListening) : server.listen(0, onListening); + }); + if (!listening) { + this.skip(); + return; + } const { port } = server.address(); try { diff --git a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts index 912b8dada3..832d53f9b1 100644 --- a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts +++ b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts @@ -290,9 +290,6 @@ export class AppDebugSocketProxyFactory appDebugSocket.on("close", () => { this.$logger.trace("Backend socket closed!"); - if (currentSession === session) { - currentSession = null; - } webSocket.close(); }); diff --git a/test/device-sockets/app-debug-socket-proxy-factory.ts b/test/device-sockets/app-debug-socket-proxy-factory.ts index 9497f914e0..30d28473d2 100644 --- a/test/device-sockets/app-debug-socket-proxy-factory.ts +++ b/test/device-sockets/app-debug-socket-proxy-factory.ts @@ -1,6 +1,7 @@ import { assert } from "chai"; import * as net from "net"; import * as os from "os"; +import * as sinon from "sinon"; import * as ws from "ws"; import { MessageUnpackStream } from "ios-device-lib"; import { @@ -18,6 +19,11 @@ const encode = (message: string): Buffer => { return payload; }; +const hasIPv6Loopback = () => + Object.values(os.networkInterfaces()) + .flat() + .some((address) => address.internal && address.family === "IPv6"); + describe("AppDebugSocketProxyFactory", () => { let backend: net.Server; let backendPort: number; @@ -61,11 +67,14 @@ describe("AppDebugSocketProxyFactory", () => { }; }; - const createProxy = (getAvailablePortInRange?: () => Promise) => + const createProxy = ( + getAvailablePortInRange?: () => Promise, + watch = true, + ) => new AppDebugSocketProxyFactory( { info: () => {}, trace: () => {}, warn: () => {} }, { fail: (message: string) => assert.fail(message) }, - { watch: true }, + { watch }, {}, { getAvailablePortInRange: @@ -161,7 +170,11 @@ describe("AppDebugSocketProxyFactory", () => { it("listens on both loopback addresses and nothing else", async () => { const port = await startProxy(); - for (const host of ["127.0.0.1", "[::1]"]) { + const hosts = ["127.0.0.1"]; + if (hasIPv6Loopback()) { + hosts.push("[::1]"); + } + for (const host of hosts) { const client = await open(port, undefined, host); assert.equal(await roundTrip(client, host), host); } @@ -229,6 +242,27 @@ describe("AppDebugSocketProxyFactory", () => { assert.equal(backendConnections, 2); }); + it("cleans up and exits without watch when the app goes away", async () => { + const exit = sinon.stub(process, "exit"); + const destroyDebugSocket = sinon.spy(device, "destroyDebugSocket"); + try { + const port = await startProxy(createProxy(undefined, false)); + const client = await open(port); + const closed = closeReason(client); + const appSocket = await device.getDebugSocket("app"); + + appSocket.destroy(); + await closed; + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.isTrue(destroyDebugSocket.called); + assert.isTrue(exit.calledWith(0)); + } finally { + exit.restore(); + destroyDebugSocket.restore(); + } + }); + it("keeps the new client's backend when the replaced client closes late", async () => { const port = await startProxy(); const first = await open(port); From 572af5297aa74b305a973eafd7851173c5702c3a Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 20:50:34 +0100 Subject: [PATCH 06/11] test(net): skip unavailable addresses through vitest's test context --- lib/common/test/unit-tests/services/net-service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/common/test/unit-tests/services/net-service.ts b/lib/common/test/unit-tests/services/net-service.ts index 35e0553982..73751f1a00 100644 --- a/lib/common/test/unit-tests/services/net-service.ts +++ b/lib/common/test/unit-tests/services/net-service.ts @@ -30,7 +30,7 @@ describe("net", () => { const hosts = [undefined, "127.0.0.1", "::1", "0.0.0.0", "::"]; for (const host of hosts) { - it(`returns false for a port taken on ${host || "the default address"}`, async function () { + it(`returns false for a port taken on ${host || "the default address"}`, async (context: any) => { const service = createTestInjector("darwin").resolve(Net); const server = net.createServer(); const listening = await new Promise((resolve) => { @@ -39,7 +39,7 @@ describe("net", () => { host ? server.listen(0, host, onListening) : server.listen(0, onListening); }); if (!listening) { - this.skip(); + context.skip(); return; } const { port } = server.address(); From 8c15aa1fecf094e9a425f88af36c66a854245964 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 17:23:45 +0100 Subject: [PATCH 07/11] feat(ios): share DerivedData with Xcode and speed up incremental builds DerivedData moves into platforms/ios/build through the per-user WorkspaceSettings, so CLI and Xcode builds reuse each other's output. Settings that made the two builds differ come off the xcodebuild command line: -sdk and forced explicit modules are gone, and the deployment target moves into the Podfile post_install. Simulator builds target the specific destination, debug device builds use a plain build instead of an archive, and the compilation cache lives in platforms/.cache/ios. Device discovery stops waiting as soon as the requested device is found. --- lib/common/definitions/mobile.d.ts | 5 + .../ios/device/ios-device-operations.ts | 16 +- .../mobile/mobile-core/devices-service.ts | 51 +++-- .../test/unit-tests/mobile/devices-service.ts | 33 +++ lib/definitions/ios.d.ts | 3 + lib/definitions/project.d.ts | 6 + lib/services/cocoapods-service.ts | 206 ++++++++++++------ lib/services/ios-project-service.ts | 157 ++++++++++++- lib/services/ios/spm-service.ts | 17 +- lib/services/ios/xcodebuild-args-service.ts | 138 ++++++------ lib/services/ios/xcodebuild-service.ts | 85 ++++++-- test/cocoapods-service.ts | 182 ++++++++++++---- test/ios-project-service.ts | 133 ++++++++++- test/services/ios/xcodebuild-args-service.ts | 140 ++++++++++-- test/services/ios/xcodebuild-service.ts | 71 +++++- 15 files changed, 972 insertions(+), 271 deletions(-) diff --git a/lib/common/definitions/mobile.d.ts b/lib/common/definitions/mobile.d.ts index bb522079fa..4c27929b2b 100644 --- a/lib/common/definitions/mobile.d.ts +++ b/lib/common/definitions/mobile.d.ts @@ -1174,6 +1174,11 @@ declare global { shouldReturnImmediateResult: boolean; platform: string; fullDiscovery?: boolean; + /** + * Ends the wait for physical devices once the device requested on the + * command line has been found by any discovery. + */ + isRequestedDeviceFound?: () => boolean; } interface IAndroidDeviceHashService { diff --git a/lib/common/mobile/ios/device/ios-device-operations.ts b/lib/common/mobile/ios/device/ios-device-operations.ts index e0675096c7..c562b29cf8 100644 --- a/lib/common/mobile/ios/device/ios-device-operations.ts +++ b/lib/common/mobile/ios/device/ios-device-operations.ts @@ -86,21 +86,25 @@ export class IOSDeviceOperations // We need this because we need to make sure that we have devices. await new Promise((resolve, reject) => { - let iterationsCount = 0; - const maxIterationsCount = 3; + const pollInterval = 100; + const maxWait = 6000; + let waited = 0; const intervalHandle: NodeJS.Timer = setInterval(() => { - if (foundDevice && !options.fullDiscovery) { + if ( + (foundDevice && !options.fullDiscovery) || + options.isRequestedDeviceFound?.() + ) { resolve(); return clearInterval(intervalHandle as unknown as number); } - iterationsCount++; - if (iterationsCount >= maxIterationsCount) { + waited += pollInterval; + if (waited >= maxWait) { clearInterval(intervalHandle as unknown as number); return resolve(); } - }, 2000); + }, pollInterval); }); } } diff --git a/lib/common/mobile/mobile-core/devices-service.ts b/lib/common/mobile/mobile-core/devices-service.ts index 15bf11787f..ed6a65950e 100644 --- a/lib/common/mobile/mobile-core/devices-service.ts +++ b/lib/common/mobile/mobile-core/devices-service.ts @@ -415,13 +415,23 @@ export class DevicesService deviceInitOpts?: Mobile.IDeviceLookingOptions, ): Promise { const options = this.getDeviceLookingOptions(deviceInitOpts); - - for (const deviceDiscovery of this._allDeviceDiscoveries) { + const startLooking = async (deviceDiscovery: Mobile.IDeviceDiscovery) => { try { await deviceDiscovery.startLookingForDevices(options); } catch (err) { this.$logger.trace("Error while checking for devices.", err); } + }; + + // Concurrently, so a requested simulator ends the wait for physical + // devices. Otherwise in order, as device indexes follow discovery order. + if (options.isRequestedDeviceFound) { + await Promise.all(this._allDeviceDiscoveries.map(startLooking)); + return; + } + + for (const deviceDiscovery of this._allDeviceDiscoveries) { + await startLooking(deviceDiscovery); } } @@ -516,6 +526,18 @@ export class DevicesService * The identifier is expected to be the same as the running device declares it (emulator-5554 for android or GUID for ios). * @param identifier running emulator or device identifier */ + private findDevice(deviceOption: string): Mobile.IDevice { + return _.find( + this.getDeviceInstances(), + (d) => + (d.deviceInfo.identifier && d.deviceInfo.identifier === deviceOption) || + (d.deviceInfo.displayName && + d.deviceInfo.displayName === deviceOption) || + (d.deviceInfo.imageIdentifier && + d.deviceInfo.imageIdentifier === deviceOption), + ); + } + public getDeviceByIdentifier(identifier: string): Mobile.IDevice { const searchedDevice = _.find( this.getDeviceInstances(), @@ -581,20 +603,7 @@ export class DevicesService * @param deviceOption parameter passed by the user to --device flag. Can be name, identifier or imageIdentifier. */ public async getDevice(deviceOption: string): Promise { - let device: Mobile.IDevice = null; - - if (!device) { - device = _.find( - this.getDeviceInstances(), - (d) => - (d.deviceInfo.identifier && - d.deviceInfo.identifier === deviceOption) || - (d.deviceInfo.displayName && - d.deviceInfo.displayName === deviceOption) || - (d.deviceInfo.imageIdentifier && - d.deviceInfo.imageIdentifier === deviceOption), - ); - } + let device = this.findDevice(deviceOption); if (!device && helpers.isNumberWithoutExponent(deviceOption)) { device = this.getDeviceByIndex(parseInt(deviceOption, 10)); @@ -907,6 +916,13 @@ export class DevicesService } deviceInitOpts = deviceInitOpts || {}; + const deviceOption = deviceInitOpts.deviceId; + if (deviceOption && !helpers.isNumberWithoutExponent(deviceOption)) { + deviceInitOpts = { + ...deviceInitOpts, + isRequestedDeviceFound: () => !!this.findDevice(deviceOption), + }; + } this._data = deviceInitOpts; if (!deviceInitOpts.skipEmulatorStart) { @@ -915,13 +931,13 @@ export class DevicesService } const platform = deviceInitOpts.platform; - const deviceOption = deviceInitOpts.deviceId; const deviceLookingOptions: Mobile.IDeviceLookingOptions = { emulator: deviceInitOpts.emulator, platform: deviceInitOpts.platform, shouldReturnImmediateResult: deviceInitOpts.shouldReturnImmediateResult, detectionInterval: deviceInitOpts.detectionInterval, fullDiscovery: deviceInitOpts.fullDiscovery, + isRequestedDeviceFound: deviceInitOpts.isRequestedDeviceFound, }; if (platform && deviceOption) { @@ -1198,6 +1214,7 @@ export class DevicesService shouldReturnImmediateResult: !!shouldReturnImmediateResult, emulator: !!emulator, fullDiscovery: deviceInitOpts.fullDiscovery, + isRequestedDeviceFound: deviceInitOpts.isRequestedDeviceFound, }; } diff --git a/lib/common/test/unit-tests/mobile/devices-service.ts b/lib/common/test/unit-tests/mobile/devices-service.ts index ae319ce0b9..0f70915973 100644 --- a/lib/common/test/unit-tests/mobile/devices-service.ts +++ b/lib/common/test/unit-tests/mobile/devices-service.ts @@ -1237,6 +1237,39 @@ describe("devicesService", () => { ); }); + it("stops waiting for physical devices once the requested simulator is found", async () => { + let waitedForRequestedDevice = false; + (iOSDeviceDiscovery).startLookingForDevices = ( + options: Mobile.IDeviceLookingOptions, + ) => + new Promise((resolve) => { + const poll = setInterval(() => { + if (options.isRequestedDeviceFound?.()) { + waitedForRequestedDevice = true; + clearInterval(poll); + resolve(); + } + }, 5); + }); + (iOSSimulatorDiscovery).startLookingForDevices = async () => { + iOSSimulatorDiscovery.emit( + DeviceDiscoveryEventNames.DEVICE_FOUND, + iOSSimulator, + ); + }; + + await devicesService.initialize({ + platform: "ios", + deviceId: iOSSimulator.deviceInfo.identifier, + skipEmulatorStart: true, + }); + + assert.isTrue(waitedForRequestedDevice); + assert.deepStrictEqual(devicesService.getDeviceInstances(), [ + iOSSimulator, + ]); + }); + describe("when only deviceIdentifier is passed", () => { const assertAllMethodsResults = async (deviceId: string) => { assert.isFalse( diff --git a/lib/definitions/ios.d.ts b/lib/definitions/ios.d.ts index 60c3430bbb..08c67db5c7 100644 --- a/lib/definitions/ios.d.ts +++ b/lib/definitions/ios.d.ts @@ -114,6 +114,9 @@ declare global { platformData: IPlatformData, projectData: IProjectData, ): string[]; + shouldArchiveDeviceBuild( + buildConfig: IBuildConfig & { buildForAppStore?: boolean }, + ): boolean; } interface IXcodebuildCommandService { diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index cd2a931617..53c0d2d10d 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -563,6 +563,12 @@ interface ICocoaPodsService { platformData: IPlatformData, ): Promise; + applyPodfileBuildSettings( + projectData: IProjectData, + platformData: IPlatformData, + buildSettings: Record | null, + ): Promise; + applyPodfileFromExtensions( projectData: IProjectData, platformData: IPlatformData, diff --git a/lib/services/cocoapods-service.ts b/lib/services/cocoapods-service.ts index 17e433feae..b2b132dfa3 100644 --- a/lib/services/cocoapods-service.ts +++ b/lib/services/cocoapods-service.ts @@ -35,11 +35,11 @@ export class CocoaPodsService implements ICocoaPodsService { private $logger: ILogger, private $config: IConfiguration, private $xcconfigService: IXcconfigService, - private $xcodeSelectService: XcodeSelectService + private $xcodeSelectService: XcodeSelectService, ) { this.getCocoaPodsFromPodfile = _.memoize( this._getCocoaPodsFromPodfile, - getHash + getHash, ); } @@ -57,7 +57,7 @@ export class CocoaPodsService implements ICocoaPodsService { public async executePodInstall( projectRoot: string, - xcodeProjPath: string + xcodeProjPath: string, ): Promise { this.$logger.info("Installing pods..."); let podTool = this.$config.USE_POD_SANDBOX ? "sandbox-pod" : "pod"; @@ -79,7 +79,7 @@ export class CocoaPodsService implements ICocoaPodsService { if (!res.includes("Bad CPU type in executable")) { this.$logger.trace( - "Running on arm64 but pod is installed under rosetta2 - running pod through rosetta2" + "Running on arm64 but pod is installed under rosetta2 - running pod through rosetta2", ); args.unshift(podTool); args.unshift("-x86_64"); @@ -92,7 +92,7 @@ export class CocoaPodsService implements ICocoaPodsService { args, "close", { cwd: projectRoot, stdio: ["pipe", process.stdout, process.stdout] }, - { throwError: false } + { throwError: false }, ); if (podInstallResult.exitCode !== 0) { @@ -113,28 +113,28 @@ ${versionResolutionHint}`); public async mergePodXcconfigFile( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): Promise { const podFilesRootDirName = path.join( "Pods", "Target Support Files", - `Pods-${projectData.projectName}` + `Pods-${projectData.projectName}`, ); const podFolder = path.join(platformData.projectRoot, podFilesRootDirName); if (this.$fs.exists(podFolder)) { const pluginsXcconfigFilePaths = this.$xcconfigService.getPluginsXcconfigFilePaths( - platformData.projectRoot + platformData.projectRoot, ); for (const configuration in pluginsXcconfigFilePaths) { const pluginsXcconfigFilePath = pluginsXcconfigFilePaths[configuration]; const podXcconfigFilePath = path.join( podFolder, - `Pods-${projectData.projectName}.${configuration}.xcconfig` + `Pods-${projectData.projectName}.${configuration}.xcconfig`, ); await this.$xcconfigService.mergeFiles( podXcconfigFilePath, - pluginsXcconfigFilePath + pluginsXcconfigFilePath, ); } } @@ -142,13 +142,13 @@ ${versionResolutionHint}`); public async applyPodfileFromAppResources( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): Promise { const { projectRoot, normalizedPlatformName } = platformData; const mainPodfilePath = path.join( projectData.appResourcesDirectoryPath, normalizedPlatformName, - PODFILE_NAME + PODFILE_NAME, ); const projectPodfilePath = this.getProjectPodfilePath(projectRoot); if ( @@ -159,14 +159,14 @@ ${versionResolutionHint}`); NS_BASE_PODFILE, mainPodfilePath, projectData, - platformData + platformData, ); } } public async applyPodfileArchExclusions( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): Promise { const xcodeVersionData = await this.$xcodeSelectService.getXcodeVersion(); @@ -194,24 +194,90 @@ end`.trim(); "NativeScript-CLI-Architecture-Exclusions", exclusionsPodfile, projectData, - platformData + platformData, ); // clean up this.$fs.deleteFile(exclusionsPodfile); } + /** + * Applies the app's build settings that pods need too: raises pods below + * the app's deployment target (from build.xcconfig) up to it, and sets the + * given settings on every pod target. Living in the Pods project rather + * than on the xcodebuild command line, they apply the same way to CLI and + * Xcode builds. + */ + public async applyPodfileBuildSettings( + projectData: IProjectData, + platformData: IPlatformData, + buildSettings: Record | null, + ): Promise { + const { projectRoot } = platformData; + const buildSettingsPodfile = path.join( + projectRoot, + "Podfile-build-settings", + ); + const deploymentTarget = this.$xcconfigService.readPropertyValue( + path.join( + projectData.appResourcesDirectoryPath, + platformData.normalizedPlatformName, + constants.BUILD_XCCONFIG_FILE_NAME, + ), + "IPHONEOS_DEPLOYMENT_TARGET", + ); + + const configLines = _.map( + buildSettings, + (value, key) => + ` config.build_settings[${JSON.stringify(key)}] = ${JSON.stringify(value)}`, + ); + if (deploymentTarget) { + configLines.push( + ` current = config.build_settings["IPHONEOS_DEPLOYMENT_TARGET"]`, + ` if current.nil? || Gem::Version.new(current) < Gem::Version.new(${JSON.stringify(deploymentTarget)})`, + ` config.build_settings["IPHONEOS_DEPLOYMENT_TARGET"] = ${JSON.stringify(deploymentTarget)}`, + ` end`, + ); + } + + if (configLines.length) { + this.$fs.writeFile( + buildSettingsPodfile, + [ + "post_install do |installer|", + " installer.pods_project.targets.each do |target|", + " target.build_configurations.each do |config|", + ...configLines, + " end", + " end", + "end", + ].join(EOL), + ); + } + + // Without a temporary Podfile this removes a previously applied section. + await this.applyPodfileToProject( + "NativeScript-CLI-Build-Settings", + buildSettingsPodfile, + projectData, + platformData, + ); + + this.$fs.deleteFile(buildSettingsPodfile); + } + public async applyPodfileFromExtensions( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ) { const extensionFolderPath = path.join( projectData.getAppResourcesDirectoryPath(), constants.iOSAppResourcesFolderName, - constants.NATIVE_EXTENSION_FOLDER + constants.NATIVE_EXTENSION_FOLDER, ); const projectPodfilePath = this.getProjectPodfilePath( - platformData.projectRoot + platformData.projectRoot, ); if ( @@ -235,7 +301,7 @@ end`.trim(); podfilePath: path.join( extensionFolderPath, name, - constants.PODFILE_NAME + constants.PODFILE_NAME, ), })); @@ -244,9 +310,9 @@ end`.trim(); const regExpToRemove = new RegExp( `${this.getExtensionPodfileHeader( podfilePath, - targetName + targetName, )}[\\s\\S]*?${this.getExtensionPodfileEnd()}`, - "mg" + "mg", ); projectPodFileContent = projectPodFileContent.replace(regExpToRemove, ""); @@ -270,7 +336,7 @@ end`.trim(); moduleName: string, podfilePath: string, projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): Promise { const nativeProjectPath = platformData.projectRoot; if (!this.$fs.exists(podfilePath)) { @@ -278,7 +344,7 @@ end`.trim(); moduleName, podfilePath, projectData, - nativeProjectPath + nativeProjectPath, ); return; } @@ -288,7 +354,7 @@ end`.trim(); podfilePath, moduleName, projectData, - platformData + platformData, ); const pathToProjectPodfile = this.getProjectPodfilePath(nativeProjectPath); const projectPodfileContent = this.$fs.exists(pathToProjectPodfile) @@ -301,24 +367,24 @@ end`.trim(); moduleName, podfilePath, projectData, - nativeProjectPath + nativeProjectPath, ); let finalPodfileContent = this.$fs.exists(pathToProjectPodfile) ? this.getPodfileContentWithoutTarget( projectData, - this.$fs.readText(pathToProjectPodfile) - ) + this.$fs.readText(pathToProjectPodfile), + ) : ""; if ( podfileContent.indexOf( - CocoaPodsService.PODFILE_POST_INSTALL_SECTION_NAME + CocoaPodsService.PODFILE_POST_INSTALL_SECTION_NAME, ) !== -1 ) { finalPodfileContent = this.addPostInstallHook( replacedFunctions, finalPodfileContent, - podfileContent + podfileContent, ); } @@ -326,7 +392,7 @@ end`.trim(); finalPodfileContent = this.$cocoaPodsPlatformManager.addPlatformSection( projectData, podfilePlatformData, - finalPodfileContent + finalPodfileContent, ); } @@ -334,7 +400,7 @@ end`.trim(); this.saveProjectPodfile( projectData, finalPodfileContent, - nativeProjectPath + nativeProjectPath, ); } } @@ -343,33 +409,33 @@ end`.trim(); moduleName: string, podfilePath: string, projectData: IProjectData, - projectRoot: string + projectRoot: string, ): void { if (this.$fs.exists(this.getProjectPodfilePath(projectRoot))) { let projectPodFileContent = this.$fs.readText( - this.getProjectPodfilePath(projectRoot) + this.getProjectPodfilePath(projectRoot), ); // Remove the data between #Begin Podfile and #EndPodfile const regExpToRemove = new RegExp( `${this.getPluginPodfileHeader( - podfilePath + podfilePath, )}[\\s\\S]*?${this.getPluginPodfileEnd()}`, - "mg" + "mg", ); projectPodFileContent = projectPodFileContent.replace(regExpToRemove, ""); projectPodFileContent = this.removePostInstallHook( moduleName, - projectPodFileContent + projectPodFileContent, ); projectPodFileContent = this.$cocoaPodsPlatformManager.removePlatformSection( moduleName, projectPodFileContent, - podfilePath + podfilePath, ); const defaultPodfileBeginning = this.getPodfileHeader( - projectData.projectName + projectData.projectName, ); const defaultContentWithPostInstallHook = `${defaultPodfileBeginning}${this.getPostInstallHookHeader()}end${EOL}end`; const defaultContentWithoutPostInstallHook = `${defaultPodfileBeginning}${EOL}end`; @@ -383,7 +449,7 @@ end`.trim(); } else { this.$fs.writeFile( this.getProjectPodfilePath(projectRoot), - projectPodFileContent + projectPodFileContent, ); } } @@ -391,11 +457,11 @@ end`.trim(); public getPluginPodfilePath(pluginData: IPluginData): string { const pluginPlatformsFolderPath = pluginData.pluginPlatformsFolderPath( - PlatformTypes.ios + PlatformTypes.ios, ); const pluginPodFilePath = path.join( pluginPlatformsFolderPath, - PODFILE_NAME + PODFILE_NAME, ); return pluginPodFilePath; } @@ -403,7 +469,7 @@ end`.trim(); private addPostInstallHook( replacedFunctions: IRubyFunction[], finalPodfileContent: string, - pluginPodfileContent: string + pluginPodfileContent: string, ): string { const postInstallHookStart = this.getPostInstallHookHeader(); let postInstallHookContent = ""; @@ -424,11 +490,11 @@ end`.trim(); if (index !== -1) { const regExp = new RegExp( `(${regExpEscape(postInstallHookStart)}[\\s\\S]*?)(\\bend\\b)`, - "m" + "m", ); finalPodfileContent = finalPodfileContent.replace( regExp, - `$1${postInstallHookContent.trimRight()}${EOL}$2` + `$1${postInstallHookContent.trimRight()}${EOL}$2`, ); } else { if (finalPodfileContent.length > 0) { @@ -444,13 +510,13 @@ end`.trim(); private getPodfileContentWithoutTarget( projectData: IProjectData, - projectPodfileContent: string + projectPodfileContent: string, ): string { const podFileHeader = this.getPodfileHeader(projectData.projectName); if (_.startsWith(projectPodfileContent, podFileHeader)) { projectPodfileContent = projectPodfileContent.substr( - podFileHeader.length + podFileHeader.length, ); const podFileFooter = this.getPodfileFooter(); @@ -458,7 +524,7 @@ end`.trim(); if (_.endsWith(projectPodfileContent, podFileFooter)) { projectPodfileContent = projectPodfileContent.substr( 0, - projectPodfileContent.length - podFileFooter.length + projectPodfileContent.length - podFileFooter.length, ); } } @@ -469,11 +535,11 @@ end`.trim(); private saveProjectPodfile( projectData: IProjectData, projectPodfileContent: string, - projectRoot: string + projectRoot: string, ): void { projectPodfileContent = this.getPodfileContentWithoutTarget( projectData, - projectPodfileContent + projectPodfileContent, ); const podFileHeader = this.getPodfileHeader(projectData.projectName); const podFileFooter = this.getPodfileFooter(); @@ -484,14 +550,14 @@ end`.trim(); private removePostInstallHook( moduleName: string, - projectPodFileContent: string + projectPodFileContent: string, ): string { const regExp = new RegExp( `^.*?${this.getHookBasicFuncNameForPlugin( CocoaPodsService.PODFILE_POST_INSTALL_SECTION_NAME, - moduleName + moduleName, )}.*?$\\r?\\n`, - "gm" + "gm", ); projectPodFileContent = projectPodFileContent.replace(regExp, ""); return projectPodFileContent; @@ -499,7 +565,7 @@ end`.trim(); private getHookBasicFuncNameForPlugin( hookName: string, - pluginName: string + pluginName: string, ): string { // nativescript-hook and nativescript_hook should have different names, so replace all _ with ___ first and then replace all special symbols with _ // This will lead to a clash in case plugins are called nativescript-hook and nativescript___hook @@ -512,13 +578,13 @@ end`.trim(); private replaceHookContent( hookName: string, podfileContent: string, - pluginName: string + pluginName: string, ): { replacedContent: string; newFunctions: IRubyFunction[] } { const hookStart = `${hookName} do`; const hookDefinitionRegExp = new RegExp( `${hookStart} *(\\|(\\w+)\\|)?`, - "g" + "g", ); const newFunctions: IRubyFunction[] = []; @@ -528,11 +594,11 @@ end`.trim(); substring: string, firstGroup: string, secondGroup: string, - index: number + index: number, ): string => { const newFunctionName = `${this.getHookBasicFuncNameForPlugin( hookName, - pluginName + pluginName, )}_${newFunctions.length}`; let newDefinition = `def ${newFunctionName}`; @@ -545,7 +611,7 @@ end`.trim(); newFunctions.push(rubyFunction); return newDefinition; - } + }, ); return { replacedContent, newFunctions }; @@ -563,11 +629,11 @@ end`.trim(); private getExtensionPodfileHeader( extensionPodFilePath: string, - targetName: string + targetName: string, ): string { const targetHeader = `target "${targetName.trim()}" do`; return `${this.getPluginPodfileHeader( - extensionPodFilePath + extensionPodFilePath, )}${EOL}${targetHeader}`; } @@ -583,7 +649,7 @@ end`.trim(); pluginPodFilePath: string, pluginName: string, projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): { podfileContent: string; replacedFunctions: IRubyFunction[]; @@ -593,11 +659,11 @@ end`.trim(); const data = this.replaceHookContent( CocoaPodsService.PODFILE_POST_INSTALL_SECTION_NAME, pluginPodfileContent, - pluginName + pluginName, ); const cocoapodsData = this.$cocoaPodsPlatformManager.replacePlatformRow( data.replacedContent, - pluginPodFilePath + pluginPodFilePath, ); const podfilePlatformData = cocoapodsData.podfilePlatformData; let replacedContent = cocoapodsData.replacedContent; @@ -610,13 +676,13 @@ end`.trim(); replacedContent = this.overridePodsFromFile( replacedContent, projectData, - platformData + platformData, ); } return { podfileContent: `${this.getPluginPodfileHeader( - pluginPodFilePath + pluginPodFilePath, )}${EOL}${replacedContent}${EOL}${this.getPluginPodfileEnd()}`, replacedFunctions: data.newFunctions, podfilePlatformData, @@ -625,19 +691,19 @@ end`.trim(); private getMainPodFilePath( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): string { return path.join( projectData.appResourcesDirectoryPath, platformData.normalizedPlatformName, - PODFILE_NAME + PODFILE_NAME, ); } private isMainPodFile( podFilePath: string, projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): boolean { const mainPodfilePath = this.getMainPodFilePath(projectData, platformData); @@ -647,7 +713,7 @@ end`.trim(); private overridePodsFromFile( podfileContent: string, projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): string { const mainPodfilePath = this.getMainPodFilePath(projectData, platformData); @@ -657,7 +723,7 @@ end`.trim(); _.forEach(pods, (pod) => { podfileContent = podfileContent.replace( new RegExp(`^[ ]*pod\\s*["']${pod}['"].*$`, "gm"), - "#$&" + "#$&", ); }); } diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index 7ac6f30c0e..c7cc428353 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -7,7 +7,7 @@ import * as helpers from "../common/helpers"; import { attachAwaitDetach } from "../common/helpers"; import * as projectServiceBaseLib from "./platform-project-service-base"; import { PlistSession, Reporter } from "../tools/plist-merge/plist-session"; -import { EOL } from "os"; +import { EOL, userInfo } from "os"; import * as plist from "plist"; import * as fastGlob from "fast-glob"; import { IOSProvisionService } from "./ios-provision-service"; @@ -70,6 +70,31 @@ export const SimulatorPlatformSdkName = "iphonesimulator"; export const VisionDevicePlatformSdkName = "xros"; export const VisionSimulatorPlatformSdkName = "xrsimulator"; +const DERIVED_DATA_RELATIVE_PATH = path.join( + constants.BUILD_DIR, + "DerivedData", +); + +/** + * With a workspace-relative DerivedData location, Xcode appends the workspace + * name to it, so this matches what Xcode uses and lets CLI and Xcode builds + * share one cache. + */ +export const getDerivedDataPath = ( + projectRoot: string, + projectName: string, +): string => path.join(projectRoot, DERIVED_DATA_RELATIVE_PATH, projectName); + +/** + * Lives next to the platform folder rather than in it, so regenerating + * platforms/ios still reuses unchanged compiles; deleting platforms/ clears it. + */ +export const getCompilationCachePath = ( + platformsDir: string, + platformName: string, +): string => + path.join(platformsDir, ".cache", platformName, "CompilationCache"); + const FRAMEWORK_EXTENSIONS = [".framework", ".xcframework"]; const getPlatformSdkName = (buildData: IBuildData): string => { @@ -177,8 +202,9 @@ export class IOSProjectService getBuildOutputPath: (options: IBuildData): string => { const config = getConfigurationName(!options || options.release); return path.join( - projectRoot, - constants.BUILD_DIR, + getDerivedDataPath(projectRoot, projectData.projectName), + "Build", + "Products", `${config}-${getPlatformSdkName(options)}`, ); }, @@ -437,6 +463,7 @@ export class IOSProjectService buildData: IOSBuildData, ): Promise { const platformData = this.getPlatformData(projectData); + this.ensureWorkspaceDerivedDataLocation(projectData, platformData); // On a first build, the runtime (and any other Swift packages) download // here. Pre-resolve under a clear spinner so the subsequent @@ -928,6 +955,8 @@ export class IOSProjectService "The support for Apple Watch App is currently in Beta. For more information about the current development state and any known issues, please check the relevant GitHub issue: https://github.com/NativeScript/nativescript-cli/issues/4589", ); } + + this.ensureWorkspaceDerivedDataLocation(projectData, platformData); } public prepareAppResources(projectData: IProjectData): void { @@ -1292,6 +1321,11 @@ export class IOSProjectService projectData, platformData, ); + await this.$cocoapodsService.applyPodfileBuildSettings( + projectData, + platformData, + this.getCompilationCacheSettings(projectData), + ); await this.$cocoapodsService.applyPodfileFromExtensions( projectData, platformData, @@ -1370,6 +1404,10 @@ export class IOSProjectService } } + // `pod install` may have just created the .xcworkspace, and Swift + // packages resolve into the location this sets. + this.ensureWorkspaceDerivedDataLocation(projectData, platformData); + await this.$spmService.applySPMPackages( platformData, projectData, @@ -1377,6 +1415,73 @@ export class IOSProjectService ); } + /** + * Puts DerivedData inside the platforms folder for both xcodebuild and + * Xcode, so CLI and Xcode builds share one cache. Xcode ignores this + * setting in xcshareddata, so it has to be the per-user file. + */ + private ensureWorkspaceDerivedDataLocation( + projectData: IProjectData, + platformData: IPlatformData, + ): void { + if (this.$options.hostProjectPath) { + return; + } + + const { projectRoot } = platformData; + const xcodeprojPath = path.join( + projectRoot, + `${projectData.projectName}.xcodeproj`, + ); + const podsWorkspacePath = path.join( + projectRoot, + `${projectData.projectName}.xcworkspace`, + ); + const workspacePaths = [ + ...(this.$fs.exists(xcodeprojPath) + ? [path.join(xcodeprojPath, "project.xcworkspace")] + : []), + ...(this.$fs.exists(podsWorkspacePath) ? [podsWorkspacePath] : []), + ]; + const settings: Record = { + DerivedDataLocationStyle: "WorkspaceRelativePath", + DerivedDataCustomLocation: DERIVED_DATA_RELATIVE_PATH, + }; + + for (const workspacePath of workspacePaths) { + const contentsPath = path.join(workspacePath, "contents.xcworkspacedata"); + if (!this.$fs.exists(contentsPath)) { + this.$fs.writeFile( + contentsPath, + `${EOL}${EOL} ${EOL} ${EOL}${EOL}`, + ); + } + + const settingsPath = path.join( + workspacePath, + "xcuserdata", + `${userInfo().username}.xcuserdatad`, + "WorkspaceSettings.xcsettings", + ); + let existing: Record = {}; + if (this.$fs.exists(settingsPath)) { + try { + existing = plist.parse(this.$fs.readText(settingsPath)); + } catch (err) { + this.$logger.trace(`Unable to parse ${settingsPath}: `, err); + } + } + if (_.isMatch(existing, settings)) { + continue; + } + + this.$fs.writeFile( + settingsPath, + plist.build({ ...existing, ...settings }), + ); + } + } + public beforePrepareAllPlugins( projectData: IProjectData, dependencies?: IDependencyData[], @@ -1872,6 +1977,27 @@ export class IOSProjectService } } + const compilationCacheSettings = + this.getCompilationCacheSettings(projectData); + if (compilationCacheSettings) { + const tempDir = await this.$tempService.mkdirSync("compilation-cache"); + const tempXcconfigPath = path.join(tempDir, "compilation-cache.xcconfig"); + this.$fs.writeFile( + tempXcconfigPath, + _.map( + compilationCacheSettings, + (value, key) => `${key} = ${value}${EOL}`, + ).join(""), + ); + // Merged last, so the app's or a plugin's xcconfig can turn it off. + for (const pluginsXcconfigFilePath of pluginsXcconfigFilePaths) { + await this.$xcconfigService.mergeFiles( + tempXcconfigPath, + pluginsXcconfigFilePath, + ); + } + } + for (const pluginsXcconfigFilePath of pluginsXcconfigFilePaths) { // Set Entitlements Property to point to default file if not set explicitly by the user. const entitlementsPropertyValue = this.$xcconfigService.readPropertyValue( @@ -1909,6 +2035,31 @@ export class IOSProjectService } } + /** + * Xcode's compilation cache replays compiles whose inputs are unchanged, + * even after the project is regenerated and file timestamps change. + */ + public getCompilationCacheSettings( + projectData: IProjectData, + ): Record | null { + if (this.$options.hostProjectPath) { + return null; + } + + const platformData = this.getPlatformData(projectData); + return { + COMPILATION_CACHE_ENABLE_CACHING: + this.$xcconfigService.readPropertyValue( + this.getBuildXCConfigFilePath(projectData), + "COMPILATION_CACHE_ENABLE_CACHING", + ) || "YES", + COMPILATION_CACHE_CAS_PATH: getCompilationCachePath( + projectData.platformsDir, + platformData.platformNameLowerCase, + ), + }; + } + private getBuildXCConfigFilePath(projectData: IProjectData): string { const buildXCConfig = path.join( projectData.appResourcesDirectoryPath, diff --git a/lib/services/ios/spm-service.ts b/lib/services/ios/spm-service.ts index b4515b4dc7..d31b07c4b4 100644 --- a/lib/services/ios/spm-service.ts +++ b/lib/services/ios/spm-service.ts @@ -1,6 +1,7 @@ import { injector } from "../../common/yok"; import { IProjectConfigService, IProjectData } from "../../definitions/project"; import { IPlatformData } from "../../definitions/platform"; +import { getDerivedDataPath } from "../ios-project-service"; import { IFileSystem } from "../../common/declarations"; import { ITerminalSpinnerService } from "../../definitions/terminal-spinner-service"; import { color } from "../../color"; @@ -531,9 +532,9 @@ export class SPMService implements ISPMService { } /** - * True when a Package.resolved already exists for the project — i.e. packages - * have been resolved at least once, so the build's own resolve step will be a - * fast no-op rather than a slow, silent first-time download. + * True when a Package.resolved exists and packages were already fetched into + * the build's DerivedData, so the build's own resolve step will be a fast + * no-op rather than a slow, silent first-time download. */ private arePackagesResolved( platformData: IPlatformData, @@ -556,7 +557,15 @@ export class SPMService implements ISPMService { "Package.resolved", ), ]; - return candidates.some((p) => this.$fs.exists(p)); + return ( + candidates.some((p) => this.$fs.exists(p)) && + this.$fs.exists( + path.join( + getDerivedDataPath(platformData.projectRoot, projectData.projectName), + "SourcePackages", + ), + ) + ); } } injector.register("spmService", SPMService); diff --git a/lib/services/ios/xcodebuild-args-service.ts b/lib/services/ios/xcodebuild-args-service.ts index fbc5a2ebe4..0104caf2fe 100644 --- a/lib/services/ios/xcodebuild-args-service.ts +++ b/lib/services/ios/xcodebuild-args-service.ts @@ -7,18 +7,13 @@ import { IBuildConfig, IiOSBuildConfig, } from "../../definitions/project"; -import { IXcconfigService } from "../../declarations"; +import { IOptions, IXcconfigService } from "../../declarations"; import { IPlatformData } from "../../definitions/platform"; import { IFileSystem } from "../../common/declarations"; import { injector } from "../../common/yok"; import * as _ from "lodash"; -import { - DevicePlatformSdkName, - SimulatorPlatformSdkName, - VisionDevicePlatformSdkName, - VisionSimulatorPlatformSdkName, -} from "../ios-project-service"; +import { getDerivedDataPath } from "../ios-project-service"; export class XcodebuildArgsService implements IXcodebuildArgsService { constructor( @@ -27,6 +22,7 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { private $fs: IFileSystem, private $iOSWatchAppService: IIOSWatchAppService, private $logger: ILogger, + private $options: IOptions, private $xcconfigService: IXcconfigService, ) {} @@ -43,13 +39,11 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { args = args.concat(["CODE_SIGN_IDENTITY="]); } - let destination = "generic/platform=iOS Simulator"; - - let isvisionOS = this.$devicePlatformsConstants.isvisionOS( - buildConfig.platform, - ); + let destination = buildConfig._device + ? `platform=iOS Simulator,id=${buildConfig._device.deviceInfo.identifier}` + : "generic/platform=iOS Simulator"; - if (isvisionOS) { + if (this.$devicePlatformsConstants.isvisionOS(buildConfig.platform)) { destination = "generic/platform=visionOS Simulator"; if (buildConfig._device) { destination += `,id=${buildConfig._device.deviceInfo.identifier}`; @@ -64,15 +58,6 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { "-configuration", buildConfig.release ? Configurations.Release : Configurations.Debug, ]) - .concat( - this.getBuildCommonArgs( - platformData, - projectData, - isvisionOS - ? VisionSimulatorPlatformSdkName - : SimulatorPlatformSdkName, - ), - ) .concat(this.getBuildLoggingArgs()) .concat(this.getXcodeProjectArgs(platformData, projectData)); @@ -89,10 +74,14 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { platformData.getBuildOutputPath(buildConfig), projectData.projectName + ".xcarchive", ); - let destination = "generic/platform=iOS"; let isvisionOS = this.$devicePlatformsConstants.isvisionOS( buildConfig.platform, ); + const shouldArchive = this.shouldArchiveDeviceBuild(buildConfig); + let destination = + !shouldArchive && buildConfig._device + ? `platform=iOS,id=${buildConfig._device.deviceInfo.identifier}` + : "generic/platform=iOS"; if (isvisionOS) { destination = "generic/platform=visionOS"; @@ -103,22 +92,13 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { const args = [ "-destination", destination, - "archive", - "-archivePath", - archivePath, + ...(shouldArchive ? ["archive", "-archivePath", archivePath] : ["build"]), "-configuration", buildConfig.release ? Configurations.Release : Configurations.Debug, "-allowProvisioningUpdates", ] .concat(this.getXcodeProjectArgs(platformData, projectData)) .concat(architectures) - .concat( - this.getBuildCommonArgs( - platformData, - projectData, - isvisionOS ? VisionDevicePlatformSdkName : DevicePlatformSdkName, - ), - ) .concat(this.getBuildLoggingArgs()); // pbxproj-dom sets CODE_SIGN_IDENTITY[sdk=iphoneos*] which doesn't match @@ -134,10 +114,20 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { return args; } + /** + * Debug device builds use a plain build, like Xcode's Run, so both share + * one set of intermediates; archives are only needed for release. + */ + public shouldArchiveDeviceBuild( + buildConfig: IBuildConfig & { buildForAppStore?: boolean }, + ): boolean { + return !!(buildConfig.release || buildConfig.buildForAppStore); + } + private async getArchitecturesArgs( buildConfig: IBuildConfig, ): Promise { - const args = []; + const args: string[] = []; if (this.$devicePlatformsConstants.isvisionOS(buildConfig.platform)) { // visionOS builds (device/simulator) are arm64-only; rely on destination for arch @@ -146,10 +136,22 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { return args; } - const devicesArchitectures = buildConfig.buildForDevice - ? await this.getArchitecturesFromConnectedDevices(buildConfig) - : []; - if (!buildConfig.buildForDevice || devicesArchitectures.length > 1) { + if (!buildConfig.buildForDevice) { + // A specific simulator destination already limits the build to its + // architecture, the same way Xcode builds for it. + if (!buildConfig._device) { + args.push("ONLY_ACTIVE_ARCH=NO"); + } + return args; + } + + if (buildConfig._device && !this.shouldArchiveDeviceBuild(buildConfig)) { + return args; + } + + const devicesArchitectures = + await this.getArchitecturesFromConnectedDevices(buildConfig); + if (devicesArchitectures.length > 1) { args.push("ONLY_ACTIVE_ARCH=NO"); } @@ -180,6 +182,17 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { skipMacroValidation, ]; + // Otherwise the workspace's DerivedData setting decides the location. + // Passing -derivedDataPath as well would move the module and SDK stat + // caches, whose paths end up in every compile command, so CLI and Xcode + // builds would no longer reuse each other's output. + if (this.$options.hostProjectPath) { + extraArgs.push( + "-derivedDataPath", + getDerivedDataPath(platformData.projectRoot, projectData.projectName), + ); + } + // Selects how xcodebuild authenticates against private Swift Package // registries ("netrc" or "keychain"). It describes the machine's // credential setup rather than the build, so it is read from the @@ -206,29 +219,20 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { // references: https://medium.com/@iostechset/why-cocoapods-eats-app-icons-79fe729808d4 // https://github.com/CocoaPods/CocoaPods/issues/7003 - // Xcode 26 makes Swift "explicitly built modules" the default. A - // regression there prevents macro/compiler-plugin SPM targets from - // resolving their swift-syntax module dependencies, failing with: - // "Unable to resolve module dependency: 'SwiftSyntax'" (and SwiftParser, - // SwiftSyntaxMacros, SwiftCompilerPlugin, SwiftDiagnostics). - // Passed as a command-line build setting so it overrides ALL targets, - // including the package targets we don't control. + // Passed as a command-line build setting so it also reaches package + // targets, but only when the app sets it: any value Xcode wouldn't use + // itself stops CLI and Xcode builds from reusing each other's output. + // Early Xcode 26 releases fail on macro packages with explicit modules + // ("Unable to resolve module dependency: 'SwiftSyntax'"); setting it to + // NO in build.xcconfig works around that. // ref: https://forums.swift.org/t/xcode-26-unable-to-find-module-dependency/80516 const explicitModulesProperty = "SWIFT_ENABLE_EXPLICIT_MODULES"; - const explicitModulesValue = - this.$xcconfigService.readPropertyValue( - BUILD_SETTINGS_FILE_PATH, - explicitModulesProperty, - ) || "NO"; - extraArgs.push(`${explicitModulesProperty}=${explicitModulesValue}`); - - const deployTargetProperty = "IPHONEOS_DEPLOYMENT_TARGET"; - const deployTargetVersion = this.$xcconfigService.readPropertyValue( + const explicitModulesValue = this.$xcconfigService.readPropertyValue( BUILD_SETTINGS_FILE_PATH, - deployTargetProperty, + explicitModulesProperty, ); - if (deployTargetVersion) { - extraArgs.push(`${deployTargetProperty}=${deployTargetVersion}`); + if (explicitModulesValue) { + extraArgs.push(`${explicitModulesProperty}=${explicitModulesValue}`); } const swiftUIBootProperty = "NS_SWIFTUI_BOOT"; @@ -267,26 +271,6 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { return this.$logger.getLevel() === "INFO" ? ["-quiet"] : []; } - private getBuildCommonArgs( - platformData: IPlatformData, - projectData: IProjectData, - platformSdkName: string, - ): string[] { - let args: string[] = []; - - if (!this.$iOSWatchAppService.hasWatchApp(platformData, projectData)) { - args = args.concat(["-sdk", platformSdkName]); - } - - args = args.concat([ - "BUILD_DIR=" + path.join(platformData.projectRoot, constants.BUILD_DIR), - "SHARED_PRECOMPS_DIR=" + - path.join(platformData.projectRoot, constants.BUILD_DIR, "sharedpch"), - ]); - - return args; - } - private async getArchitecturesFromConnectedDevices( buildConfig: IiOSBuildConfig, ): Promise { diff --git a/lib/services/ios/xcodebuild-service.ts b/lib/services/ios/xcodebuild-service.ts index 4f8308dfb5..6c7d8904a9 100644 --- a/lib/services/ios/xcodebuild-service.ts +++ b/lib/services/ios/xcodebuild-service.ts @@ -2,32 +2,38 @@ import * as path from "path"; import { IPlatformData } from "../../definitions/platform"; import { IProjectData, IBuildConfig } from "../../definitions/project"; import { injector } from "../../common/yok"; +import { IFileSystem, IChildProcess } from "../../common/declarations"; export class XcodebuildService implements IXcodebuildService { constructor( + private $childProcess: IChildProcess, private $exportOptionsPlistService: IExportOptionsPlistService, + private $fs: IFileSystem, private $xcodebuildArgsService: IXcodebuildArgsService, - private $xcodebuildCommandService: IXcodebuildCommandService + private $xcodebuildCommandService: IXcodebuildCommandService, ) {} public async buildForDevice( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const args = await this.$xcodebuildArgsService.getBuildForDeviceArgs( platformData, projectData, - buildConfig + buildConfig, ); await this.$xcodebuildCommandService.executeCommand(args, { cwd: platformData.projectRoot, stdio: buildConfig && buildConfig.buildOutputStdio, }); + if (!this.$xcodebuildArgsService.shouldArchiveDeviceBuild(buildConfig)) { + return this.packageDevelopmentIpa(platformData, projectData, buildConfig); + } const archivePath = await this.createDevelopmentArchive( platformData, projectData, - buildConfig + buildConfig, ); return archivePath; } @@ -35,12 +41,12 @@ export class XcodebuildService implements IXcodebuildService { public async buildForSimulator( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const args = await this.$xcodebuildArgsService.getBuildForSimulatorArgs( platformData, projectData, - buildConfig + buildConfig, ); await this.$xcodebuildCommandService.executeCommand(args, { cwd: platformData.projectRoot, @@ -51,12 +57,12 @@ export class XcodebuildService implements IXcodebuildService { public async buildForAppStore( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const args = await this.$xcodebuildArgsService.getBuildForDeviceArgs( platformData, projectData, - buildConfig + buildConfig, ); await this.$xcodebuildCommandService.executeCommand(args, { cwd: platformData.projectRoot, @@ -65,25 +71,74 @@ export class XcodebuildService implements IXcodebuildService { const archivePath = await this.createDistributionArchive( platformData, projectData, - buildConfig + buildConfig, ); return archivePath; } + /** + * Packages the signed .app from a plain build as an .ipa, at the same path + * an exported development archive would use. + */ + private async packageDevelopmentIpa( + platformData: IPlatformData, + projectData: IProjectData, + buildConfig: IBuildConfig, + ): Promise { + const outputPath = platformData.getBuildOutputPath(buildConfig); + const appPath = path.join(outputPath, `${projectData.projectName}.app`); + const ipaPath = path.join(outputPath, `${projectData.projectName}.ipa`); + // Kept next to the .app so cp -c can clone it instead of copying. + const payloadPath = path.join(outputPath, "ipa-staging", "Payload"); + + this.$fs.deleteDirectory(path.dirname(payloadPath)); + this.$fs.deleteFile(ipaPath); + this.$fs.createDirectory(payloadPath); + try { + await this.$childProcess.spawnFromEvent( + "cp", + ["-cR", appPath, payloadPath], + "close", + {}, + { throwError: true }, + ); + await this.$childProcess.spawnFromEvent( + "ditto", + [ + "-c", + "-k", + "--norsrc", + "--zlibCompressionLevel", + "0", + "--keepParent", + payloadPath, + ipaPath, + ], + "close", + {}, + { throwError: true }, + ); + } finally { + this.$fs.deleteDirectory(path.dirname(payloadPath)); + } + + return ipaPath; + } + private async createDevelopmentArchive( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const archivePath = path.join( platformData.getBuildOutputPath(buildConfig), - projectData.projectName + ".xcarchive" + projectData.projectName + ".xcarchive", ); const output = await this.$exportOptionsPlistService.createDevelopmentExportOptionsPlist( archivePath, projectData, - buildConfig + buildConfig, ); const args = [ "-exportArchive", @@ -105,17 +160,17 @@ export class XcodebuildService implements IXcodebuildService { private async createDistributionArchive( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const archivePath = path.join( platformData.getBuildOutputPath(buildConfig), - projectData.projectName + ".xcarchive" + projectData.projectName + ".xcarchive", ); const output = await this.$exportOptionsPlistService.createDistributionExportOptionsPlist( archivePath, projectData, - buildConfig + buildConfig, ); const provision = buildConfig.provision || buildConfig.mobileProvisionIdentifier; diff --git a/test/cocoapods-service.ts b/test/cocoapods-service.ts index 24281b5636..aa9431df92 100644 --- a/test/cocoapods-service.ts +++ b/test/cocoapods-service.ts @@ -6,6 +6,9 @@ import * as _ from "lodash"; import { LoggerStub, ErrorsStub } from "./stubs"; import { XcconfigService } from "../lib/services/xcconfig-service"; import * as path from "path"; +import { mkdtempSync } from "fs"; +import { tmpdir } from "os"; +import { FileSystem } from "../lib/common/file-system"; import { CocoaPodsPlatformManager } from "../lib/services/cocoapods-platform-manager"; import { IPluginData } from "../lib/definitions/plugins"; import { ICocoaPodsService, IProjectData } from "../lib/definitions/project"; @@ -27,10 +30,10 @@ interface IMergePodfileHooksTestCase { pluginData?: IPluginData; } -function createTestInjector(): IInjector { +function createTestInjector(fs: any = {}): IInjector { const testInjector: IInjector = new yok.Yok(); - testInjector.register("fs", {}); + testInjector.register("fs", fs); testInjector.register("cocoapodsService", CocoaPodsService); testInjector.register("childProcess", {}); testInjector.register("errors", ErrorsStub); @@ -61,7 +64,7 @@ function changeNewLineCharacter(input: string): string { describe("Cocoapods service", () => { if (require("os").platform() === "win32") { console.log( - "Skipping 'Cocoapods service' tests. They can work only on macOS and Linux" + "Skipping 'Cocoapods service' tests. They can work only on macOS and Linux", ); return; } @@ -92,7 +95,7 @@ describe("Cocoapods service", () => { injector: IInjector, podfileContent: string, projectPodfileContent?: string, - appResourcesPodfileContent?: string + appResourcesPodfileContent?: string, ): void => { const fs: IFileSystem = injector.resolve("fs"); @@ -567,21 +570,21 @@ end`, mockFileSystem( testInjector, testCase.input, - testCase.projectPodfileContent + testCase.projectPodfileContent, ); await cocoapodsService.applyPodfileToProject( testCase.pluginData ? testCase.pluginData.name : mockPluginData.name, cocoapodsService.getPluginPodfilePath( - testCase.pluginData || mockPluginData + testCase.pluginData || mockPluginData, ), mockProjectData, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.output) + changeNewLineCharacter(testCase.output), ); }); }); @@ -791,19 +794,19 @@ end`, mockFileSystem( testInjector, testCase.input, - testCase.projectPodfileContent + testCase.projectPodfileContent, ); cocoapodsService.removePodfileFromProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), mockProjectData, - nativeProjectPath + nativeProjectPath, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.output) + changeNewLineCharacter(testCase.output), ); }); }); @@ -818,7 +821,7 @@ end`, childProcess.exec = async ( command: string, options?: any, - execOptions?: IExecOptions + execOptions?: IExecOptions, ): Promise => { if (command === "arch -x86_64 pod --version") { // This is the command that is used to check if cocoapods is installed under Rosetta 2 @@ -835,7 +838,7 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => ({ stdout: "", stderr: "", @@ -857,7 +860,7 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => { commandCalled = command; return { @@ -880,7 +883,7 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => { commandCalled = command; assert.deepStrictEqual(args, ["install"]); @@ -908,7 +911,7 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => { return { stdout: "", @@ -919,7 +922,7 @@ end`, await assert.isRejected( cocoapodsService.executePodInstall(projectRoot, xcodeProjPath), - "'pod install' command failed." + "'pod install' command failed.", ); }); @@ -935,14 +938,14 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => { return expectedResult; }; const result = await cocoapodsService.executePodInstall( projectRoot, - xcodeProjPath + xcodeProjPath, ); assert.deepStrictEqual(result, expectedResult); }); @@ -1084,13 +1087,13 @@ end`, pod.name, pod.path, projectData, - platformData + platformData, ); } assert.deepStrictEqual( projectPodfileContent, - testCase.expectedProjectPodfileContentAfterApply + testCase.expectedProjectPodfileContentAfterApply, ); for (const pod of testCase.podsToRemove) { @@ -1098,13 +1101,13 @@ end`, pod.name, pod.path, projectData, - projectPodfilePath + projectPodfilePath, ); } assert.deepStrictEqual( projectPodfileContent, - testCase.expectedProjectPodfileContentAfterRemove + testCase.expectedProjectPodfileContentAfterRemove, ); }); }); @@ -1365,13 +1368,13 @@ end`, pod.name, pod.path, projectData, - platformData + platformData, ); } assert.deepStrictEqual( projectPodfileContent, - testCase.expectedProjectPodfileContent + testCase.expectedProjectPodfileContent, ); }); }); @@ -1413,27 +1416,27 @@ end`, testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.appResourcesPodContent + testCase.appResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedOutput) + changeNewLineCharacter(testCase.expectedOutput), ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedFinalOutput) + changeNewLineCharacter(testCase.expectedFinalOutput), ); }); @@ -1485,49 +1488,49 @@ end`, testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.appResourcesPodContent + testCase.appResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedOutput) + changeNewLineCharacter(testCase.expectedOutput), ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedIntermidiatOutput) + changeNewLineCharacter(testCase.expectedIntermidiatOutput), ); mockFileSystem( testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.updatedAppResourcesPodContent + testCase.updatedAppResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedFinalOutput) + changeNewLineCharacter(testCase.expectedFinalOutput), ); }); @@ -1579,50 +1582,133 @@ end`, testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.appResourcesPodContent + testCase.appResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedOutput) + changeNewLineCharacter(testCase.expectedOutput), ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedIntermidiatOutput) + changeNewLineCharacter(testCase.expectedIntermidiatOutput), ); mockFileSystem( testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.updatedAppResourcesPodContent + testCase.updatedAppResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedFinalOutput) + changeNewLineCharacter(testCase.expectedFinalOutput), ); }); }); }); + +describe("applyPodfileBuildSettings", () => { + const projectName = "projectName"; + + function setup(buildXcconfig: string) { + const dir = mkdtempSync(path.join(tmpdir(), "podfile-deployment-target-")); + const testInjector = createTestInjector(FileSystem); + const fs: IFileSystem = testInjector.resolve("fs"); + const cocoapodsService: ICocoaPodsService = + testInjector.resolve("cocoapodsService"); + const projectData: any = { + projectDir: dir, + projectName, + appResourcesDirectoryPath: path.join(dir, "App_Resources"), + nsConfig: { overridePods: false }, + }; + const platformData: any = { + projectRoot: path.join(dir, "platforms", "ios"), + normalizedPlatformName: "iOS", + }; + const podfilePath = path.join(platformData.projectRoot, "Podfile"); + const xcconfigPath = path.join( + projectData.appResourcesDirectoryPath, + "iOS", + "build.xcconfig", + ); + fs.writeFile( + podfilePath, + `use_frameworks!${EOL}${EOL}target "${projectName}" do${EOL}${EOL}end`, + ); + fs.writeFile(xcconfigPath, buildXcconfig); + const apply = (buildSettings: Record = null) => + cocoapodsService.applyPodfileBuildSettings( + projectData, + platformData, + buildSettings, + ); + return { fs, podfilePath, xcconfigPath, platformData, apply }; + } + + it("raises pods below the app's deployment target", async () => { + const { fs, podfilePath, platformData, apply } = setup( + "IPHONEOS_DEPLOYMENT_TARGET = 18.0;", + ); + + await apply(); + + const podfile = fs.readText(podfilePath); + assert.include(podfile, "# Begin Podfile - "); + assert.include(podfile, 'Gem::Version.new("18.0")'); + assert.include(podfile, "post_install do |installer|"); + assert.isFalse( + fs.exists(path.join(platformData.projectRoot, "Podfile-build-settings")), + ); + }); + + it("sets the given build settings on every pod target", async () => { + const { fs, podfilePath, apply } = setup(""); + + await apply({ COMPILATION_CACHE_ENABLE_CACHING: "YES" }); + + const podfile = fs.readText(podfilePath); + assert.include( + podfile, + 'config.build_settings["COMPILATION_CACHE_ENABLE_CACHING"] = "YES"', + ); + assert.notInclude(podfile, "IPHONEOS_DEPLOYMENT_TARGET"); + }); + + it("removes the section once build.xcconfig no longer sets a deployment target", async () => { + const { fs, podfilePath, xcconfigPath, apply } = setup( + "IPHONEOS_DEPLOYMENT_TARGET = 18.0;", + ); + await apply(); + + fs.writeFile(xcconfigPath, ""); + await apply(); + + const podfile = fs.readText(podfilePath); + assert.notInclude(podfile, "Podfile-build-settings"); + assert.notInclude(podfile, "IPHONEOS_DEPLOYMENT_TARGET"); + }); +}); diff --git a/test/ios-project-service.ts b/test/ios-project-service.ts index e55c28f3f2..4d54f6477d 100644 --- a/test/ios-project-service.ts +++ b/test/ios-project-service.ts @@ -39,7 +39,8 @@ import { } from "./stubs"; import { xcode } from "../lib/node/xcode"; import { mkdtempSync } from "fs"; -import { tmpdir } from "os"; +import { tmpdir, userInfo } from "os"; +import * as plist from "plist"; import * as path from "path"; import { CocoaPodsPlatformManager } from "../lib/services/cocoapods-platform-manager"; import { XcodebuildService } from "../lib/services/ios/xcodebuild-service"; @@ -406,6 +407,9 @@ describeOnMacOS("Cocoapods support", () => { const projectPath = mkdtempSync(path.join(tmpdir(), `${projectName}-`)); const testInjector = createTestInjector(projectPath, projectName); + // Keeps the expected Podfile to the plugin's own section. + testInjector.resolve("iOSProjectService").getCompilationCacheSettings = + (): null => null; const fs: IFileSystem = testInjector.resolve("fs"); const packageJsonData = { @@ -516,6 +520,9 @@ describeOnMacOS("Cocoapods support", () => { const projectPath = mkdtempSync(path.join(tmpdir(), `${projectName}-`)); const testInjector = createTestInjector(projectPath, projectName); + // Keeps the expected Podfile to the plugin's own section. + testInjector.resolve("iOSProjectService").getCompilationCacheSettings = + (): null => null; const fs: IFileSystem = testInjector.resolve("fs"); const packageJsonData = { @@ -1392,7 +1399,7 @@ describeOnMacOS("Merge Project XCConfig files", () => { }); }); - it("creates empty plugins-.xcconfig in case there are no build.xcconfig in App_Resources and in plugins", async () => { + it("creates plugins-.xcconfig with only the compilation cache settings when there are no build.xcconfig in App_Resources and in plugins", async () => { await (iOSProjectService).mergeProjectXcconfigFiles(projectData); const destinationFilePaths = @@ -1403,10 +1410,49 @@ describeOnMacOS("Merge Project XCConfig files", () => { fs.exists(destinationFilePath), `Target build xcconfig ${destinationFilePath} is missing.`, ); - const content = fs.readFile(destinationFilePath).toString(); - assert.equal(content, ""); + assert.equal( + xcconfigService.readPropertyValue( + destinationFilePath, + "COMPILATION_CACHE_ENABLE_CACHING", + ), + "YES", + ); + assert.equal( + xcconfigService.readPropertyValue( + destinationFilePath, + "COMPILATION_CACHE_CAS_PATH", + ), + path.join( + projectData.platformsDir, + ".cache", + "ios", + "CompilationCache", + ), + ); }); }); + + it("lets the app's build.xcconfig turn compilation caching off", async () => { + fs.writeFile( + appResourcesXcconfigPath, + "COMPILATION_CACHE_ENABLE_CACHING = NO", + ); + + await (iOSProjectService).mergeProjectXcconfigFiles(projectData); + + _.each( + xcconfigService.getPluginsXcconfigFilePaths(projectRoot), + (destinationFilePath) => { + assert.equal( + xcconfigService.readPropertyValue( + destinationFilePath, + "COMPILATION_CACHE_ENABLE_CACHING", + ), + "NO", + ); + }, + ); + }); }); describe("handleNativeDependenciesChange", () => { @@ -1442,6 +1488,85 @@ describe("handleNativeDependenciesChange", () => { }); }); +describe("workspace DerivedData location", () => { + const projectName = "myTestProjectName"; + const settingsFile = (workspacePath: string) => + path.join( + workspacePath, + "xcuserdata", + `${userInfo().username}.xcuserdatad`, + "WorkspaceSettings.xcsettings", + ); + + function setup() { + const dir = mkdtempSync(path.join(tmpdir(), "derivedDataLocation-")); + const testInjector = createTestInjector(dir, projectName); + const iOSProjectService = testInjector.resolve("iOSProjectService"); + const projectData = testInjector.resolve("projectData"); + const platformData = iOSProjectService.getPlatformData(projectData); + const fs: IFileSystem = testInjector.resolve("fs"); + const xcodeprojPath = path.join( + platformData.projectRoot, + `${projectName}.xcodeproj`, + ); + fs.createDirectory(xcodeprojPath); + const writeSettings = () => + iOSProjectService.ensureWorkspaceDerivedDataLocation( + projectData, + platformData, + ); + return { fs, platformData, xcodeprojPath, writeSettings }; + } + + it("points the project and pods workspaces at the CLI's DerivedData folder", () => { + const { fs, platformData, xcodeprojPath, writeSettings } = setup(); + const podsWorkspacePath = path.join( + platformData.projectRoot, + `${projectName}.xcworkspace`, + ); + fs.createDirectory(podsWorkspacePath); + + writeSettings(); + + const projectWorkspacePath = path.join( + xcodeprojPath, + "project.xcworkspace", + ); + assert.isTrue( + fs.exists(path.join(projectWorkspacePath, "contents.xcworkspacedata")), + ); + for (const workspacePath of [projectWorkspacePath, podsWorkspacePath]) { + assert.deepStrictEqual( + plist.parse(fs.readText(settingsFile(workspacePath))), + { + DerivedDataLocationStyle: "WorkspaceRelativePath", + DerivedDataCustomLocation: path.join("build", "DerivedData"), + }, + ); + } + }); + + it("keeps other workspace settings", () => { + const { fs, xcodeprojPath, writeSettings } = setup(); + const file = settingsFile(path.join(xcodeprojPath, "project.xcworkspace")); + fs.writeFile( + file, + plist.build({ + DerivedDataLocationStyle: "Default", + ShowSharedSchemesAutomaticallyEnabled: true, + }), + ); + + writeSettings(); + + assert.deepStrictEqual(plist.parse(fs.readText(file)), { + DerivedDataLocationStyle: "WorkspaceRelativePath", + DerivedDataCustomLocation: path.join("build", "DerivedData"), + ShowSharedSchemesAutomaticallyEnabled: true, + }); + }); +}); + describe("SPM Packages", () => { it("should add SPM packages to the project", async () => { // todo: add tests for SPM packages diff --git a/test/services/ios/xcodebuild-args-service.ts b/test/services/ios/xcodebuild-args-service.ts index 6bffa9e9e0..e753d177a5 100644 --- a/test/services/ios/xcodebuild-args-service.ts +++ b/test/services/ios/xcodebuild-args-service.ts @@ -12,6 +12,7 @@ function createTestInjector(data: { hasProjectWorkspace: boolean; connectedDevices?: any[]; buildXcconfigContent?: string; + hostProjectPath?: string; }): IInjector { const injector = new Yok(); injector.register("devicePlatformsConstants", DevicePlatformsConstants); @@ -29,6 +30,7 @@ function createTestInjector(data: { injector.register("logger", { getLevel: () => data.logLevel, }); + injector.register("options", { hostProjectPath: data.hostProjectPath }); injector.register("xcodebuildArgsService", XcodebuildArgsService); injector.register("childProcess", {}); injector.register("xcconfigService", XcconfigService); @@ -45,20 +47,12 @@ const appResourcesDirectoryPath = "App_Resources"; const projectName = "myApp"; const buildOutputPath = path.join(projectRoot, projectName, "archive"); -function getCommonArgs() { - return [ - "BUILD_DIR=" + path.join(projectRoot, "build"), - "SHARED_PRECOMPS_DIR=" + path.join(projectRoot, "build", "sharedpch"), - ]; -} - function getXcodeProjectArgs(data?: { hasProjectWorkspace: boolean }) { const extraArgs = [ "-scheme", projectName, "-skipPackagePluginValidation", "-skipMacroValidation", - "SWIFT_ENABLE_EXPLICIT_MODULES=NO", ]; return data && data.hasProjectWorkspace ? [ @@ -112,6 +106,27 @@ describe("xcodebuildArgsService", () => { assert.notInclude(actualArgs, "SWIFT_ENABLE_EXPLICIT_MODULES=NO"); }); + it("leaves SWIFT_ENABLE_EXPLICIT_MODULES to Xcode when build.xcconfig doesn't set it", () => { + const injector = createTestInjector({ + logLevel: "INFO", + hasProjectWorkspace: false, + }); + const xcodebuildArgsService: IXcodebuildArgsService = injector.resolve( + "xcodebuildArgsService", + ); + + const actualArgs = xcodebuildArgsService.getXcodeProjectArgs( + { projectRoot, normalizedPlatformName }, + { projectName, appResourcesDirectoryPath }, + ); + + assert.isUndefined( + actualArgs.find((arg) => + arg.startsWith("SWIFT_ENABLE_EXPLICIT_MODULES="), + ), + ); + }); + it("should include DEVELOPMENT_TEAM from build.xcconfig", () => { const injector = createTestInjector({ logLevel: "INFO", @@ -168,6 +183,27 @@ describe("xcodebuildArgsService", () => { assert.notInclude(actualArgs, "-packageAuthorizationProvider"); }); + it("passes the DerivedData location only for host projects", () => { + const argsFor = (hostProjectPath?: string) => + createTestInjector({ + logLevel: "INFO", + hasProjectWorkspace: false, + hostProjectPath, + }) + .resolve("xcodebuildArgsService") + .getXcodeProjectArgs( + { projectRoot, normalizedPlatformName }, + { projectName, appResourcesDirectoryPath }, + ); + + assert.notInclude(argsFor(), "-derivedDataPath"); + const hostArgs = argsFor("path/to/host"); + assert.strictEqual( + hostArgs[hostArgs.indexOf("-derivedDataPath") + 1], + path.join(projectRoot, "build", "DerivedData", projectName), + ); + }); + it("keeps the project path as the second argument", () => { process.env.NS_PACKAGE_AUTHORIZATION_PROVIDER = "keychain"; const injector = createTestInjector({ @@ -228,10 +264,7 @@ describe("xcodebuildArgsService", () => { "build", "-configuration", configuration, - "-sdk", - platform === "visionOS" ? "xrsimulator" : "iphonesimulator", ] - .concat(getCommonArgs()) .concat(getBuildLoggingArgs(logLevel)) .concat(getXcodeProjectArgs({ hasProjectWorkspace })); @@ -242,6 +275,73 @@ describe("xcodebuildArgsService", () => { }); }); }); + describe("getBuildForSimulatorArgs with a target simulator", () => { + it("builds for that simulator only, like Xcode does", async () => { + const injector = createTestInjector({ + logLevel: "TRACE", + hasProjectWorkspace: false, + }); + const xcodebuildArgsService: IXcodebuildArgsService = injector.resolve( + "xcodebuildArgsService", + ); + + const actualArgs = await xcodebuildArgsService.getBuildForSimulatorArgs( + { projectRoot, normalizedPlatformName }, + { projectName, appResourcesDirectoryPath }, + { + buildForDevice: false, + platform: "iOS", + _device: { deviceInfo: { identifier: "SIM-UDID" } }, + }, + ); + + assert.deepStrictEqual(actualArgs.slice(0, 3), [ + "CODE_SIGN_IDENTITY=", + "-destination", + "platform=iOS Simulator,id=SIM-UDID", + ]); + assert.notInclude(actualArgs, "ONLY_ACTIVE_ARCH=NO"); + }); + }); + + describe("getBuildForDeviceArgs with a target device", () => { + it("builds for that device, like Xcode's Run", async () => { + let queriedDevices = false; + const injector = createTestInjector({ + logLevel: "TRACE", + hasProjectWorkspace: false, + }); + injector.resolve("devicesService").initialize = async () => { + queriedDevices = true; + }; + const xcodebuildArgsService: IXcodebuildArgsService = injector.resolve( + "xcodebuildArgsService", + ); + + const actualArgs = await xcodebuildArgsService.getBuildForDeviceArgs( + { + projectRoot, + normalizedPlatformName, + getBuildOutputPath: () => buildOutputPath, + }, + { projectName, appResourcesDirectoryPath }, + { + buildForDevice: true, + platform: "ios", + _device: { deviceInfo: { identifier: "DEVICE-UDID" } }, + }, + ); + + assert.deepStrictEqual(actualArgs.slice(0, 3), [ + "-destination", + "platform=iOS,id=DEVICE-UDID", + "build", + ]); + assert.notInclude(actualArgs, "ONLY_ACTIVE_ARCH=NO"); + assert.isFalse(queriedDevices); + }); + }); + describe("getBuildForDeviceArgs", () => { const testCases = [ { @@ -250,19 +350,17 @@ describe("xcodebuildArgsService", () => { { deviceInfo: { activeArchitecture: "arm64" } }, { deviceInfo: { activeArchitecture: "armv7" } }, ], - expectedArgs: ["ONLY_ACTIVE_ARCH=NO", "-sdk", "iphoneos"].concat( - getCommonArgs(), - ), + expectedArgs: ["ONLY_ACTIVE_ARCH=NO"], }, { name: "should return correct args when there is only one connected device", connectedDevices: [{ deviceInfo: { activeArchitecture: "arm64" } }], - expectedArgs: ["-sdk", "iphoneos"].concat(getCommonArgs()), + expectedArgs: [], }, { name: "should return correct args when no connected devices", connectedDevices: [], - expectedArgs: ["-sdk", "iphoneos"].concat(getCommonArgs()), + expectedArgs: [], }, ]; @@ -303,9 +401,13 @@ describe("xcodebuildArgsService", () => { const expectedArgs = [ "-destination", "generic/platform=iOS", - "archive", - "-archivePath", - path.join(buildOutputPath, `${projectName}.xcarchive`), + ...(configuration === "Release" + ? [ + "archive", + "-archivePath", + path.join(buildOutputPath, `${projectName}.xcarchive`), + ] + : ["build"]), "-configuration", configuration, "-allowProvisioningUpdates", diff --git a/test/services/ios/xcodebuild-service.ts b/test/services/ios/xcodebuild-service.ts index 611f402c77..543cfbe577 100644 --- a/test/services/ios/xcodebuild-service.ts +++ b/test/services/ios/xcodebuild-service.ts @@ -15,9 +15,21 @@ const exportOptionsPlistOutput = { }; let actualBuildArgs: string[] = []; let actualBuildOptions: IXcodebuildCommandOptions = null; +let spawnedCommands: string[][] = []; function createTestInjector(): IInjector { const injector = new Yok(); + spawnedCommands = []; + injector.register("childProcess", { + spawnFromEvent: async (command: string, args: string[]) => { + spawnedCommands.push([command, ...args]); + }, + }); + injector.register("fs", { + createDirectory: (): void => undefined, + deleteDirectory: (): void => undefined, + deleteFile: (): void => undefined, + }); injector.register("exportOptionsPlistService", { createDevelopmentExportOptionsPlist: async () => exportOptionsPlistOutput, createDistributionExportOptionsPlist: async () => exportOptionsPlistOutput, @@ -25,11 +37,13 @@ function createTestInjector(): IInjector { injector.register("xcodebuildArgsService", { getBuildForDeviceArgs: async () => [], getBuildForSimulatorArgs: async () => [], + shouldArchiveDeviceBuild: (buildConfig: any) => + !!(buildConfig.release || buildConfig.buildForAppStore), }); injector.register("xcodebuildCommandService", { executeCommand: async ( args: string[], - options: IXcodebuildCommandOptions + options: IXcodebuildCommandOptions, ) => { actualBuildArgs = args; actualBuildOptions = options; @@ -43,7 +57,48 @@ function createTestInjector(): IInjector { describe("xcodebuildService", () => { describe("buildForDevice", () => { - it("should build correctly for device", async () => { + it("packages the built .app as an .ipa for debug builds", async () => { + const injector = createTestInjector(); + const xcodebuildService = injector.resolve("xcodebuildService"); + const platformData = { + getBuildOutputPath: () => buildOutputPath, + projectRoot, + }; + const projectData = { projectName }; + + const buildResult = await xcodebuildService.buildForDevice( + platformData, + projectData, + {}, + ); + + const payloadPath = path.join(buildOutputPath, "ipa-staging", "Payload"); + assert.deepStrictEqual(spawnedCommands, [ + [ + "cp", + "-cR", + path.join(buildOutputPath, `${projectName}.app`), + payloadPath, + ], + [ + "ditto", + "-c", + "-k", + "--norsrc", + "--zlibCompressionLevel", + "0", + "--keepParent", + payloadPath, + path.join(buildOutputPath, `${projectName}.ipa`), + ], + ]); + assert.deepStrictEqual( + buildResult, + path.join(buildOutputPath, `${projectName}.ipa`), + ); + }); + + it("exports an archive for release builds", async () => { const injector = createTestInjector(); const xcodebuildService = injector.resolve("xcodebuildService"); const platformData = { @@ -55,7 +110,7 @@ describe("xcodebuildService", () => { const buildResult = await xcodebuildService.buildForDevice( platformData, projectData, - {} + { release: true }, ); const expectedBuildArgs = [ @@ -63,7 +118,7 @@ describe("xcodebuildService", () => { "-archivePath", path.join( platformData.getBuildOutputPath(), - `${projectName}.xcarchive` + `${projectName}.xcarchive`, ), "-exportPath", exportOptionsPlistOutput.exportFileDir, @@ -77,7 +132,7 @@ describe("xcodebuildService", () => { }); assert.deepStrictEqual( buildResult, - exportOptionsPlistOutput.exportFilePath + exportOptionsPlistOutput.exportFilePath, ); }); }); @@ -113,7 +168,7 @@ describe("xcodebuildService", () => { const buildResult = await xcodebuildService.buildForAppStore( platformData, projectData, - {} + {}, ); const expectedBuildArgs = [ @@ -121,7 +176,7 @@ describe("xcodebuildService", () => { "-archivePath", path.join( platformData.getBuildOutputPath(), - `${projectName}.xcarchive` + `${projectName}.xcarchive`, ), "-exportPath", exportOptionsPlistOutput.exportFileDir, @@ -133,7 +188,7 @@ describe("xcodebuildService", () => { assert.deepStrictEqual(actualBuildOptions, { cwd: projectRoot }); assert.deepStrictEqual( buildResult, - exportOptionsPlistOutput.exportFilePath + exportOptionsPlistOutput.exportFilePath, ); }); }); From de9c0d38034900138c5e4b0e86c13aaa193dfc5e Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 20:24:39 +0100 Subject: [PATCH 08/11] fix(ios): don't end device discovery on the wrong device With 100 ms polling, an unrelated device ended the wait almost immediately. A requested device now only ends it by being found, and otherwise the 2 seconds the old polling allowed still apply, so device indexes stay stable. --- .../ios/device/ios-device-operations.ts | 27 +++++++++---- .../mobile/ios-device-operations.ts | 39 +++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 lib/common/test/unit-tests/mobile/ios-device-operations.ts diff --git a/lib/common/mobile/ios/device/ios-device-operations.ts b/lib/common/mobile/ios/device/ios-device-operations.ts index c562b29cf8..bffc982e23 100644 --- a/lib/common/mobile/ios/device/ios-device-operations.ts +++ b/lib/common/mobile/ios/device/ios-device-operations.ts @@ -11,6 +11,22 @@ import { } from "../../../declarations"; import { injector } from "../../../yok"; +// Other devices can still be arriving by then, and device indexes follow the +// order they were discovered in. +const MIN_DISCOVERY_WAIT_MS = 2000; + +export function isDeviceDiscoveryDone( + foundDevice: boolean, + waitedMs: number, + options?: Mobile.IDeviceLookingOptions, +): boolean { + if (options?.isRequestedDeviceFound) { + return options.isRequestedDeviceFound(); + } + + return foundDevice && !options?.fullDiscovery && waitedMs >= MIN_DISCOVERY_WAIT_MS; +} + export class IOSDeviceOperations extends EventEmitter implements IIOSDeviceOperations, IDisposable, IShouldDispose @@ -91,16 +107,11 @@ export class IOSDeviceOperations let waited = 0; const intervalHandle: NodeJS.Timer = setInterval(() => { + waited += pollInterval; if ( - (foundDevice && !options.fullDiscovery) || - options.isRequestedDeviceFound?.() + waited >= maxWait || + isDeviceDiscoveryDone(foundDevice, waited, options) ) { - resolve(); - return clearInterval(intervalHandle as unknown as number); - } - - waited += pollInterval; - if (waited >= maxWait) { clearInterval(intervalHandle as unknown as number); return resolve(); } diff --git a/lib/common/test/unit-tests/mobile/ios-device-operations.ts b/lib/common/test/unit-tests/mobile/ios-device-operations.ts new file mode 100644 index 0000000000..1e2482d92b --- /dev/null +++ b/lib/common/test/unit-tests/mobile/ios-device-operations.ts @@ -0,0 +1,39 @@ +import { assert } from "chai"; +import { isDeviceDiscoveryDone } from "../../../mobile/ios/device/ios-device-operations"; + +describe("isDeviceDiscoveryDone", () => { + const options = (extra: object = {}) => { ...extra }; + + it("ends as soon as the requested device is found", () => { + const isRequestedDeviceFound = () => true; + assert.isTrue( + isDeviceDiscoveryDone(false, 100, options({ isRequestedDeviceFound })), + ); + }); + + it("keeps waiting for the requested device when another one shows up", () => { + const isRequestedDeviceFound = () => false; + assert.isFalse( + isDeviceDiscoveryDone(true, 5000, options({ isRequestedDeviceFound })), + ); + }); + + it("gives other devices time to arrive when none was requested", () => { + assert.isFalse(isDeviceDiscoveryDone(true, 100, options())); + assert.isTrue(isDeviceDiscoveryDone(true, 2000, options())); + }); + + it("keeps waiting while no device was found", () => { + assert.isFalse(isDeviceDiscoveryDone(false, 5000, options())); + }); + + it("doesn't end early on a full discovery", () => { + assert.isFalse( + isDeviceDiscoveryDone(true, 5000, options({ fullDiscovery: true })), + ); + }); + + it("works without options", () => { + assert.isTrue(isDeviceDiscoveryDone(true, 2000)); + }); +}); From 8783785a678effd1cf3b89ef95f59a4d6bfc7f83 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 17:23:48 +0100 Subject: [PATCH 09/11] feat(ios): structured app logs that follow relaunches Devices stream com.apple.os_trace_relay and simulators use log stream, both selected by executable name, so logs survive closing and reopening the app. --log picks the scope: JS output by default, the app's native logs without debug entries with debug, everything with trace or all. Without a new enough ios-device-lib, devices fall back to the syslog relay. Debug builds ask the runtime for chunk markers through logChunkMarkers in the app's package.json, and the CLI stitches split messages back together per process and thread. --- docs/man_pages/project/testing/run-ios.md | 1 + lib/common/constants.ts | 7 +- lib/common/definitions/mobile.d.ts | 45 ++++ lib/common/mobile/device-log-emitter.ts | 52 +++- lib/common/mobile/device-log-provider-base.ts | 61 +++-- lib/common/mobile/device-log-provider.ts | 36 ++- .../ios/device/ios-device-operations.ts | 37 ++- lib/common/mobile/ios/device/ios-device.ts | 103 ++++++-- lib/common/mobile/ios/ios-log-entry.ts | 137 +++++++++++ lib/common/mobile/ios/ios-log-filter.ts | 28 --- .../simulator/ios-simulator-log-provider.ts | 105 ++++++-- lib/common/test/unit-tests/ios-log-filter.ts | 224 ------------------ .../unit-tests/mobile/device-log-provider.ts | 23 ++ .../test/unit-tests/mobile/ios-log-entry.ts | 200 ++++++++++++++++ lib/common/test/unit-tests/stubs.ts | 12 + lib/controllers/prepare-controller.ts | 11 +- lib/services/ios-log-filter.ts | 18 +- test/controllers/prepare-controller.ts | 36 +++ test/services/ios-log-filter.ts | 62 ++++- 19 files changed, 876 insertions(+), 322 deletions(-) create mode 100644 lib/common/mobile/ios/ios-log-entry.ts delete mode 100644 lib/common/mobile/ios/ios-log-filter.ts delete mode 100644 lib/common/test/unit-tests/ios-log-filter.ts create mode 100644 lib/common/test/unit-tests/mobile/ios-log-entry.ts diff --git a/docs/man_pages/project/testing/run-ios.md b/docs/man_pages/project/testing/run-ios.md index 3e8d23de6f..43259fe40f 100644 --- a/docs/man_pages/project/testing/run-ios.md +++ b/docs/man_pages/project/testing/run-ios.md @@ -33,6 +33,7 @@ Start an emulator with specified device identifier and sdk | `$ ns run ios [--de * `--emulator` - If set, runs the app in all available and configured ios simulators. It will start a simulator if none are already running. * `--sdk` - Specifies the target simulator's sdk. * `--justlaunch` - If set, does not print the application output in the console. +* `--log ` - Also controls how much of the application output is printed. By default only its JavaScript output is shown. `debug` adds everything else the app logs - its native code and the system frameworks it uses, like Xcode's console - and `trace` adds debug level entries as well. * `--clean` - If set, forces the complete rebuild of the native application. * `--no-watch` - If set, changes in your code will not be reflected during the execution of this command. * `--release` - If set, produces a release build by running webpack in production mode and native build in release mode. Otherwise, produces a debug build. diff --git a/lib/common/constants.ts b/lib/common/constants.ts index 447efc7ca0..bfa8170818 100644 --- a/lib/common/constants.ts +++ b/lib/common/constants.ts @@ -62,9 +62,12 @@ export class EmulatorDiscoveryNames { } export const DEVICE_LOG_EVENT_NAME = "deviceLogData"; +export const DEVICE_LOG_ENTRY_EVENT_NAME = "deviceLogEntry"; export const IOS_LOG_PREDICATE = 'senderImagePath contains "NativeScript"'; -export const IOS_APP_CRASH_LOG_REG_EXP = /Fatal JavaScript exception \- application has been terminated/; -export const FAIL_LIVESYNC_LOG_REGEX = /Failed to refresh the application with RefreshRequest./; +export const IOS_APP_CRASH_LOG_REG_EXP = + /Fatal JavaScript exception \- application has been terminated/; +export const FAIL_LIVESYNC_LOG_REGEX = + /Failed to refresh the application with RefreshRequest./; export const TARGET_FRAMEWORK_IDENTIFIERS = { Cordova: "Cordova", diff --git a/lib/common/definitions/mobile.d.ts b/lib/common/definitions/mobile.d.ts index 4c27929b2b..32a4bb7f4c 100644 --- a/lib/common/definitions/mobile.d.ts +++ b/lib/common/definitions/mobile.d.ts @@ -154,6 +154,27 @@ declare global { /** * Describes log stream options */ + /** + * A structured unified logging (os_log) entry. + */ + interface IiOSLogEntry { + /** + * Together with the thread, tells apart messages logged at the same time. + */ + pid?: number; + thread?: number; + /** + * Path of the executable of the process that logged the entry. + */ + process: string; + /** + * Path of the binary (executable, framework or library) that logged the entry. + */ + image: string; + level: string; + message: string; + } + interface IiOSLogStreamOptions { /** * This is the --predicate option which will be passed to `log stream` command @@ -213,6 +234,21 @@ declare global { */ logData(line: string, platform: string, deviceIdentifier: string): void; + /** + * Logs a structured iOS log entry, when it belongs to the application + * whose logs are shown on the device. + */ + logEntry( + entry: IiOSLogEntry, + platform: string, + deviceIdentifier: string, + ): void; + + /** + * Returns the project name set for the device, if any. + */ + getProjectNameForDevice(deviceIdentifier: string): string; + /** * Sets the level of logging that will be used. * @param {string} level The level of logs - could be INFO or FULL. @@ -1331,6 +1367,15 @@ declare global { startDeviceLog(deviceIdentifier: string): void; + /** + * Streams structured logs of the process with the given executable name. + * Returns false when the installed ios-device-lib cannot stream them. + */ + startDeviceLogStream( + deviceIdentifier: string, + processName: string, + ): boolean; + apps( deviceIdentifiers: string[], errorHandler?: DeviceOperationErrorHandler, diff --git a/lib/common/mobile/device-log-emitter.ts b/lib/common/mobile/device-log-emitter.ts index dbb59149a5..c254034b61 100644 --- a/lib/common/mobile/device-log-emitter.ts +++ b/lib/common/mobile/device-log-emitter.ts @@ -2,13 +2,14 @@ import { DeviceLogProviderBase } from "./device-log-provider-base"; import { DEVICE_LOG_EVENT_NAME } from "../constants"; import { injector } from "../yok"; import * as _ from "lodash"; +import { getIOSLogEntryText, getIOSLogScope } from "./ios/ios-log-entry"; export class DeviceLogEmitter extends DeviceLogProviderBase { constructor( protected $logFilter: Mobile.ILogFilter, $logger: ILogger, private $loggingLevels: Mobile.ILoggingLevels, - protected $logSourceMapService: Mobile.ILogSourceMapService + protected $logSourceMapService: Mobile.ILogSourceMapService, ) { super($logFilter, $logger, $logSourceMapService); } @@ -16,20 +17,56 @@ export class DeviceLogEmitter extends DeviceLogProviderBase { public logData( line: string, platform: string, - deviceIdentifier: string + deviceIdentifier: string, ): void { this.setDefaultLogLevelForDevice(deviceIdentifier); const loggingOptions = this.getDeviceLogOptionsForDevice( - deviceIdentifier + deviceIdentifier, ) || { logLevel: this.$loggingLevels.info, projectDir: null }; let data = this.$logFilter.filterData(platform, line, loggingOptions); data = this.$logSourceMapService.replaceWithOriginalFileLocations( platform, data, - loggingOptions + loggingOptions, ); + this.emitData(data, platform, deviceIdentifier); + } + + public logEntry( + entry: Mobile.IiOSLogEntry, + platform: string, + deviceIdentifier: string, + ): void { + const loggingOptions = this.getDeviceLogOptionsForDevice(deviceIdentifier); + const text = getIOSLogEntryText(entry, { + processName: loggingOptions && loggingOptions.projectName, + ...getIOSLogScope( + this.$logger.getLevel(), + loggingOptions?.logLevel?.toUpperCase() === this.$loggingLevels.full, + ), + }); + if (text) { + this.stitchIOSLogEntry(entry, text, deviceIdentifier, (message) => + this.emitData( + this.$logSourceMapService.replaceWithOriginalFileLocations( + platform, + message + "\n", + loggingOptions, + ), + platform, + deviceIdentifier, + ), + ); + } + } + + private emitData( + data: string, + platform: string, + deviceIdentifier: string, + ): void { if (data) { this.emit("data", deviceIdentifier, data); this.emit(DEVICE_LOG_EVENT_NAME, data, deviceIdentifier, platform); @@ -42,7 +79,7 @@ export class DeviceLogEmitter extends DeviceLogProviderBase { deviceIdentifier, (deviceLogOptions: Mobile.IDeviceLogOptions) => deviceLogOptions.logLevel, - logLevel.toUpperCase() + logLevel.toUpperCase(), ); } else { this.$logFilter.loggingLevel = logLevel.toUpperCase(); @@ -50,9 +87,8 @@ export class DeviceLogEmitter extends DeviceLogProviderBase { _.keys(this.devicesLogOptions).forEach((deviceId) => { this.devicesLogOptions[deviceId] = this.devicesLogOptions[deviceId] || {}; - this.devicesLogOptions[ - deviceId - ].logLevel = this.$logFilter.loggingLevel; + this.devicesLogOptions[deviceId].logLevel = + this.$logFilter.loggingLevel; }); } } diff --git a/lib/common/mobile/device-log-provider-base.ts b/lib/common/mobile/device-log-provider-base.ts index 1329cde6da..904e8a6531 100644 --- a/lib/common/mobile/device-log-provider-base.ts +++ b/lib/common/mobile/device-log-provider-base.ts @@ -1,27 +1,29 @@ import { EventEmitter } from "events"; import { getPropertyName } from "../helpers"; import { IDictionary } from "../declarations"; +import { IOSLogChunkStitcher, isIOSRuntimeLogEntry } from "./ios/ios-log-entry"; export abstract class DeviceLogProviderBase extends EventEmitter implements Mobile.IDeviceLogProvider { protected devicesLogOptions: IDictionary = {}; + private iOSLogChunkStitcher = new IOSLogChunkStitcher(); constructor( protected $logFilter: Mobile.ILogFilter, protected $logger: ILogger, - protected $logSourceMapService: Mobile.ILogSourceMapService + protected $logSourceMapService: Mobile.ILogSourceMapService, ) { super(); } public async setSourceFileLocation( - pathToOriginalFile: string + pathToOriginalFile: string, ): Promise { try { await this.$logSourceMapService.setSourceMapConsumerForFile( - pathToOriginalFile + pathToOriginalFile, ); } catch (err) { this.$logger.trace("Error while trying to set source map file", err); @@ -31,23 +33,47 @@ export abstract class DeviceLogProviderBase public abstract logData( lineText: string, platform: string, - deviceIdentifier: string + deviceIdentifier: string, + ): void; + + public abstract logEntry( + entry: Mobile.IiOSLogEntry, + platform: string, + deviceIdentifier: string, ): void; public abstract setLogLevel( logLevel: string, - deviceIdentifier?: string + deviceIdentifier?: string, ): void; + protected stitchIOSLogEntry( + entry: Mobile.IiOSLogEntry, + text: string, + deviceIdentifier: string, + emit: (text: string) => void, + ): void { + if (!isIOSRuntimeLogEntry(entry)) { + emit(text); + return; + } + + this.iOSLogChunkStitcher.add( + `${deviceIdentifier}:${entry.pid}:${entry.thread}`, + text, + emit, + ); + } + public setApplicationPidForDevice( deviceIdentifier: string, - pid: string + pid: string, ): void { this.setDeviceLogOptionsProperty( deviceIdentifier, (deviceLogOptions: Mobile.IDeviceLogOptions) => deviceLogOptions.applicationPid, - pid + pid, ); } @@ -56,31 +82,31 @@ export abstract class DeviceLogProviderBase deviceIdentifier, (deviceLogOptions: Mobile.IDeviceLogOptions) => deviceLogOptions.applicationId, - appId + appId, ); } public setProjectNameForDevice( deviceIdentifier: string, - projectName: string + projectName: string, ): void { this.setDeviceLogOptionsProperty( deviceIdentifier, (deviceLogOptions: Mobile.IDeviceLogOptions) => deviceLogOptions.projectName, - projectName + projectName, ); } public setProjectDirForDevice( deviceIdentifier: string, - projectDir: string + projectDir: string, ): void { this.setDeviceLogOptionsProperty( deviceIdentifier, (deviceLogOptions: Mobile.IDeviceLogOptions) => deviceLogOptions.projectDir, - projectDir + projectDir, ); } @@ -99,6 +125,13 @@ export abstract class DeviceLogProviderBase ); } + public getProjectNameForDevice(deviceIdentifier: string): string { + return ( + this.devicesLogOptions[deviceIdentifier] && + this.devicesLogOptions[deviceIdentifier].projectName + ); + } + getApplicationIdForDevice(deviceIdentifier: string) { return ( this.devicesLogOptions[deviceIdentifier] && @@ -107,7 +140,7 @@ export abstract class DeviceLogProviderBase } protected getDeviceLogOptionsForDevice( - deviceIdentifier: string + deviceIdentifier: string, ): Mobile.IDeviceLogOptions { const loggingOptions = this.devicesLogOptions[deviceIdentifier]; if (!loggingOptions) { @@ -120,7 +153,7 @@ export abstract class DeviceLogProviderBase protected setDeviceLogOptionsProperty( deviceIdentifier: string, propNameFunction: Function, - propertyValue: string | boolean + propertyValue: string | boolean, ): void { const propertyName = getPropertyName(propNameFunction); diff --git a/lib/common/mobile/device-log-provider.ts b/lib/common/mobile/device-log-provider.ts index d5c548f780..0833399c25 100644 --- a/lib/common/mobile/device-log-provider.ts +++ b/lib/common/mobile/device-log-provider.ts @@ -7,6 +7,7 @@ import { IOptions } from "../../declarations"; import { color, StyleFormat } from "../../color"; import { ITimelineProfilerService } from "../../services/timeline-profiler-service"; +import { getIOSLogEntryText, getIOSLogScope } from "./ios/ios-log-entry"; export class DeviceLogProvider extends DeviceLogProviderBase { constructor( @@ -15,6 +16,7 @@ export class DeviceLogProvider extends DeviceLogProviderBase { protected $logSourceMapService: Mobile.ILogSourceMapService, protected $timelineProfilerService: ITimelineProfilerService, protected $options: IOptions, + private $loggingLevels: Mobile.ILoggingLevels, ) { super($logFilter, $logger, $logSourceMapService); } @@ -24,13 +26,41 @@ export class DeviceLogProvider extends DeviceLogProviderBase { platform: string, deviceIdentifier: string, ): void { - // console.log(lineText) const loggingOptions = this.getDeviceLogOptionsForDevice(deviceIdentifier); - let data = this.$logFilter.filterData(platform, lineText, loggingOptions); + const data = this.$logFilter.filterData(platform, lineText, loggingOptions); + this.printData(data, lineText, platform, deviceIdentifier); + } + + public logEntry( + entry: Mobile.IiOSLogEntry, + platform: string, + deviceIdentifier: string, + ): void { + const loggingOptions = this.getDeviceLogOptionsForDevice(deviceIdentifier); + const text = getIOSLogEntryText(entry, { + processName: loggingOptions.projectName, + ...getIOSLogScope( + this.$logger.getLevel(), + loggingOptions.logLevel?.toUpperCase() === this.$loggingLevels.full, + ), + }); + if (text) { + this.stitchIOSLogEntry(entry, text, deviceIdentifier, (message) => + this.printData(message + "\n", message, platform, deviceIdentifier), + ); + } + } + + private printData( + data: string, + lineText: string, + platform: string, + deviceIdentifier: string, + ): void { data = this.$logSourceMapService.replaceWithOriginalFileLocations( platform, data, - loggingOptions, + this.getDeviceLogOptionsForDevice(deviceIdentifier), ); if (data) { diff --git a/lib/common/mobile/ios/device/ios-device-operations.ts b/lib/common/mobile/ios/device/ios-device-operations.ts index bffc982e23..b0e2a8a469 100644 --- a/lib/common/mobile/ios/device/ios-device-operations.ts +++ b/lib/common/mobile/ios/device/ios-device-operations.ts @@ -1,6 +1,9 @@ import { IOSDeviceLib as IOSDeviceLibModule } from "ios-device-lib"; import { cache } from "../../../decorators"; -import { DEVICE_LOG_EVENT_NAME } from "../../../constants"; +import { + DEVICE_LOG_ENTRY_EVENT_NAME, + DEVICE_LOG_EVENT_NAME, +} from "../../../constants"; import * as _ from "lodash"; import * as assert from "assert"; import { EventEmitter } from "events"; @@ -133,6 +136,28 @@ export class IOSDeviceOperations this.deviceLib.startDeviceLog([deviceIdentifier]); } + public startDeviceLogStream( + deviceIdentifier: string, + processName: string, + ): boolean { + this.assertIsInitialized(); + if (typeof this.deviceLib.startDeviceLogStream !== "function") { + return false; + } + + this.setShouldDispose(false); + this.$logger.trace( + `Streaming structured logs of ${processName} on device with identifier: ${deviceIdentifier}.`, + ); + + this.attachDeviceLogEntryHandler(); + + this.deviceLib.startDeviceLogStream([ + { deviceId: deviceIdentifier, processName }, + ]); + return true; + } + public async apps( deviceIdentifiers: string[], errorHandler?: DeviceOperationErrorHandler, @@ -380,6 +405,16 @@ export class IOSDeviceOperations assert.ok(this.isInitialized, "iOS device operations not initialized."); } + @cache() + private attachDeviceLogEntryHandler(): void { + this.deviceLib.on( + DEVICE_LOG_ENTRY_EVENT_NAME, + (response: IOSDeviceLib.IDeviceLogEntryData) => { + this.emit(DEVICE_LOG_ENTRY_EVENT_NAME, response); + }, + ); + } + @cache() private attacheDeviceLogDataHandler(): void { this.deviceLib.on( diff --git a/lib/common/mobile/ios/device/ios-device.ts b/lib/common/mobile/ios/device/ios-device.ts index 90dd12e7bc..7aef9a2df4 100644 --- a/lib/common/mobile/ios/device/ios-device.ts +++ b/lib/common/mobile/ios/device/ios-device.ts @@ -17,6 +17,7 @@ export class IOSDevice extends IOSDeviceBase { public fileSystem: Mobile.IDeviceFileSystem; public deviceInfo: Mobile.IDeviceInfo; private _deviceLogHandler: (...args: any[]) => void; + private _deviceLogEntryHandler: (...args: any[]) => void; constructor( private deviceActionInfo: IOSDeviceLib.IDeviceActionInfo, @@ -30,16 +31,16 @@ export class IOSDevice extends IOSDeviceBase { private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, private $iOSDeviceProductNameMapper: Mobile.IiOSDeviceProductNameMapper, private $iosDeviceOperations: IIOSDeviceOperations, - private $mobileHelper: Mobile.IMobileHelper + private $mobileHelper: Mobile.IMobileHelper, ) { super(); this.applicationManager = this.$injector.resolve( applicationManagerPath.IOSApplicationManager, - { device: this, devicePointer: this.deviceActionInfo } + { device: this, devicePointer: this.deviceActionInfo }, ); this.fileSystem = this.$injector.resolve( fileSystemPath.IOSDeviceFileSystem, - { device: this, devicePointer: this.deviceActionInfo } + { device: this, devicePointer: this.deviceActionInfo }, ); const productType = deviceActionInfo.productType; const isTablet = this.$mobileHelper.isiOSTablet(productType); @@ -58,7 +59,7 @@ export class IOSDevice extends IOSDeviceBase { isTablet: isTablet, displayName: this.$iOSDeviceProductNameMapper.resolveProductName( - deviceActionInfo.deviceName + deviceActionInfo.deviceName, ) || deviceActionInfo.deviceName, model: this.$iOSDeviceProductNameMapper.resolveProductName(productType), version: deviceActionInfo.productVersion, @@ -81,28 +82,63 @@ export class IOSDevice extends IOSDeviceBase { public get isOnlyWiFiConnected(): boolean { const result = this.deviceInfo.connectionTypes.every( - (connectionType) => connectionType === constants.DeviceConnectionType.Wifi + (connectionType) => + connectionType === constants.DeviceConnectionType.Wifi, ); return result; } @cache() public async openDeviceLogStream(): Promise { - if (this.deviceInfo.status !== commonConstants.UNREACHABLE_STATUS) { - this._deviceLogHandler = this.actionOnDeviceLog.bind(this); - this.$iosDeviceOperations.on( - commonConstants.DEVICE_LOG_EVENT_NAME, - this._deviceLogHandler - ); - this.$iosDeviceOperations.startDeviceLog(this.deviceInfo.identifier); + if (this.deviceInfo.status === commonConstants.UNREACHABLE_STATUS) { + return; + } + + // Structured logs need the app's executable name to select its entries; + // without one (e.g. `ns device log`) the whole device log is shown. + const processName = this.$deviceLogProvider.getProjectNameForDevice( + this.deviceInfo.identifier, + ); + if (processName && this.startStructuredDeviceLog(processName)) { + return; + } + + this.startSyslogDeviceLog(); + } + + private startStructuredDeviceLog(processName: string): boolean { + this._deviceLogEntryHandler = this.actionOnDeviceLogEntry.bind(this); + this.$iosDeviceOperations.on( + commonConstants.DEVICE_LOG_ENTRY_EVENT_NAME, + this._deviceLogEntryHandler, + ); + if ( + this.$iosDeviceOperations.startDeviceLogStream( + this.deviceInfo.identifier, + processName, + ) + ) { + return true; } + + this.removeDeviceLogEntryHandler(); + return false; + } + + private startSyslogDeviceLog(): void { + this._deviceLogHandler = this.actionOnDeviceLog.bind(this); + this.$iosDeviceOperations.on( + commonConstants.DEVICE_LOG_EVENT_NAME, + this._deviceLogHandler, + ); + this.$iosDeviceOperations.startDeviceLog(this.deviceInfo.identifier); } protected async getDebugSocketCore(appId: string): Promise { await this.$iOSSocketRequestExecutor.executeAttachRequest( this, constants.AWAIT_NOTIFICATION_TIMEOUT_SECONDS, - appId + appId, ); const port = await super.getDebuggerPort(appId); const deviceId = this.deviceInfo.identifier; @@ -113,7 +149,7 @@ export class IOSDevice extends IOSDeviceBase { await this.$iosDeviceOperations.connectToPort([ { deviceId: deviceId, port: port }, ]) - )[deviceId] + )[deviceId], ); const _socket = new net.Socket(); _socket.connect(deviceResponse.port, deviceResponse.host); @@ -128,8 +164,42 @@ export class IOSDevice extends IOSDeviceBase { this.$deviceLogProvider.logData( response.message, this.$devicePlatformsConstants.iOS, - this.deviceInfo.identifier + this.deviceInfo.identifier, + ); + } + } + + private actionOnDeviceLogEntry( + response: IOSDeviceLib.IDeviceLogEntryData, + ): void { + if (response.deviceId !== this.deviceInfo.identifier) { + return; + } + + if (response.error) { + this.$logger.trace( + `Structured device logs are not available for ${this.deviceInfo.identifier}, falling back to the syslog relay.`, + response.error, + ); + this.removeDeviceLogEntryHandler(); + this.startSyslogDeviceLog(); + return; + } + + this.$deviceLogProvider.logEntry( + response.entry, + this.$devicePlatformsConstants.iOS, + this.deviceInfo.identifier, + ); + } + + private removeDeviceLogEntryHandler(): void { + if (this._deviceLogEntryHandler) { + this.$iosDeviceOperations.removeListener( + commonConstants.DEVICE_LOG_ENTRY_EVENT_NAME, + this._deviceLogEntryHandler, ); + this._deviceLogEntryHandler = null; } } @@ -137,9 +207,10 @@ export class IOSDevice extends IOSDeviceBase { if (this._deviceLogHandler) { this.$iosDeviceOperations.removeListener( commonConstants.DEVICE_LOG_EVENT_NAME, - this._deviceLogHandler + this._deviceLogHandler, ); } + this.removeDeviceLogEntryHandler(); } private getActiveArchitecture(productType: string): string { diff --git a/lib/common/mobile/ios/ios-log-entry.ts b/lib/common/mobile/ios/ios-log-entry.ts new file mode 100644 index 0000000000..0d1121fe52 --- /dev/null +++ b/lib/common/mobile/ios/ios-log-entry.ts @@ -0,0 +1,137 @@ +import * as path from "path"; + +export interface IIOSLogEntryFilterOptions { + /** + * Name of the application's executable. Entries are matched by name rather + * than pid, so the logs follow the app when it is relaunched. + */ + processName: string; + /** + * Include everything the app's process logs - its own native code and the + * system frameworks it uses - like Xcode's console does. + */ + includeNative?: boolean; + /** + * Include debug level entries as well. + */ + includeDebug?: boolean; +} + +/** + * Which of the app's logs to show, from the CLI's --log level: JS output by + * default, everything but debug entries (like Xcode) with "debug", and + * everything with "trace" or "all". A full device log level shows everything. + */ +export function getIOSLogScope( + cliLogLevel: string, + fullDeviceLog: boolean, +): Pick { + const level = (cliLogLevel || "").toUpperCase(); + const includeDebug = fullDeviceLog || level === "TRACE" || level === "ALL"; + return { + includeNative: includeDebug || level === "DEBUG", + includeDebug, + }; +} + +export function isIOSRuntimeLogEntry(entry: Mobile.IiOSLogEntry): boolean { + return path.basename(entry.image || "") === "NativeScript"; +} + +// Logged by the runtime itself on every launch, not by the app. +const RUNTIME_NOISE = ["NativeScript loaded bundle"]; + +/** + * Returns the text to print for a structured iOS log entry, or null when the + * entry should not be shown. + * + * JS console output and runtime errors are the entries logged by the + * NativeScript framework; everything else in the app's process is native and + * printed prefixed with the binary that logged it. + */ +export function getIOSLogEntryText( + entry: Mobile.IiOSLogEntry, + options: IIOSLogEntryFilterOptions, +): string | null { + if (!entry || path.basename(entry.process || "") !== options.processName) { + return null; + } + + const imageName = path.basename(entry.image || ""); + if (isIOSRuntimeLogEntry(entry)) { + return RUNTIME_NOISE.some((noise) => entry.message.includes(noise)) + ? null + : entry.message; + } + + if ( + !options.includeNative || + (entry.level === "debug" && !options.includeDebug) + ) { + return null; + } + + return `${imageName}: ${entry.message}`; +} + +// The runtime ends a chunk of a split message with one of these when the CLI +// asks for it, through the logChunkMarkers key of the app's package.json. +const CHUNK_JOIN_MARKER = "\u2060"; +const CHUNK_LINE_MARKER = "\u2061"; +const CONSOLE_PREFIX = /^CONSOLE [A-Z]+: /; + +// Long enough to ride out a backlog of chunks, while the rest of a message that +// never arrives, because the app crashed, still gets printed. +const CHUNK_WAIT_MS = 500; + +interface IPendingLogMessage { + text: string; + prefix: string; + separator: string; + timer: NodeJS.Timeout; +} + +/** + * Reassembles messages the runtime split to get them through os_log. + */ +export class IOSLogChunkStitcher { + private pending = new Map(); + + /** + * @param key Identifies the logging thread: chunks from different threads can interleave. + * @param emit Called with the complete message. + */ + public add(key: string, text: string, emit: (text: string) => void): void { + const marker = text[text.length - 1]; + const continues = + marker === CHUNK_JOIN_MARKER || marker === CHUNK_LINE_MARKER; + const chunk = continues ? text.slice(0, -1) : text; + + const message = this.pending.get(key) ?? { + text: "", + prefix: chunk.match(CONSOLE_PREFIX)?.[0] ?? "", + separator: "", + timer: null, + }; + clearTimeout(message.timer); + message.text += + message.separator + + (message.text && chunk.startsWith(message.prefix) + ? chunk.slice(message.prefix.length) + : chunk); + + if (!continues) { + this.pending.delete(key); + emit(message.text); + return; + } + + message.separator = marker === CHUNK_LINE_MARKER ? "\n" : ""; + message.timer = setTimeout(() => { + this.pending.delete(key); + emit(message.text); + }, CHUNK_WAIT_MS); + message.timer.unref(); + this.pending.set(key, message); + } +} diff --git a/lib/common/mobile/ios/ios-log-filter.ts b/lib/common/mobile/ios/ios-log-filter.ts deleted file mode 100644 index e5f1188659..0000000000 --- a/lib/common/mobile/ios/ios-log-filter.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { injector } from "../../yok"; - -export class IOSLogFilter implements Mobile.IPlatformLogFilter { - protected infoFilterRegex = /^.*?(AppBuilder|Cordova|NativeScript).*?(:.*?|:.*?|:.*?)$/im; - - constructor(private $loggingLevels: Mobile.ILoggingLevels) {} - - public filterData( - data: string, - loggingOptions: Mobile.IDeviceLogOptions = {} - ): string { - const specifiedLogLevel = (loggingOptions.logLevel || "").toUpperCase(); - const pid = loggingOptions && loggingOptions.applicationPid; - - if (specifiedLogLevel === this.$loggingLevels.info && data) { - if (pid) { - return data.indexOf(`[${pid}]`) !== -1 ? data.trim() : null; - } - - const matchingInfoMessage = data.match(this.infoFilterRegex); - return matchingInfoMessage ? matchingInfoMessage[2] : null; - } - - return data; - } -} - -injector.register("iOSLogFilter", IOSLogFilter); diff --git a/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts b/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts index a13dd2055e..e560adf789 100644 --- a/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts +++ b/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts @@ -1,4 +1,5 @@ import { ChildProcess } from "child_process"; +import * as readline from "readline"; import { EventEmitter } from "events"; import * as _ from "lodash"; import { @@ -7,10 +8,13 @@ import { IDisposable, } from "../../../declarations"; import { injector } from "../../../yok"; +import { IChildProcess } from "../../../declarations"; +import { getIOSLogScope } from "../ios-log-entry"; export class IOSSimulatorLogProvider extends EventEmitter - implements Mobile.IiOSSimulatorLogProvider, IDisposable, IShouldDispose { + implements Mobile.IiOSSimulatorLogProvider, IDisposable, IShouldDispose +{ public shouldDispose: boolean; private simulatorsLoggingEnabled: IDictionary = {}; private simulatorsLogProcess: IDictionary = {}; @@ -19,7 +23,8 @@ export class IOSSimulatorLogProvider private $iOSSimResolver: Mobile.IiOSSimResolver, private $logger: ILogger, private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - private $deviceLogProvider: Mobile.IDeviceLogProvider + private $deviceLogProvider: Mobile.IDeviceLogProvider, + private $childProcess: IChildProcess, ) { super(); this.shouldDispose = true; @@ -31,20 +36,26 @@ export class IOSSimulatorLogProvider public async startLogProcess( deviceId: string, - options?: Mobile.IiOSLogStreamOptions + options?: Mobile.IiOSLogStreamOptions, ): Promise { if (!this.simulatorsLoggingEnabled[deviceId]) { - const deviceLogChildProcess: ChildProcess = await this.$iOSSimResolver.iOSSim.getDeviceLogProcess( - deviceId, - options ? options.predicate : null - ); + // Structured logs need the app's executable name to select its + // entries; without one the predicate decides what is shown. + const processName = + this.$deviceLogProvider.getProjectNameForDevice(deviceId); + const deviceLogChildProcess: ChildProcess = processName + ? this.startStructuredLogProcess(deviceId, processName) + : await this.$iOSSimResolver.iOSSim.getDeviceLogProcess( + deviceId, + options ? options.predicate : null, + ); const action = (data: Buffer | string) => { const message = data.toString(); this.$deviceLogProvider.logData( message, this.$devicePlatformsConstants.iOS, - deviceId + deviceId, ); }; @@ -55,18 +66,24 @@ export class IOSSimulatorLogProvider deviceLogChildProcess.once("error", (err) => { this.$logger.trace( - `Error is thrown for device with identifier ${deviceId}. More info: ${err.message}.` + `Error is thrown for device with identifier ${deviceId}. More info: ${err.message}.`, ); this.simulatorsLoggingEnabled[deviceId] = false; }); } - if (deviceLogChildProcess.stdout) { - deviceLogChildProcess.stdout.on("data", action.bind(this)); - } + if (processName) { + readline + .createInterface({ input: deviceLogChildProcess.stdout }) + .on("line", (line) => this.logStructuredLine(deviceId, line)); + } else { + if (deviceLogChildProcess.stdout) { + deviceLogChildProcess.stdout.on("data", action.bind(this)); + } - if (deviceLogChildProcess.stderr) { - deviceLogChildProcess.stderr.on("data", action.bind(this)); + if (deviceLogChildProcess.stderr) { + deviceLogChildProcess.stderr.on("data", action.bind(this)); + } } this.simulatorsLoggingEnabled[deviceId] = true; @@ -74,6 +91,64 @@ export class IOSSimulatorLogProvider } } + private startStructuredLogProcess( + deviceId: string, + processName: string, + ): ChildProcess { + // Matched by name rather than pid, so the logs follow the app when it is + // relaunched. log stream leaves out info and debug entries by default. + const scope = getIOSLogScope(this.$logger.getLevel(), false); + const args = [ + "simctl", + "spawn", + deviceId, + "log", + "stream", + "--style", + "ndjson", + "--predicate", + `process == ${JSON.stringify(processName)}`, + ]; + if (scope.includeNative) { + args.push("--level", scope.includeDebug ? "debug" : "info"); + } + + return this.$childProcess.spawn("xcrun", args); + } + + private logStructuredLine(deviceId: string, line: string): void { + // log stream prints a non-JSON "Filtering the log data..." header first. + if (!line.startsWith("{")) { + return; + } + + let event: any; + try { + event = JSON.parse(line); + } catch (err) { + this.$logger.trace(`Unable to parse simulator log entry: ${line}`); + return; + } + + // Activities and signposts are streamed too, but are not log messages. + if (event.eventType && event.eventType !== "logEvent") { + return; + } + + this.$deviceLogProvider.logEntry( + { + pid: event.processID, + thread: event.threadID, + process: event.processImagePath, + image: event.senderImagePath, + level: (event.messageType || "").toLowerCase(), + message: event.eventMessage, + }, + this.$devicePlatformsConstants.iOS, + deviceId, + ); + } + public dispose(signal?: any) { if (this.shouldDispose) { _.each( @@ -82,7 +157,7 @@ export class IOSSimulatorLogProvider if (logProcess) { logProcess.kill(signal); } - } + }, ); } } diff --git a/lib/common/test/unit-tests/ios-log-filter.ts b/lib/common/test/unit-tests/ios-log-filter.ts deleted file mode 100644 index ed07a29e11..0000000000 --- a/lib/common/test/unit-tests/ios-log-filter.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { IOSLogFilter } from "../../mobile/ios/ios-log-filter"; -import { LoggingLevels } from "../../mobile/logging-levels"; -import { Yok } from "../../yok"; -import * as assert from "assert"; -import * as _ from "lodash"; - -const iosTestData = [ - { - input: - "Dec 29 08:46:04 Dragons-iPhone iaptransportd[65] : CIapPortAppleIDBus: Auth timer timeout completed on pAIDBPort:0x135d09410, portID:01 downstream port", - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:46:06 Dragons-iPhone kernel[0] : AppleARMPMUCharger: AppleUSBCableDetect 1", - output: null, - pid13309Output: null, - }, - { - input: - 'Dec 29 08:47:24 Dragons-iPhone bird[131] : unable to determine evictable space: Error Domain=LibrarianErrorDomain Code=10 "The operation couldn’t be completed. (LibrarianErrorDomain error 10 - Unable to configure the collection.)" UserInfo=0x137528190 {NSDescription=Unable to configure the collection.}', - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:47:43 Dragons-iPhone syslog_relay[179] : syslog_relay found the ASL prompt. Starting...", - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:48:47 Dragons-iPhone com.apple.xpc.launchd[1] (com.apple.WebKit.Networking.08B3A589-3D68-492A-BA8D-A812EC55FDEB[13306]) : Service exited with abnormal code: 1", - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:48:47 Dragons-iPhone ReportCrash[13308] : Saved report to /var/mobile/Library/Logs/CrashReporter/Cordova370_2015-12-29-084847_Dragons-iPhone.ips", - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:48:47 Dragons-iPhone com.apple.WebKit.Networking[13306] : Faild to obtain sandbox extension for path=/private/var/mobile/Containers/Data/Application/047BB8F2-B8C8-405F-A820-8719EE207E6F/Library/Caches/com.telerik.BlankJS. Errno:1", - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Apache Cordova native platform version 3.7.0 is starting.", - output: - ": Apache Cordova native platform version 3.7.0 is starting.", - pid13309Output: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Apache Cordova native platform version 3.7.0 is starting.", - }, - { - input: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Multi-tasking -> Device: YES, App: YES", - output: ": Multi-tasking -> Device: YES, App: YES", - pid13309Output: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Multi-tasking -> Device: YES, App: YES", - }, - { - input: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Unlimited access to network resources", - output: ": Unlimited access to network resources", - pid13309Output: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Unlimited access to network resources", - }, - { - input: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Finished load of: file:///var/mobile/Containers/Data/Application/0746156D-3C83-402E-8B4E-2B3063F42F76/Documents/index.html", - output: - ": Finished load of: file:///var/mobile/Containers/Data/Application/0746156D-3C83-402E-8B4E-2B3063F42F76/Documents/index.html", - pid13309Output: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Finished load of: file:///var/mobile/Containers/Data/Application/0746156D-3C83-402E-8B4E-2B3063F42F76/Documents/index.html", - }, - { - input: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : ---------------------------------- LOG FROM MY APP", - output: ": ---------------------------------- LOG FROM MY APP", - pid13309Output: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : ---------------------------------- LOG FROM MY APP", - }, - { - input: - "Dec 29 08:50:31 Dragons-iPhone NativeScript143[13314] : assertion failed: 12F70: libxpc.dylib + 71768 [B870B51D-AA85-3686-A7D9-ACD48C5FE153]: 0x7d", - output: - ": assertion failed: 12F70: libxpc.dylib + 71768 [B870B51D-AA85-3686-A7D9-ACD48C5FE153]: 0x7d", - pid13309Output: null, - }, - { - input: "Dec 29 08:50:31 Dragons-iPhone Unknown[13314] :", - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:50:31 Dragons-iPhone locationd[57] : Gesture EnabledForTopCLient: 0, EnabledInDaemonSettings: 0", - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:55:24 Dragons-iPhone NativeScript143[13309] : file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41", - output: - ": file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41", - pid13309Output: - "Dec 29 08:55:24 Dragons-iPhone NativeScript143[13309] : file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41", - }, - { - input: - "Dec 29 08:55:24 Dragons-iPhone NativeScript143[13309] : file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41\n", - output: - ": file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41", - pid13309Output: - "Dec 29 08:55:24 Dragons-iPhone NativeScript143[13309] : file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41", - }, - { - input: - "Dec 29 08:55:24 Dragons-iPhone NativeScript143[13309]: : file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41\n", - output: - ": file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41", - pid13309Output: - "Dec 29 08:55:24 Dragons-iPhone NativeScript143[13309]: : file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41", - }, - { - input: - "Oct 4 08:53:46 bd-airtestmac com.apple.CoreSimulator.SimDevice.3616FC55-9CAB-47D4-8C58-5E5F0BE99C8E.launchd_sim[30337] (UIKitApplication:org.nativescript.ap1[0x76c5][13309]): Service exited due to signal: Terminated: 15", - output: null, - pid13309Output: - "Oct 4 08:53:46 bd-airtestmac com.apple.CoreSimulator.SimDevice.3616FC55-9CAB-47D4-8C58-5E5F0BE99C8E.launchd_sim[30337] (UIKitApplication:org.nativescript.ap1[0x76c5][13309]): Service exited due to signal: Terminated: 15", - }, - { - input: - "Oct 4 08:52:44 bd-airtestmac assertiond[13309]: assertion failed: 15G31 13A344: assertiond + 12188 [93893311-6962-33A7-A734-E36F946D8EBA]: 0x1", - output: null, - pid13309Output: - "Oct 4 08:52:44 bd-airtestmac assertiond[13309]: assertion failed: 15G31 13A344: assertiond + 12188 [93893311-6962-33A7-A734-E36F946D8EBA]: 0x1", - }, -]; - -describe("iOSLogFilter", () => { - const assertFiltering = ( - inputData: string, - expectedOutput: string, - _logLevel?: string, - _pid?: string - ) => { - const testInjector = new Yok(); - testInjector.register("loggingLevels", LoggingLevels); - const iOSLogFilter = ( - testInjector.resolve(IOSLogFilter) - ); - const filteredData = iOSLogFilter.filterData(inputData, { - logLevel: _logLevel, - applicationPid: _pid, - projectDir: null, - }); - assert.deepStrictEqual( - filteredData, - expectedOutput, - `The actual result '${filteredData}' did NOT match expected output '${expectedOutput}'.` - ); - }; - - let logLevel = "INFO"; - const pid = "13309"; - - describe("filterData", () => { - describe("when PID is not provided", () => { - it("when log level is full returns full data", () => { - logLevel = "FULL"; - _.each(iosTestData, (testData) => { - assertFiltering(testData.input, testData.input, logLevel); - }); - }); - - it("when log level is INFO filters data", () => { - logLevel = "INFO"; - _.each(iosTestData, (testData) => { - assertFiltering(testData.input, testData.output, logLevel); - }); - }); - - it("when log level is not specified returns full data", () => { - logLevel = null; - _.each(iosTestData, (testData) => { - assertFiltering(testData.input, testData.input); - }); - }); - }); - - describe("when PID is provided", () => { - it("when log level is full returns full data", () => { - logLevel = "FULL"; - _.each(iosTestData, (testData) => { - assertFiltering(testData.input, testData.input, logLevel, pid); - }); - }); - - it("when log level is INFO filters data", () => { - logLevel = "INFO"; - _.each(iosTestData, (testData) => { - assertFiltering( - testData.input, - testData.pid13309Output, - logLevel, - pid - ); - }); - }); - - it("when log level is not specified returns full data", () => { - logLevel = null; - _.each(iosTestData, (testData) => { - assertFiltering(testData.input, testData.input, logLevel, pid); - }); - }); - }); - }); -}); diff --git a/lib/common/test/unit-tests/mobile/device-log-provider.ts b/lib/common/test/unit-tests/mobile/device-log-provider.ts index 46f6929a90..b122676ceb 100644 --- a/lib/common/test/unit-tests/mobile/device-log-provider.ts +++ b/lib/common/test/unit-tests/mobile/device-log-provider.ts @@ -1671,4 +1671,27 @@ at promiseReactionJob\n`, }); }); }); + describe("structured iOS log entries", () => { + const structuredDevice = "structuredDevice"; + const appDir = "/private/var/containers/Bundle/Application/1/app.app"; + + it("prints the app's JS output and drops other entries", () => { + deviceLogProvider.setProjectNameForDevice(structuredDevice, "app"); + const log = (image: string, message: string, process = `${appDir}/app`) => + deviceLogProvider.logEntry( + { process, image, level: "notice", message }, + "iOS", + structuredDevice, + ); + + log( + `${appDir}/Frameworks/NativeScript.framework/NativeScript`, + "CONSOLE LOG: hello", + ); + log("/System/Library/Frameworks/Network.framework/Network", "nw noise"); + log("", "wlan0: noise", "/kernel"); + + assertData(logger.output, "CONSOLE LOG: hello\n"); + }); + }); }); diff --git a/lib/common/test/unit-tests/mobile/ios-log-entry.ts b/lib/common/test/unit-tests/mobile/ios-log-entry.ts new file mode 100644 index 0000000000..e8c8cc4a2e --- /dev/null +++ b/lib/common/test/unit-tests/mobile/ios-log-entry.ts @@ -0,0 +1,200 @@ +import { assert } from "chai"; +import * as sinon from "sinon"; +import { + getIOSLogEntryText, + getIOSLogScope, + IOSLogChunkStitcher, +} from "../../../mobile/ios/ios-log-entry"; + +const appDir = + "/private/var/containers/Bundle/Application/CFC7170F-ACC6-429C-942C-FF90EDC34D47/app.app"; + +const entry = (image: string, message: string, process = `${appDir}/app`) => ({ + process, + image, + level: "notice", + message, +}); + +const jsLog = entry( + `${appDir}/Frameworks/NativeScript.framework/NativeScript`, + 'CONSOLE LOG: [ "network", "internet probe -> true (status 200)" ]', +); +const appFrameworkLog = entry( + `${appDir}/Frameworks/LightLib.framework/LightLib`, + "universe 1 updated", +); +const systemFrameworkLog = entry( + "/System/Library/Frameworks/Network.framework/Network", + "nw_connection_report_state_with_handler_on_nw_queue [C2] reporting state cancelled", +); +const kernelLog = entry( + "", + "wlan0:com.apple.p2p.awdl0: isInfraRealtimePacketThresholdAllowed allowed:1", + "/kernel", +); + +describe("getIOSLogEntryText", () => { + const debugLog = { ...systemFrameworkLog, level: "debug" }; + + it("returns JS console output of the app", () => { + assert.equal( + getIOSLogEntryText(jsLog, { processName: "app" }), + jsLog.message, + ); + }); + + it("drops entries of other processes", () => { + assert.isNull(getIOSLogEntryText(kernelLog, { processName: "app" })); + assert.isNull( + getIOSLogEntryText(kernelLog, { + processName: "app", + includeNative: true, + includeDebug: true, + }), + ); + assert.isNull(getIOSLogEntryText(jsLog, { processName: "otherApp" })); + }); + + it("drops native entries unless they are requested", () => { + assert.isNull(getIOSLogEntryText(appFrameworkLog, { processName: "app" })); + assert.isNull( + getIOSLogEntryText(systemFrameworkLog, { processName: "app" }), + ); + }); + + it("includes everything the app's process logs, prefixed with the binary, when native logs are requested", () => { + const options = { processName: "app", includeNative: true }; + + assert.equal( + getIOSLogEntryText(appFrameworkLog, options), + "LightLib: universe 1 updated", + ); + assert.equal( + getIOSLogEntryText(systemFrameworkLog, options), + `Network: ${systemFrameworkLog.message}`, + ); + assert.isNull(getIOSLogEntryText(debugLog, options)); + }); + + it("includes debug entries only when requested", () => { + assert.equal( + getIOSLogEntryText(debugLog, { + processName: "app", + includeNative: true, + includeDebug: true, + }), + `Network: ${debugLog.message}`, + ); + }); + + it("drops the runtime's own startup message", () => { + assert.isNull( + getIOSLogEntryText( + { ...jsLog, message: "NativeScript loaded bundle file:///app/" }, + { processName: "app" }, + ), + ); + }); +}); + +describe("getIOSLogScope", () => { + it("maps the CLI log level to the app logs that are shown", () => { + assert.deepEqual(getIOSLogScope("INFO", false), { + includeNative: false, + includeDebug: false, + }); + assert.deepEqual(getIOSLogScope("DEBUG", false), { + includeNative: true, + includeDebug: false, + }); + assert.deepEqual(getIOSLogScope("TRACE", false), { + includeNative: true, + includeDebug: true, + }); + assert.deepEqual(getIOSLogScope("ALL", false), { + includeNative: true, + includeDebug: true, + }); + }); + + it("shows everything for a full device log", () => { + assert.deepEqual(getIOSLogScope("INFO", true), { + includeNative: true, + includeDebug: true, + }); + }); +}); + +describe("IOSLogChunkStitcher", () => { + const JOIN = "\u2060"; + const LINE = "\u2061"; + let stitcher: IOSLogChunkStitcher; + let messages: string[]; + let clock: sinon.SinonFakeTimers; + const add = (text: string, key = "thread") => + stitcher.add(key, text, (message) => messages.push(message)); + + beforeEach(() => { + clock = sinon.useFakeTimers(); + stitcher = new IOSLogChunkStitcher(); + messages = []; + }); + + afterEach(() => { + clock.restore(); + }); + + it("passes through messages that were not split", () => { + add("CONSOLE LOG: hello"); + assert.deepEqual(messages, ["CONSOLE LOG: hello"]); + }); + + it("rejoins chunks without the repeated prefix", () => { + add(`CONSOLE LOG: {"a":${JOIN}`); + add(`CONSOLE LOG: 1,"b":${JOIN}`); + assert.isEmpty(messages); + add("CONSOLE LOG: 2}"); + assert.deepEqual(messages, ['CONSOLE LOG: {"a":1,"b":2}']); + }); + + it("restores the line break a chunk was cut at", () => { + add(`CONSOLE ERROR: first${LINE}`); + add("CONSOLE ERROR: second"); + assert.deepEqual(messages, ["CONSOLE ERROR: first\nsecond"]); + }); + + it("rejoins messages that have no prefix", () => { + add(`Assertion failed: a${JOIN}`); + add("b"); + assert.deepEqual(messages, ["Assertion failed: ab"]); + }); + + it("keeps a chunk's own text when it only looks like a prefix", () => { + add(`CONSOLE LOG: a${JOIN}`); + add("CONSOLE ERROR: b"); + assert.deepEqual(messages, ["CONSOLE LOG: aCONSOLE ERROR: b"]); + }); + + it("keeps interleaved threads apart", () => { + add(`CONSOLE LOG: a1${JOIN}`, "a"); + add(`CONSOLE LOG: b1${JOIN}`, "b"); + add("CONSOLE LOG: other", "c"); + add("CONSOLE LOG: a2", "a"); + add("CONSOLE LOG: b2", "b"); + assert.deepEqual(messages, [ + "CONSOLE LOG: other", + "CONSOLE LOG: a1a2", + "CONSOLE LOG: b1b2", + ]); + }); + + it("prints what it has when the rest never arrives", () => { + add(`CONSOLE LOG: a${JOIN}`); + clock.tick(500); + assert.deepEqual(messages, ["CONSOLE LOG: a"]); + + add("CONSOLE LOG: unrelated"); + assert.deepEqual(messages, ["CONSOLE LOG: a", "CONSOLE LOG: unrelated"]); + }); +}); diff --git a/lib/common/test/unit-tests/stubs.ts b/lib/common/test/unit-tests/stubs.ts index 8bcb472531..83aeaf7f19 100644 --- a/lib/common/test/unit-tests/stubs.ts +++ b/lib/common/test/unit-tests/stubs.ts @@ -234,6 +234,18 @@ export class DeviceLogProviderStub }); } + logEntry( + entry: Mobile.IiOSLogEntry, + platform: string, + deviceIdentifier: string, + ): void { + this.logData(entry.message, platform, deviceIdentifier); + } + + getProjectNameForDevice(deviceIdentifier: string): string { + return this.currentDeviceProjectNames[deviceIdentifier]; + } + setLogLevel(level: string, deviceIdentifier?: string): void {} setApplicationPidForDevice(deviceIdentifier: string, pid: string): void { diff --git a/lib/controllers/prepare-controller.ts b/lib/controllers/prepare-controller.ts index b39a06d032..f70795f49b 100644 --- a/lib/controllers/prepare-controller.ts +++ b/lib/controllers/prepare-controller.ts @@ -341,6 +341,7 @@ export class PrepareController newNativeWatchStarted = await this.startNativeWatcher( platformData, projectData, + prepareData, ); } @@ -359,6 +360,7 @@ export class PrepareController private async startNativeWatcher( platformData: IPlatformData, projectData: IProjectData, + prepareData: IPrepareData, ): Promise { if ( this.watchersData[projectData.projectDir][ @@ -388,7 +390,11 @@ export class PrepareController this.$watchIgnoreListService.removeFileFromIgnoreList(filePath); } else { this.$logger.info(`Chokidar raised event ${event} for ${filePath}.`); - await this.writeRuntimePackageJson(projectData, platformData); + await this.writeRuntimePackageJson( + projectData, + platformData, + prepareData, + ); this.emitPrepareEvent({ files: [], staleFiles: [], @@ -518,6 +524,9 @@ export class PrepareController version: installedRuntimePackageJSON.version, }; } + if (prepareData && !prepareData.release) { + packageData.logChunkMarkers = true; + } if (packageData.ios && packageData.ios.discardUncaughtJsExceptions) { packageData.discardUncaughtJsExceptions = packageData.ios.discardUncaughtJsExceptions; diff --git a/lib/services/ios-log-filter.ts b/lib/services/ios-log-filter.ts index 04f58146dc..c994442a0c 100644 --- a/lib/services/ios-log-filter.ts +++ b/lib/services/ios-log-filter.ts @@ -2,15 +2,17 @@ import { injector } from "../common/yok"; export class IOSLogFilter implements Mobile.IPlatformLogFilter { // Used to recognize output related to the current project - // This looks for artifacts like: AppName[22432] or AppName(SomeTextHere)[23123] - private appOutputRegex: RegExp = /([^\s\(\)]+)(?:\(([^\s]+)\))?\[[0-9]+\]/; + // This looks for artifacts like: AppName[22432], AppName(SomeTextHere)[23123] + // or kernel()[0] - a header must be recognized even without an image name, + // or the line is treated as a continuation of the previous message. + private appOutputRegex: RegExp = /([^\s\(\)]+)(?:\(([^\s]*)\))?\[[0-9]+\]/; // Used to trim the passed messages to a simpler output // Example: // This: "May 24 15:54:52 Dragons-iPhone NativeScript250(NativeScript)[356] : CONSOLE ERROR file:///app/tns_modules/@angular/core/bundles/core.umd.js:3477:36: ORIGINAL STACKTRACE:" // Becomes: CONSOLE ERROR file:///app/tns_modules/@angular/core/bundles/core.umd.js:3477:36: ORIGINAL STACKTRACE: protected infoFilterRegex = new RegExp( - `^.*(?::[ \t]?|:[ \t]?|:[ \t]?|\\(NativeScript\\)[ \t]?|${this.appOutputRegex.source}:[ \t]?){1}` + `^.*(?::[ \t]?|:[ \t]?|:[ \t]?|\\(NativeScript\\)[ \t]?|${this.appOutputRegex.source}:[ \t]?){1}`, ); // Used to post filter messages that slip through but are not coming from NativeScript itself. @@ -21,6 +23,11 @@ export class IOSLogFilter implements Mobile.IPlatformLogFilter { // we then use this to filter out non-NativeScript lines protected postFilterRegex: RegExp = /^\((.+)\) \[com\.apple.+\]/; + // The markers the runtime ends a chunk of a split message with, which only the + // structured logs stitch. Syslog-style output shows them escaped. + private chunkMarkerRegex: RegExp = + /(?:\\M-b\\M\^A(?:\\240|\\M-!)|[\u2060\u2061])$/; + private filterActive: boolean = true; private partialLine: string = null; @@ -29,7 +36,7 @@ export class IOSLogFilter implements Mobile.IPlatformLogFilter { public filterData( data: string, - loggingOptions: Mobile.IDeviceLogOptions = {} + loggingOptions: Mobile.IDeviceLogOptions = {}, ): string { const specifiedLogLevel = (loggingOptions.logLevel || "").toUpperCase(); @@ -86,7 +93,8 @@ export class IOSLogFilter implements Mobile.IPlatformLogFilter { // remove lading space before CONSOLE .replace(/^\s*CONSOLE/, "CONSOLE") // trim trailing spaces only to preserve indentation - .trimEnd(); + .trimEnd() + .replace(this.chunkMarkerRegex, ""); // post filtering: () check if is not "NativeScript" const postFilterMatch = this.postFilterRegex.exec(currentLine); diff --git a/test/controllers/prepare-controller.ts b/test/controllers/prepare-controller.ts index a8a901a569..336db5e766 100644 --- a/test/controllers/prepare-controller.ts +++ b/test/controllers/prepare-controller.ts @@ -151,6 +151,42 @@ describe("prepareController", () => { }); }); + describe("writeRuntimePackageJson", () => { + const writePackageJson = async (platform: string, release: boolean) => { + const injector = createTestInjector({ hasNativeChanges: false }); + let written: any; + const prepareController: PrepareController = + injector.resolve("prepareController"); + _.assign(prepareController, { + $projectConfigService: { + detectProjectConfigs: () => ({ usingNSConfig: false }), + readConfig: () => ({}), + }, + $fs: { + readJson: () => ({}), + writeJson: (filePath: string, data: any) => { + written = data; + }, + }, + }); + await prepareController.writeRuntimePackageJson( + { projectDir, projectName: "app", packageJsonData: {} }, + { platformNameLowerCase: platform, projectRoot: projectDir }, + { ...prepareData, release }, + ); + + return written; + }; + + it("asks for log chunk markers in iOS debug builds only", async () => { + assert.isTrue((await writePackageJson("ios", false)).logChunkMarkers); + assert.isUndefined((await writePackageJson("ios", true)).logChunkMarkers); + assert.isUndefined( + (await writePackageJson("android", false)).logChunkMarkers, + ); + }); + }); + describe("stopWatchers", () => { it("detaches the bundler handler of a paused watcher", async () => { const injector = createTestInjector({ hasNativeChanges: false }); diff --git a/test/services/ios-log-filter.ts b/test/services/ios-log-filter.ts index 2330e8958e..4b71158310 100644 --- a/test/services/ios-log-filter.ts +++ b/test/services/ios-log-filter.ts @@ -182,6 +182,58 @@ describe("iOSLogFilter", () => { const infoLogLevel = "INFO"; const fullLogLevel = "FULL"; + it("removes the markers of split messages, escaped or not", () => { + testInjector = createTestInjector("app"); + logFilter = testInjector.resolve(IOSLogFilter); + const header = + "\u0000Oct 29 20:20:54 iPad app(NativeScript)[1514] : "; + const chunks = [ + `${header}CONSOLE LOG: a\\M-b\\M^A\\240\n`, + `${header}CONSOLE LOG: b\\M-b\\M^A\\M-!\n`, + `${header}CONSOLE LOG: c\u2060\n`, + `${header}CONSOLE LOG: d\n`, + ]; + + const output = chunks + .map((chunk) => + logFilter.filterData(chunk, { + logLevel: infoLogLevel, + projectName: "app", + }), + ) + .join(""); + + assert.strictEqual( + output, + "CONSOLE LOG: a\nCONSOLE LOG: b\nCONSOLE LOG: c\nCONSOLE LOG: d\n", + ); + }); + + it("drops other processes' lines that have no image name, such as the kernel's", () => { + testInjector = createTestInjector("nsconsoletablet"); + logFilter = testInjector.resolve(IOSLogFilter); + const chunks = [ + '\u0000Oct 29 20:20:54 iPad nsconsoletablet(NativeScript)[1514] : CONSOLE LOG: [ "network", "internet probe -> true (status 200)" ]\n', + "\u0000Oct 29 20:20:45 iPad kernel()[0] : wlan0:com.apple.p2p.awdl0: isInfraRealtimePacketThresholdAllowed allowed:1 option:32 threshold:50\n", + "\u0000Oct 29 20:20:45 iPad kernel()[0] : wlan0:com.apple.p2p.awdl0: currentInfraTrafficType:9274 checking if realtime upgrade required\n", + ]; + + const output = chunks + .map((chunk) => + logFilter.filterData(chunk, { + logLevel: infoLogLevel, + projectName: "nsconsoletablet", + }), + ) + .filter(Boolean) + .join(""); + + assert.strictEqual( + output, + 'CONSOLE LOG: [ "network", "internet probe -> true (status 200)" ]\n', + ); + }); + describe("filterData", () => { testData.forEach((data) => { it(`returns correct data on iOS ${data.version} when data comes in chunks`, () => { @@ -211,7 +263,7 @@ describe("iOSLogFilter", () => { assert.deepStrictEqual( output, - data.infoExpectedArr.filter((item) => item !== null).join("\n") + data.infoExpectedArr.filter((item) => item !== null).join("\n"), ); }); @@ -220,7 +272,7 @@ describe("iOSLogFilter", () => { logFilter = testInjector.resolve(IOSLogFilter); const actualData = logFilter.filterData( data.originalDataArr.join("\n"), - { logLevel: fullLogLevel, projectName: data.projectName } + { logLevel: fullLogLevel, projectName: data.projectName }, ); const actualArr = actualData.split("\n").map((line) => line.trim()); const expectedArr = data.originalDataArr @@ -252,7 +304,7 @@ describe("iOSLogFilter", () => { const actualArr = actualData.split("\n").map((line) => line.trim()); assert.deepStrictEqual( actualArr, - data.simulatorExpectedArr.filter((item) => item !== null) + data.simulatorExpectedArr.filter((item) => item !== null), ); }); @@ -261,11 +313,11 @@ describe("iOSLogFilter", () => { logFilter = testInjector.resolve(IOSLogFilter); const actualData = logFilter.filterData( data.originalDataArr.join("\n"), - { logLevel: infoLogLevel, projectName: data.projectName } + { logLevel: infoLogLevel, projectName: data.projectName }, ); const actualArr = actualData.split("\n").map((line) => line.trim()); const expectedArr = data.infoExpectedArr.filter( - (item) => item !== null + (item) => item !== null, ); assert.deepStrictEqual(actualArr, expectedArr); }); From e9a3bd72e51a036f595788f4e7add449ece24583 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sat, 7 Nov 2026 16:55:36 -0300 Subject: [PATCH 10/11] feat(ios): reassemble split app logs from the runtime's piece headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asks the runtime for log format 1 through the logFormat key of the app's package.json, in place of logChunkMarkers. In format 1, each piece of a message the runtime had to split is logged as "[ns /] ", where the pieces are consecutive slices of the message. The stitcher now collects pieces by process and message id, prints the message as soon as its last piece arrives, and after 500 ms prints what it has with "<…lost…>" where pieces never arrived. A message can no longer be joined onto another one whose last piece os_log dropped. Pieces are joined before the entry is turned into text, so the repeated console prefix no longer has to be stripped. The syslog fallback, which doesn't reassemble, removes the headers and shows the pieces as lines. --- lib/common/mobile/device-log-emitter.ts | 29 ++--- lib/common/mobile/device-log-provider-base.ts | 9 +- lib/common/mobile/device-log-provider.ts | 25 ++-- lib/common/mobile/ios/ios-log-entry.ts | 115 ++++++++++++------ .../unit-tests/mobile/device-log-provider.ts | 23 ++++ .../test/unit-tests/mobile/ios-log-entry.ts | 99 ++++++++++----- lib/controllers/prepare-controller.ts | 3 +- lib/services/ios-log-filter.ts | 12 +- test/controllers/prepare-controller.ts | 10 +- test/services/ios-log-filter.ts | 8 +- 10 files changed, 213 insertions(+), 120 deletions(-) diff --git a/lib/common/mobile/device-log-emitter.ts b/lib/common/mobile/device-log-emitter.ts index c254034b61..e4659a780d 100644 --- a/lib/common/mobile/device-log-emitter.ts +++ b/lib/common/mobile/device-log-emitter.ts @@ -39,27 +39,28 @@ export class DeviceLogEmitter extends DeviceLogProviderBase { platform: string, deviceIdentifier: string, ): void { - const loggingOptions = this.getDeviceLogOptionsForDevice(deviceIdentifier); - const text = getIOSLogEntryText(entry, { - processName: loggingOptions && loggingOptions.projectName, - ...getIOSLogScope( - this.$logger.getLevel(), - loggingOptions?.logLevel?.toUpperCase() === this.$loggingLevels.full, - ), - }); - if (text) { - this.stitchIOSLogEntry(entry, text, deviceIdentifier, (message) => + this.stitchIOSLogEntry(entry, deviceIdentifier, (completeEntry) => { + const loggingOptions = + this.getDeviceLogOptionsForDevice(deviceIdentifier); + const text = getIOSLogEntryText(completeEntry, { + processName: loggingOptions && loggingOptions.projectName, + ...getIOSLogScope( + this.$logger.getLevel(), + loggingOptions?.logLevel?.toUpperCase() === this.$loggingLevels.full, + ), + }); + if (text) { this.emitData( this.$logSourceMapService.replaceWithOriginalFileLocations( platform, - message + "\n", + text + "\n", loggingOptions, ), platform, deviceIdentifier, - ), - ); - } + ); + } + }); } private emitData( diff --git a/lib/common/mobile/device-log-provider-base.ts b/lib/common/mobile/device-log-provider-base.ts index 904e8a6531..eee704c96f 100644 --- a/lib/common/mobile/device-log-provider-base.ts +++ b/lib/common/mobile/device-log-provider-base.ts @@ -49,18 +49,17 @@ export abstract class DeviceLogProviderBase protected stitchIOSLogEntry( entry: Mobile.IiOSLogEntry, - text: string, deviceIdentifier: string, - emit: (text: string) => void, + emit: (entry: Mobile.IiOSLogEntry) => void, ): void { if (!isIOSRuntimeLogEntry(entry)) { - emit(text); + emit(entry); return; } this.iOSLogChunkStitcher.add( - `${deviceIdentifier}:${entry.pid}:${entry.thread}`, - text, + `${deviceIdentifier}:${entry.pid}`, + entry, emit, ); } diff --git a/lib/common/mobile/device-log-provider.ts b/lib/common/mobile/device-log-provider.ts index 0833399c25..5872f0f0e0 100644 --- a/lib/common/mobile/device-log-provider.ts +++ b/lib/common/mobile/device-log-provider.ts @@ -36,19 +36,20 @@ export class DeviceLogProvider extends DeviceLogProviderBase { platform: string, deviceIdentifier: string, ): void { - const loggingOptions = this.getDeviceLogOptionsForDevice(deviceIdentifier); - const text = getIOSLogEntryText(entry, { - processName: loggingOptions.projectName, - ...getIOSLogScope( - this.$logger.getLevel(), - loggingOptions.logLevel?.toUpperCase() === this.$loggingLevels.full, - ), + this.stitchIOSLogEntry(entry, deviceIdentifier, (completeEntry) => { + const loggingOptions = + this.getDeviceLogOptionsForDevice(deviceIdentifier); + const text = getIOSLogEntryText(completeEntry, { + processName: loggingOptions.projectName, + ...getIOSLogScope( + this.$logger.getLevel(), + loggingOptions.logLevel?.toUpperCase() === this.$loggingLevels.full, + ), + }); + if (text) { + this.printData(text + "\n", text, platform, deviceIdentifier); + } }); - if (text) { - this.stitchIOSLogEntry(entry, text, deviceIdentifier, (message) => - this.printData(message + "\n", message, platform, deviceIdentifier), - ); - } } private printData( diff --git a/lib/common/mobile/ios/ios-log-entry.ts b/lib/common/mobile/ios/ios-log-entry.ts index 0d1121fe52..e5b9f883d6 100644 --- a/lib/common/mobile/ios/ios-log-entry.ts +++ b/lib/common/mobile/ios/ios-log-entry.ts @@ -74,20 +74,31 @@ export function getIOSLogEntryText( return `${imageName}: ${entry.message}`; } -// The runtime ends a chunk of a split message with one of these when the CLI -// asks for it, through the logChunkMarkers key of the app's package.json. -const CHUNK_JOIN_MARKER = "\u2060"; -const CHUNK_LINE_MARKER = "\u2061"; -const CONSOLE_PREFIX = /^CONSOLE [A-Z]+: /; +/** + * The newest log format the CLI understands, written to the logFormat key of + * the app's package.json. The runtime uses the lower of this and its own. + */ +export const IOS_LOG_FORMAT = 1; + +// Format 1: each piece of a message the runtime had to split is logged as +// "[ns /] ". The id is unique per message in the +// process, and the pieces are consecutive slices of the message. A piece of +// nothing but whitespace arrives as the bare header: the unified log strips +// whitespace from the end of every entry. +export const IOS_LOG_PIECE_HEADER = /^\[ns ([0-9a-f]+) (\d+)\/(\d+)\](?: |$)/; -// Long enough to ride out a backlog of chunks, while the rest of a message that -// never arrives, because the app crashed, still gets printed. -const CHUNK_WAIT_MS = 500; +// Where a piece that never arrived belongs; os_log marks truncated text alike. +const LOST_PIECE = "<…lost…>"; + +// Long enough to ride out a backlog of pieces, while a message whose pieces +// never all arrive, because os_log dropped some or the app crashed, still gets +// printed. +const PIECE_WAIT_MS = 500; interface IPendingLogMessage { - text: string; - prefix: string; - separator: string; + entry: Mobile.IiOSLogEntry; + pieces: string[]; + received: number; timer: NodeJS.Timeout; } @@ -98,40 +109,66 @@ export class IOSLogChunkStitcher { private pending = new Map(); /** - * @param key Identifies the logging thread: chunks from different threads can interleave. - * @param emit Called with the complete message. + * @param source Identifies the logging process, whose message ids are unique. + * @param emit Called with each complete entry, in place of its pieces. */ - public add(key: string, text: string, emit: (text: string) => void): void { - const marker = text[text.length - 1]; - const continues = - marker === CHUNK_JOIN_MARKER || marker === CHUNK_LINE_MARKER; - const chunk = continues ? text.slice(0, -1) : text; - - const message = this.pending.get(key) ?? { - text: "", - prefix: chunk.match(CONSOLE_PREFIX)?.[0] ?? "", - separator: "", - timer: null, - }; - clearTimeout(message.timer); - message.text += - message.separator + - (message.text && chunk.startsWith(message.prefix) - ? chunk.slice(message.prefix.length) - : chunk); + public add( + source: string, + entry: Mobile.IiOSLogEntry, + emit: (entry: Mobile.IiOSLogEntry) => void, + ): void { + const header = (entry.message || "").match(IOS_LOG_PIECE_HEADER); + const index = header ? Number(header[2]) - 1 : -1; + const count = header ? Number(header[3]) : 0; + if (!header || index < 0 || index >= count) { + emit(entry); + return; + } - if (!continues) { + const key = `${source}:${header[1]}`; + let message = this.pending.get(key); + if (!message || message.pieces.length !== count) { + message = { + entry, + pieces: new Array(count), + received: 0, + timer: null, + }; + this.pending.set(key, message); + } + if (message.pieces[index] === undefined) { + message.pieces[index] = entry.message.slice(header[0].length); + message.received++; + } + if (index === 0) { + message.entry = entry; + } + + clearTimeout(message.timer); + const finish = () => { this.pending.delete(key); - emit(message.text); + emit({ ...message.entry, message: joinPieces(message) }); + }; + if (message.received === count) { + finish(); return; } - message.separator = marker === CHUNK_LINE_MARKER ? "\n" : ""; - message.timer = setTimeout(() => { - this.pending.delete(key); - emit(message.text); - }, CHUNK_WAIT_MS); + message.timer = setTimeout(finish, PIECE_WAIT_MS); message.timer.unref(); - this.pending.set(key, message); } } + +function joinPieces(message: IPendingLogMessage): string { + let text = ""; + for (let i = 0; i < message.pieces.length; i++) { + if (message.pieces[i] === undefined) { + if (!text.endsWith(LOST_PIECE)) { + text += LOST_PIECE; + } + continue; + } + text += message.pieces[i]; + } + return text; +} diff --git a/lib/common/test/unit-tests/mobile/device-log-provider.ts b/lib/common/test/unit-tests/mobile/device-log-provider.ts index b122676ceb..b86fb8f855 100644 --- a/lib/common/test/unit-tests/mobile/device-log-provider.ts +++ b/lib/common/test/unit-tests/mobile/device-log-provider.ts @@ -1693,5 +1693,28 @@ at promiseReactionJob\n`, assertData(logger.output, "CONSOLE LOG: hello\n"); }); + + it("prints a split message once, when all of its pieces are in", () => { + deviceLogProvider.setProjectNameForDevice(structuredDevice, "app"); + const piece = (message: string) => + deviceLogProvider.logEntry( + { + pid: 1, + thread: 2, + process: `${appDir}/app`, + image: `${appDir}/Frameworks/NativeScript.framework/NativeScript`, + level: "notice", + message, + }, + "iOS", + structuredDevice, + ); + + piece("[ns 3 1/2] CONSOLE LOG: first"); + assertData(logger.output, ""); + piece("[ns 3 2/2] \nsecond"); + + assertData(logger.output, "CONSOLE LOG: first\nsecond\n"); + }); }); }); diff --git a/lib/common/test/unit-tests/mobile/ios-log-entry.ts b/lib/common/test/unit-tests/mobile/ios-log-entry.ts index e8c8cc4a2e..620125bd34 100644 --- a/lib/common/test/unit-tests/mobile/ios-log-entry.ts +++ b/lib/common/test/unit-tests/mobile/ios-log-entry.ts @@ -127,13 +127,15 @@ describe("getIOSLogScope", () => { }); describe("IOSLogChunkStitcher", () => { - const JOIN = "\u2060"; - const LINE = "\u2061"; let stitcher: IOSLogChunkStitcher; let messages: string[]; let clock: sinon.SinonFakeTimers; - const add = (text: string, key = "thread") => - stitcher.add(key, text, (message) => messages.push(message)); + const add = (message: string, source = "process") => + stitcher.add( + source, + { process: "app", image: "NativeScript", level: "default", message }, + (entry) => messages.push(entry.message), + ); beforeEach(() => { clock = sinon.useFakeTimers(); @@ -150,38 +152,34 @@ describe("IOSLogChunkStitcher", () => { assert.deepEqual(messages, ["CONSOLE LOG: hello"]); }); - it("rejoins chunks without the repeated prefix", () => { - add(`CONSOLE LOG: {"a":${JOIN}`); - add(`CONSOLE LOG: 1,"b":${JOIN}`); + it("joins the pieces of a message as they are", () => { + add('[ns 1f 1/3] CONSOLE LOG: {"a":'); + add('[ns 1f 2/3] 1,"b":'); assert.isEmpty(messages); - add("CONSOLE LOG: 2}"); + add("[ns 1f 3/3] 2}"); assert.deepEqual(messages, ['CONSOLE LOG: {"a":1,"b":2}']); }); - it("restores the line break a chunk was cut at", () => { - add(`CONSOLE ERROR: first${LINE}`); - add("CONSOLE ERROR: second"); - assert.deepEqual(messages, ["CONSOLE ERROR: first\nsecond"]); - }); - - it("rejoins messages that have no prefix", () => { - add(`Assertion failed: a${JOIN}`); - add("b"); - assert.deepEqual(messages, ["Assertion failed: ab"]); + it("keeps the whitespace a piece starts with", () => { + add("[ns 2 1/3] CONSOLE ERROR: first"); + add("[ns 2 2/3] \nsecond"); + add("[ns 2 3/3] third"); + assert.deepEqual(messages, ["CONSOLE ERROR: first \nsecond third"]); }); - it("keeps a chunk's own text when it only looks like a prefix", () => { - add(`CONSOLE LOG: a${JOIN}`); - add("CONSOLE ERROR: b"); - assert.deepEqual(messages, ["CONSOLE LOG: aCONSOLE ERROR: b"]); + it("takes a bare header for a piece of nothing but whitespace", () => { + add("[ns 9 1/3] CONSOLE LOG: a"); + add("[ns 9 2/3]"); + add("[ns 9 3/3] b"); + assert.deepEqual(messages, ["CONSOLE LOG: a b"]); }); - it("keeps interleaved threads apart", () => { - add(`CONSOLE LOG: a1${JOIN}`, "a"); - add(`CONSOLE LOG: b1${JOIN}`, "b"); - add("CONSOLE LOG: other", "c"); - add("CONSOLE LOG: a2", "a"); - add("CONSOLE LOG: b2", "b"); + it("keeps interleaved messages apart by id", () => { + add("[ns a 1/2] CONSOLE LOG: a1"); + add("[ns b 1/2] CONSOLE LOG: b1"); + add("CONSOLE LOG: other"); + add("[ns a 2/2] a2"); + add("[ns b 2/2] b2"); assert.deepEqual(messages, [ "CONSOLE LOG: other", "CONSOLE LOG: a1a2", @@ -189,12 +187,47 @@ describe("IOSLogChunkStitcher", () => { ]); }); - it("prints what it has when the rest never arrives", () => { - add(`CONSOLE LOG: a${JOIN}`); + it("keeps the same id from different processes apart", () => { + add("[ns 1 1/2] CONSOLE LOG: old", "launch-1"); + add("[ns 1 1/2] CONSOLE LOG: new", "launch-2"); + add("[ns 1 2/2] !", "launch-2"); + assert.deepEqual(messages, ["CONSOLE LOG: new!"]); + }); + + it("never joins the next message onto one whose last piece was lost", () => { + add("[ns 5 1/3] CONSOLE LOG: AAAA"); + add("[ns 5 2/3] aaaa"); + add("CONSOLE ERROR: B failed"); + assert.deepEqual(messages, ["CONSOLE ERROR: B failed"]); + clock.tick(500); - assert.deepEqual(messages, ["CONSOLE LOG: a"]); + assert.deepEqual(messages, [ + "CONSOLE ERROR: B failed", + "CONSOLE LOG: AAAAaaaa<…lost…>", + ]); + }); - add("CONSOLE LOG: unrelated"); - assert.deepEqual(messages, ["CONSOLE LOG: a", "CONSOLE LOG: unrelated"]); + it("marks where a lost piece belongs", () => { + add("[ns 6 1/4] CONSOLE LOG: one\n"); + add("[ns 6 4/4] four"); + clock.tick(500); + assert.deepEqual(messages, ["CONSOLE LOG: one\n<…lost…>four"]); + }); + + it("prints what it has when the rest never arrives", () => { + add("[ns 7 2/2] tail"); + clock.tick(499); + assert.isEmpty(messages); + clock.tick(1); + assert.deepEqual(messages, ["<…lost…>tail"]); + }); + + it("passes through text that only looks like a header", () => { + add("[ns 8 3/2] CONSOLE LOG: x"); + add("[ns zz 1/2] CONSOLE LOG: y"); + assert.deepEqual(messages, [ + "[ns 8 3/2] CONSOLE LOG: x", + "[ns zz 1/2] CONSOLE LOG: y", + ]); }); }); diff --git a/lib/controllers/prepare-controller.ts b/lib/controllers/prepare-controller.ts index f70795f49b..d1dc602b67 100644 --- a/lib/controllers/prepare-controller.ts +++ b/lib/controllers/prepare-controller.ts @@ -11,6 +11,7 @@ import { import { cache, performanceLog } from "../common/decorators"; import { hook } from "../common/helpers"; import { injector } from "../common/yok"; +import { IOS_LOG_FORMAT } from "../common/mobile/ios/ios-log-entry"; import { AnalyticsEventLabelDelimiter, BUNDLER_COMPILATION_COMPLETE, @@ -525,7 +526,7 @@ export class PrepareController }; } if (prepareData && !prepareData.release) { - packageData.logChunkMarkers = true; + packageData.logFormat = IOS_LOG_FORMAT; } if (packageData.ios && packageData.ios.discardUncaughtJsExceptions) { packageData.discardUncaughtJsExceptions = diff --git a/lib/services/ios-log-filter.ts b/lib/services/ios-log-filter.ts index c994442a0c..6b5c5d53fe 100644 --- a/lib/services/ios-log-filter.ts +++ b/lib/services/ios-log-filter.ts @@ -1,4 +1,5 @@ import { injector } from "../common/yok"; +import { IOS_LOG_PIECE_HEADER } from "../common/mobile/ios/ios-log-entry"; export class IOSLogFilter implements Mobile.IPlatformLogFilter { // Used to recognize output related to the current project @@ -23,10 +24,9 @@ export class IOSLogFilter implements Mobile.IPlatformLogFilter { // we then use this to filter out non-NativeScript lines protected postFilterRegex: RegExp = /^\((.+)\) \[com\.apple.+\]/; - // The markers the runtime ends a chunk of a split message with, which only the - // structured logs stitch. Syslog-style output shows them escaped. - private chunkMarkerRegex: RegExp = - /(?:\\M-b\\M\^A(?:\\240|\\M-!)|[\u2060\u2061])$/; + // Only the structured logs reassemble split messages; here their pieces just + // lose the header and show as consecutive lines. + private pieceHeaderRegex: RegExp = IOS_LOG_PIECE_HEADER; private filterActive: boolean = true; @@ -90,11 +90,11 @@ export class IOSLogFilter implements Mobile.IPlatformLogFilter { } currentLine = currentLine + .replace(this.pieceHeaderRegex, "") // remove lading space before CONSOLE .replace(/^\s*CONSOLE/, "CONSOLE") // trim trailing spaces only to preserve indentation - .trimEnd() - .replace(this.chunkMarkerRegex, ""); + .trimEnd(); // post filtering: () check if is not "NativeScript" const postFilterMatch = this.postFilterRegex.exec(currentLine); diff --git a/test/controllers/prepare-controller.ts b/test/controllers/prepare-controller.ts index 336db5e766..f9328a377c 100644 --- a/test/controllers/prepare-controller.ts +++ b/test/controllers/prepare-controller.ts @@ -178,12 +178,10 @@ describe("prepareController", () => { return written; }; - it("asks for log chunk markers in iOS debug builds only", async () => { - assert.isTrue((await writePackageJson("ios", false)).logChunkMarkers); - assert.isUndefined((await writePackageJson("ios", true)).logChunkMarkers); - assert.isUndefined( - (await writePackageJson("android", false)).logChunkMarkers, - ); + it("asks for the structured log format in iOS debug builds only", async () => { + assert.equal((await writePackageJson("ios", false)).logFormat, 1); + assert.isUndefined((await writePackageJson("ios", true)).logFormat); + assert.isUndefined((await writePackageJson("android", false)).logFormat); }); }); diff --git a/test/services/ios-log-filter.ts b/test/services/ios-log-filter.ts index 4b71158310..038597cd80 100644 --- a/test/services/ios-log-filter.ts +++ b/test/services/ios-log-filter.ts @@ -182,15 +182,15 @@ describe("iOSLogFilter", () => { const infoLogLevel = "INFO"; const fullLogLevel = "FULL"; - it("removes the markers of split messages, escaped or not", () => { + it("removes the headers of split messages", () => { testInjector = createTestInjector("app"); logFilter = testInjector.resolve(IOSLogFilter); const header = "\u0000Oct 29 20:20:54 iPad app(NativeScript)[1514] : "; const chunks = [ - `${header}CONSOLE LOG: a\\M-b\\M^A\\240\n`, - `${header}CONSOLE LOG: b\\M-b\\M^A\\M-!\n`, - `${header}CONSOLE LOG: c\u2060\n`, + `${header}[ns 1f 1/3] CONSOLE LOG: a\n`, + `${header}[ns 1f 2/3] CONSOLE LOG: b\n`, + `${header}[ns 1f 3/3] CONSOLE LOG: c\n`, `${header}CONSOLE LOG: d\n`, ]; From 717c3604a6a5a6de5f8a9e4df6bf2cdec6966978 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 18:40:19 +0100 Subject: [PATCH 11/11] feat(ios): show the app's forwarded stdout and stderr Debug builds ask the runtime to forward stdout and stderr through forwardStdio in the app's package.json. They arrive under the org.nativescript.stdio subsystem and show from --log debug, like the rest of the app's native output and as Xcode's console does, labeled with their stream. --- docs/man_pages/project/testing/run-ios.md | 2 +- lib/common/definitions/mobile.d.ts | 2 + lib/common/mobile/ios/ios-log-entry.ts | 8 ++++ .../simulator/ios-simulator-log-provider.ts | 2 + .../test/unit-tests/mobile/ios-log-entry.ts | 41 +++++++++++++++++++ lib/controllers/prepare-controller.ts | 1 + test/controllers/prepare-controller.ts | 4 +- 7 files changed, 58 insertions(+), 2 deletions(-) diff --git a/docs/man_pages/project/testing/run-ios.md b/docs/man_pages/project/testing/run-ios.md index 43259fe40f..c808c8f0e0 100644 --- a/docs/man_pages/project/testing/run-ios.md +++ b/docs/man_pages/project/testing/run-ios.md @@ -33,7 +33,7 @@ Start an emulator with specified device identifier and sdk | `$ ns run ios [--de * `--emulator` - If set, runs the app in all available and configured ios simulators. It will start a simulator if none are already running. * `--sdk` - Specifies the target simulator's sdk. * `--justlaunch` - If set, does not print the application output in the console. -* `--log ` - Also controls how much of the application output is printed. By default only its JavaScript output is shown. `debug` adds everything else the app logs - its native code and the system frameworks it uses, like Xcode's console - and `trace` adds debug level entries as well. +* `--log ` - Also controls how much of the application output is printed. By default only its JavaScript output is shown. `debug` adds everything else the app logs - its native code, `print()` output and the system frameworks it uses, like Xcode's console - and `trace` adds debug level entries as well. * `--clean` - If set, forces the complete rebuild of the native application. * `--no-watch` - If set, changes in your code will not be reflected during the execution of this command. * `--release` - If set, produces a release build by running webpack in production mode and native build in release mode. Otherwise, produces a debug build. diff --git a/lib/common/definitions/mobile.d.ts b/lib/common/definitions/mobile.d.ts index 32a4bb7f4c..12e8de36a6 100644 --- a/lib/common/definitions/mobile.d.ts +++ b/lib/common/definitions/mobile.d.ts @@ -173,6 +173,8 @@ declare global { image: string; level: string; message: string; + subsystem?: string; + category?: string; } interface IiOSLogStreamOptions { diff --git a/lib/common/mobile/ios/ios-log-entry.ts b/lib/common/mobile/ios/ios-log-entry.ts index e5b9f883d6..146735d82d 100644 --- a/lib/common/mobile/ios/ios-log-entry.ts +++ b/lib/common/mobile/ios/ios-log-entry.ts @@ -38,6 +38,10 @@ export function isIOSRuntimeLogEntry(entry: Mobile.IiOSLogEntry): boolean { return path.basename(entry.image || "") === "NativeScript"; } +// The runtime forwards the app's stdout and stderr under this subsystem, when +// the CLI asks for it through the forwardStdio key of the app's package.json. +const STDIO_SUBSYSTEM = "org.nativescript.stdio"; + // Logged by the runtime itself on every launch, not by the app. const RUNTIME_NOISE = ["NativeScript loaded bundle"]; @@ -57,6 +61,10 @@ export function getIOSLogEntryText( return null; } + if (entry.subsystem === STDIO_SUBSYSTEM) { + return options.includeNative ? `${entry.category}: ${entry.message}` : null; + } + const imageName = path.basename(entry.image || ""); if (isIOSRuntimeLogEntry(entry)) { return RUNTIME_NOISE.some((noise) => entry.message.includes(noise)) diff --git a/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts b/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts index e560adf789..cb1e1e48f0 100644 --- a/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts +++ b/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts @@ -143,6 +143,8 @@ export class IOSSimulatorLogProvider image: event.senderImagePath, level: (event.messageType || "").toLowerCase(), message: event.eventMessage, + subsystem: event.subsystem, + category: event.category, }, this.$devicePlatformsConstants.iOS, deviceId, diff --git a/lib/common/test/unit-tests/mobile/ios-log-entry.ts b/lib/common/test/unit-tests/mobile/ios-log-entry.ts index 620125bd34..3ed2425981 100644 --- a/lib/common/test/unit-tests/mobile/ios-log-entry.ts +++ b/lib/common/test/unit-tests/mobile/ios-log-entry.ts @@ -98,6 +98,47 @@ describe("getIOSLogEntryText", () => { }); }); +describe("getIOSLogEntryText for forwarded stdio", () => { + const entry = { + process: "/var/containers/Bundle/Application/X/app.app/app", + image: "/private/Frameworks/NativeScript.framework/NativeScript", + level: "default", + message: "printed", + subsystem: "org.nativescript.stdio", + category: "stdout", + }; + + it("is hidden by default, unlike the runtime's console output", () => { + assert.isNull(getIOSLogEntryText(entry, { processName: "app" })); + }); + + it("is labeled with its stream along with native output", () => { + assert.equal( + getIOSLogEntryText( + { ...entry, category: "stderr" }, + { processName: "app", includeNative: true }, + ), + "stderr: printed", + ); + }); + + it("is labeled once when a long line was split", () => { + const stitcher = new IOSLogChunkStitcher(); + const printed: string[] = []; + for (const message of ["[ns 4 1/2] one", "[ns 4 2/2] two"]) { + stitcher.add("process", { ...entry, message }, (complete) => + printed.push( + getIOSLogEntryText(complete, { + processName: "app", + includeNative: true, + }), + ), + ); + } + assert.deepEqual(printed, ["stdout: one two"]); + }); +}); + describe("getIOSLogScope", () => { it("maps the CLI log level to the app logs that are shown", () => { assert.deepEqual(getIOSLogScope("INFO", false), { diff --git a/lib/controllers/prepare-controller.ts b/lib/controllers/prepare-controller.ts index d1dc602b67..58415bc513 100644 --- a/lib/controllers/prepare-controller.ts +++ b/lib/controllers/prepare-controller.ts @@ -527,6 +527,7 @@ export class PrepareController } if (prepareData && !prepareData.release) { packageData.logFormat = IOS_LOG_FORMAT; + packageData.forwardStdio = true; } if (packageData.ios && packageData.ios.discardUncaughtJsExceptions) { packageData.discardUncaughtJsExceptions = diff --git a/test/controllers/prepare-controller.ts b/test/controllers/prepare-controller.ts index f9328a377c..2121f5b989 100644 --- a/test/controllers/prepare-controller.ts +++ b/test/controllers/prepare-controller.ts @@ -178,9 +178,11 @@ describe("prepareController", () => { return written; }; - it("asks for the structured log format in iOS debug builds only", async () => { + it("asks for the structured log format and stdio in iOS debug builds only", async () => { assert.equal((await writePackageJson("ios", false)).logFormat, 1); assert.isUndefined((await writePackageJson("ios", true)).logFormat); + assert.isTrue((await writePackageJson("ios", false)).forwardStdio); + assert.isUndefined((await writePackageJson("ios", true)).forwardStdio); assert.isUndefined((await writePackageJson("android", false)).logFormat); }); });