diff --git a/docs/developer-guide/MCP-Headless-API.asciidoc b/docs/developer-guide/MCP-Headless-API.asciidoc index 451253fed04..c2f59920196 100644 --- a/docs/developer-guide/MCP-Headless-API.asciidoc +++ b/docs/developer-guide/MCP-Headless-API.asciidoc @@ -34,6 +34,16 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/M The stdio transport is the standard MCP local transport, exchanging newline delimited JSON-RPC messages. While it runs, application logging is redirected away from standard output so it can't corrupt the protocol stream. The stdio transport lives in the JavaSE port because it needs process standard input, which isn't available on every target. +=== Attaching to an application on a device + +The socket transport binds the loopback interface, and it binds the loopback interface of whatever machine the application is running on. In the simulator that's your own, so an agent connects to `127.0.0.1` and there's nothing else to arrange. On a phone the port belongs to the phone, and reaching it needs a forward. + +On Android, `adb forward tcp:8765 tcp:8765` maps a port on the development machine onto the same port inside the device, so an agent connects to `127.0.0.1:8765` as before. Remove it with `adb forward --remove tcp:8765` when the session ends. The `cn1:android-on-device-debugging` goal already installs and launches a debuggable build, and its `waitForAttach=false` option is the one to use when the point of the session is the agent rather than a breakpoint, since the application then boots straight into a drivable state instead of blocking for a debugger. On iOS the same setting isn't a convenience but a requirement, because `ios.onDeviceDebug.waitForAttach` defers the callback that boots the application until a debugger attaches -- so a starter in the application's own code never runs, and there is nothing listening on the port to reach. + +The native iOS simulator shares the host's network stack, so the application's loopback port is the host's loopback port and no forwarding applies. A physical iPhone doesn't: the port sits on the device's own loopback, reachable only over the USB multiplexing channel, and `iproxy` from libimobiledevice is the usual relay. Codename One ships no goal for it, so an agent-driven session against a real iPhone depends on external tooling in a way the Android one doesn't. + +None of this is a second security boundary. A forward is something you asked for, and the reason the server refuses a release build is that the loopback interface on a device is shared with every other application installed on it -- see <>. + [[development-builds-only]] === Development builds only diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index e29a5fcf918..719b8004276 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -759,3 +759,10 @@ resizer epoll # A benchmark of one narrow operation, as against a whole application. microbenchmark + +# ----------------------------------------------------------------------------- +# Attaching an agent to a build on a device (MCP-Headless-API.asciidoc). +# ----------------------------------------------------------------------------- +# The open-source library whose iproxy tool relays a TCP port to an iPhone over +# USB. A project name, spelled lowercase by its authors. +libimobiledevice diff --git a/maven/cn1app-archetype/pom.xml b/maven/cn1app-archetype/pom.xml index f68297c9adc..c7ee0188195 100644 --- a/maven/cn1app-archetype/pom.xml +++ b/maven/cn1app-archetype/pom.xml @@ -37,15 +37,27 @@ @@ -53,7 +65,7 @@ ${project.basedir}/../../scripts/initializr/common/src/main/resources/skill - archetype-resources/.claude/skills/codename-one + archetype-resources/.agent-skills/codename-one false @@ -78,5 +90,43 @@ + + + + + org.apache.maven.plugins + maven-antrun-plugin + + + stage-agent-skill-pointers + process-resources + + run + + + + + + + + + + + diff --git a/maven/cn1app-archetype/src/main/resources/META-INF/archetype-post-generate.groovy b/maven/cn1app-archetype/src/main/resources/META-INF/archetype-post-generate.groovy index 8f215569329..82ab2e07087 100644 --- a/maven/cn1app-archetype/src/main/resources/META-INF/archetype-post-generate.groovy +++ b/maven/cn1app-archetype/src/main/resources/META-INF/archetype-post-generate.groovy @@ -120,9 +120,13 @@ def pickJavaVersionFromCurrentJvm() { /** * Apply the Java-version-specific transforms that the initializr does on its * server-rendered templates: - * - Java 17 keeps .claude/skills/codename-one/** (the Codename One authoring skill) - * - Java 8 strips .claude/ so older projects don't suddenly grow an AI-agent - * skill they never opted into. + * - Java 17 keeps the Codename One authoring skill in the same three-part + * layout the initializr generates: AGENTS.md (vendor-neutral root pointer), + * .agent-skills/codename-one/** (the skill), and .claude/skills/codename-one/ + * SKILL.md (a thin stub redirecting to it) + * - Java 8 strips all three so older projects don't suddenly grow an AI-agent + * skill they never opted into. The skill's guidance is Java 17 anyway (var, + * records, text blocks, single-file source mode in tools/). * * The win/ module is the native win32 target and ships for every Java version * (only the long-retired UWP module that previously lived under win/ used to be @@ -134,9 +138,11 @@ def pickJavaVersionFromCurrentJvm() { */ def applyJavaVersionTransforms(rootDir, rootPom, resolvedJava) { if (resolvedJava != "17") { - def claudeDir = new java.io.File(rootDir, ".claude") - if (claudeDir.exists()) { - deleteRecursively(claudeDir) + [".claude", ".agent-skills", "AGENTS.md"].each { name -> + def skillPath = new java.io.File(rootDir, name) + if (skillPath.exists()) { + deleteRecursively(skillPath) + } } } setIntellijLanguageLevel(rootDir, resolvedJava) diff --git a/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml index 4249c1353e9..9fb192cc5a0 100644 --- a/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml +++ b/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml @@ -55,14 +55,24 @@ + + .agent-skills + + ** + + .claude @@ -82,6 +92,8 @@ .gitignore + + AGENTS.md *.sh *.bat *.adoc diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index ea44ca07260..5682b5c9aed 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1831,6 +1831,55 @@ private void applyCatalogPlistEntry(BuildRequest request, } } + /** + * Whether an on-device-debug proxy host is the loopback interface, and so needs + * no local-network declaration. + * + * Deliberately a SMALL allow-list rather than a parse: everything it does not + * recognise is treated as the local network, which is the answer that keeps a + * debugging session working. The whole 127/8 block counts, because loopback is + * 127.0.0.1 by convention and not by rule. + */ + static boolean isLoopbackDebugProxyHost(String host) { + if (host == null) { + return false; + } + String trimmed = host.trim(); + // Brackets are how a literal IPv6 address is written in a host position. + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + trimmed = trimmed.substring(1, trimmed.length() - 1).trim(); + } + if (trimmed.equalsIgnoreCase("localhost") + || trimmed.equals("::1") + || trimmed.equals("0:0:0:0:0:0:0:1")) { + return true; + } + if (!trimmed.startsWith("127.")) { + return false; + } + // "127.0.0.1" yes, "127.0.0.1.example.com" no -- a host name may begin with + // digits, and one that merely starts with the right four characters is not + // an address at all. + String[] parts = trimmed.split("\\."); + if (parts.length != 4) { + return false; + } + for (int i = 0; i < parts.length; i++) { + if (parts[i].length() == 0 || parts[i].length() > 3) { + return false; + } + for (int c = 0; c < parts[i].length(); c++) { + if (parts[i].charAt(c) < '0' || parts[i].charAt(c) > '9') { + return false; + } + } + if (Integer.parseInt(parts[i]) > 255) { + return false; + } + } + return true; + } + private int getDeploymentTargetInt(BuildRequest request) { String target = getDeploymentTarget(request); if (target.indexOf(".") > 0) { @@ -16494,6 +16543,36 @@ public boolean accept(File file, String string) { + "NSAllowsArbitraryLoads" + ""; } + // A PHYSICAL device reaches the proxy across the Wi-Fi it shares with + // the developer's machine, and since iOS 14 that is local-network + // access: consent-gated, and gated on a purpose string the app has to + // declare up front. Without one the app is terminated the moment + // cn1_debugger dials out -- before it can connect, so the session fails + // with the proxy still waiting and nothing on the device to explain it. + // + // Only for the LAN case. The native simulator shares the host's + // loopback, which is not the local network, and an unnecessary purpose + // string puts a prompt in front of a developer who never asked for one + // -- the same reason the nearby flags are kept apart from each other. + // + // Ambiguity resolves TOWARDS declaring it: a proxyHost that is not + // recognisably loopback may still be a LAN name rather than an address, + // and the costs are not symmetric. A spare purpose string costs one + // prompt in a build that is debug-only by construction; a missing one + // costs a debugging session that cannot start. + // + // Through applyCatalogPlistEntry rather than putArgument, for the reason + // the Matter block above states: the sweep that copies + // ios.NS*UsageDescription hints into privacyUsageDescriptions ran long + // before this line, the plist is rendered from that map, and a bare + // argument set here would never be read. It fills only a MISSING value, + // so a project that declared its own string keeps it. + if (!isLoopbackDebugProxyHost(proxyHost)) { + applyCatalogPlistEntry(request, new String[] { + "NSLocalNetworkUsageDescription", + "Connects to the Codename One debugging proxy on your computer. " + + "This is a development build."}); + } } // Export compliance: when the app uses com.codename1.security.* we diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderOnDeviceDebugLocalNetworkTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderOnDeviceDebugLocalNetworkTest.java new file mode 100644 index 00000000000..ff419fa7bed --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderOnDeviceDebugLocalNetworkTest.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Deciding whether an on-device-debug proxy host is the local network. +/// +/// The on-device-debug app dials OUT to a proxy on the developer's machine. +/// For the native simulator that is the host's own loopback and no privacy +/// declaration applies; for a physical iPhone it is an address on the Wi-Fi +/// the two share, which since iOS 14 is consent-gated local-network access +/// and terminates an app that reaches it with no purpose string. The build +/// injects `NSLocalNetworkUsageDescription` for the second case and not the +/// first, so this predicate is what decides whether a debugging session on a +/// real device can connect at all. +/// +/// The asymmetry is deliberate and is what the cases below pin: anything not +/// recognisably loopback is treated as the local network. A spare purpose +/// string costs one prompt in a build that is debug-only by construction; a +/// missing one costs a session that cannot start, with the proxy still waiting +/// and nothing on the device to say why. +class IPhoneBuilderOnDeviceDebugLocalNetworkTest { + + @Test + void loopbackNeedsNoLocalNetworkDeclaration() { + assertTrue(IPhoneBuilder.isLoopbackDebugProxyHost("127.0.0.1"), + "the simulator's default proxy host is loopback"); + assertTrue(IPhoneBuilder.isLoopbackDebugProxyHost("localhost"), + "the name for it is loopback too"); + assertTrue(IPhoneBuilder.isLoopbackDebugProxyHost("LOCALHOST"), + "a host name is not case sensitive"); + assertTrue(IPhoneBuilder.isLoopbackDebugProxyHost(" 127.0.0.1 "), + "a hint value carries whatever spacing the properties file had"); + // Loopback is 127.0.0.1 by convention and the whole 127/8 block by rule, + // and a developer who binds a proxy to another address in it is still on + // loopback. + assertTrue(IPhoneBuilder.isLoopbackDebugProxyHost("127.0.0.53"), + "the whole 127/8 block is loopback, not just 127.0.0.1"); + assertTrue(IPhoneBuilder.isLoopbackDebugProxyHost("::1"), + "IPv6 loopback"); + assertTrue(IPhoneBuilder.isLoopbackDebugProxyHost("[::1]"), + "a literal IPv6 address in a host position is bracketed"); + } + + @Test + void aLanAddressIsTheLocalNetwork() { + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("192.168.1.42"), + "the address a physical iPhone has to dial"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("10.0.0.7"), "a LAN address"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("172.16.4.9"), "a LAN address"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("my-laptop.local"), + "a Bonjour name resolves on the local network, which is the point"); + } + + @Test + void onlyAnAddressCountsAsLoopbackNotAnythingSpelledLikeOne() { + // A host NAME may begin with digits, so "starts with 127." is not the + // question -- these all resolve wherever DNS says, which is not loopback, + // and reading them as loopback would withhold the declaration from a real + // device. + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("127.0.0.1.example.com"), + "a name that merely begins with the loopback address is not it"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("127.example.com"), + "nor is a name whose first label is 127"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("127.0.0"), + "a truncated address is not an address"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("127.0.0.256"), + "nor is one whose octet is out of range"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("127.0.0.x"), + "nor is one that is not numeric"); + } + + @Test + void anAbsentHostIsTreatedAsTheLocalNetwork() { + // Never reached through the build, which defaults the hint to 127.0.0.1, + // but the direction matters if it ever is: unknown resolves towards + // declaring, because that is the answer that keeps a session working. + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost(null), + "an unknown host must not be assumed to be loopback"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost(""), + "nor an empty one"); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java index 546a972a5fc..19a20130f5a 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java @@ -117,6 +117,71 @@ void generatedRunConfigurationsUseTheRegisteredGoalPrefix() throws Exception { } } + /** + * A generated Java 17 project must hand an agent the skill through all three + * discovery conventions, and through the same paths the Initializr uses: + * {@code AGENTS.md} at the root, the skill itself under + * {@code .agent-skills/codename-one/}, and a thin redirect stub at + * {@code .claude/skills/codename-one/SKILL.md}. + * + * This module shipped the full skill under {@code .claude/} alone (issue #5699), + * which left an archetype-generated project with no root pointer and no + * vendor-neutral copy -- so Codex and anything else that does not happen to know + * Claude Code's directory layout never found it, while the identical project + * downloaded from the Initializr was fine. + * + * All three come from one source directory, staged into the archetype JAR at build + * time, so there is nothing under archetype-resources/ to read here. What is worth + * pinning is that the wiring still names those paths, and that the two shared files + * are still where the pom expects them. + */ + @Test + void agentSkillIsGeneratedInTheSameLayoutAsTheInitializr() throws Exception { + File initializrResources = + new File("../../scripts/initializr/common/src/main/resources"); + // Stored under a name that is NOT AGENTS.md on purpose: that name is one agents + // look for by themselves, and a file called that inside this repository would be + // read as instructions for the Codename One tree rather than for a generated app. + assertTrue(new File(initializrResources, "agent-skill-agents-md.md").isFile(), + "the archetype pom stages the AGENTS.md body from " + initializrResources); + assertFalse(new File(initializrResources, "AGENTS.md").isFile(), + "the AGENTS.md body must not be stored under the reserved name"); + assertTrue(new File(initializrResources, "agent-skill-claude-stub.md").isFile(), + "the archetype pom stages the Claude stub from " + initializrResources); + assertTrue(new File(initializrResources, "skill/SKILL.md").isFile(), + "the archetype pom stages the skill itself from " + initializrResources); + + String pom = archetypeFile("pom.xml"); + assertTrue(pom.contains("archetype-resources/.agent-skills/codename-one"), + "the skill must be staged under .agent-skills/, not only under .claude/"); + assertTrue(pom.contains("archetype-resources/AGENTS.md"), + "the root AGENTS.md pointer must be staged into the archetype"); + assertTrue(pom.contains("archetype-resources/.claude/skills/codename-one/SKILL.md"), + "the Claude stub must be staged at its single-file path"); + + String metadata = archetypeFile("src/main/resources/META-INF/maven/archetype-metadata.xml"); + for (String declared : new String[] { + ".agent-skills", + ".claude", + "AGENTS.md" }) { + assertTrue(metadata.contains(declared), + "archetype-metadata.xml must extract " + declared); + } + + // Java 8 projects get none of it: the skill's guidance is Java 17 throughout. + String postGenerate = archetypeFile("src/main/resources/META-INF/archetype-post-generate.groovy"); + for (String stripped : new String[] { "\".claude\"", "\".agent-skills\"", "\"AGENTS.md\"" }) { + assertTrue(postGenerate.contains(stripped), + "archetype-post-generate.groovy must strip " + stripped + " for Java 8"); + } + } + + private static String archetypeFile(String path) throws Exception { + File file = new File("../cn1app-archetype", path); + assertTrue(file.isFile(), "Missing archetype file " + file.getAbsolutePath()); + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } + private static String pluginDescriptor() throws Exception { File pom = new File("pom.xml"); assertTrue(pom.isFile(), "expected the plugin pom at " + pom.getAbsolutePath()); diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index 219b2223b27..46d6666b785 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -69,52 +69,24 @@ public class GeneratorModel { private static final String AGENT_SKILL_TARGET_PREFIX = ".agent-skills/codename-one/"; private static final String CLAUDE_SKILL_STUB_PATH = ".claude/skills/codename-one/SKILL.md"; - private static final String CLAUDE_SKILL_STUB_BODY = - "---\n" - + "name: codename-one\n" - + "description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven, ParparVM/Android/iOS/JavaScript). Use when the project contains a `common/codenameone_settings.properties`, depends on `com.codenameone:codenameone-core`, edits CSS files under `common/src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build`, references `com.codename1.ui.*` / `com.codename1.testing.*`, or when the user asks to build a UI, write screen tests, generate screenshots, or compare to Swing/HTML/Android.\n" - + "metadata:\n" - + " type: skill\n" - + "---\n" - + "\n" - + "# Codename One — App and UI Authoring Skill (Claude Code stub)\n" - + "\n" - + "This file exists so Claude Code can index the Codename One authoring skill.\n" - + "The actual skill content is **vendor-neutral** and lives in this repository at:\n" - + "\n" - + "- `.agent-skills/codename-one/SKILL.md` — top-level cheat sheet\n" - + "- `.agent-skills/codename-one/references/*.md` — deep-dive references\n" - + "- `.agent-skills/codename-one/tools/` — runnable Java 17 utilities (`isApiSupported`, `isCssValid`, ...)\n" - + "\n" - + "**Read `.agent-skills/codename-one/SKILL.md` next.** All the guidance you need to\n" - + "build, style, test, debug, and port to Codename One is in that directory.\n"; - private static final String AGENTS_MD_BODY = - "# AGENTS.md\n" - + "\n" - + "This project is a Codename One cross-platform mobile app (Java 17 / Maven /\n" - + "ParparVM-iOS / Android / JavaScript / desktop). A vendor-neutral authoring skill\n" - + "is bundled in this repository for any AI agent:\n" - + "\n" - + "- **Start here:** `.agent-skills/codename-one/SKILL.md`\n" - + "- **Topical references:** `.agent-skills/codename-one/references/`\n" - + "- **Runnable utilities (Java 17 single-file source mode):** `.agent-skills/codename-one/tools/`\n" - + "\n" - + "Tool integrations (Claude Code, Cursor, etc.) may also pick this skill up via\n" - + "their own conventions; the canonical source of truth is `.agent-skills/`.\n" - + "\n" - + "## Quick orientation for an agent\n" - + "\n" - + "- App source lives in `common/src/main/java/`.\n" - + "- Theme/styling lives in `common/src/main/css/theme.css` (Codename One CSS — a\n" - + " deliberate subset, see `.agent-skills/codename-one/references/css.md`).\n" - + "- Run the simulator with `mvn -pl common cn1:run`.\n" - + "- Run tests with `mvn -pl common cn1:test` (on Linux CI use `xvfb-run -a`).\n" - + "- You can drive the RUNNING simulator yourself over MCP (read the screen, type,\n" - + " tap) - see `.agent-skills/codename-one/references/mcp-agent-control.md`.\n" - + "- Native cloud builds use `mvn -pl package -Dcodename1.platform=... -Dcodename1.buildTarget=...`.\n" - + "\n" - + "When in doubt, open `.agent-skills/codename-one/SKILL.md` and follow the\n" - + "reference table at the bottom.\n"; + // The AGENTS.md pointer and the Claude Code stub are FILES rather than string + // constants because maven/cn1app-archetype stages the very same two files into + // archetype-resources/ (see its pom). A project generated from the archetype and one + // downloaded from the Initializr have to hand an agent the same layout, and the only + // way to guarantee that is one copy on disk. + // + // They sit flat at the root of src/main/resources for the same reason skill/ is + // repackaged into skill.zip: Codename One's classloader rejects nested directories + // under src/main/resources at runtime. + // + // Neither is stored under the name it is published as. AGENTS.md is a name agents + // look for on their own, so a file called that here would be read as instructions + // for THIS repository -- and it says things like "run the simulator with + // mvn -pl common cn1:run", which is true of a generated app and false of the + // Codename One tree. The Claude stub already had to be renamed on staging because + // its published name is SKILL.md; this one is renamed for the opposite reason. + private static final String CLAUDE_SKILL_STUB_RESOURCE = "/agent-skill-claude-stub.md"; + private static final String AGENTS_MD_RESOURCE = "/agent-skill-agents-md.md"; private final IDE ide; private final Template template; @@ -433,10 +405,11 @@ private void addAgentSkillEntries(Map mergedEntries) throws IOEx } // Top-level AGENTS.md so agents that follow the (emerging) AGENTS.md convention // discover the skill without having to know our directory layout. - copySingleTextEntryToMap("AGENTS.md", AGENTS_MD_BODY, mergedEntries, ZipEntryType.COMMON); + copySingleTextEntryToMap("AGENTS.md", readResourceToString(AGENTS_MD_RESOURCE), + mergedEntries, ZipEntryType.COMMON); // Claude Code stub. Frontmatter so the skill shows up in /skills, body redirects // to the canonical vendor-neutral content. - copySingleTextEntryToMap(CLAUDE_SKILL_STUB_PATH, CLAUDE_SKILL_STUB_BODY, + copySingleTextEntryToMap(CLAUDE_SKILL_STUB_PATH, readResourceToString(CLAUDE_SKILL_STUB_RESOURCE), mergedEntries, ZipEntryType.COMMON); } diff --git a/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md b/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md new file mode 100644 index 00000000000..b57a6eb581c --- /dev/null +++ b/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md @@ -0,0 +1,29 @@ +# AGENTS.md + +This project is a Codename One cross-platform mobile app (Java 17 / Maven / +ParparVM-iOS / Android / JavaScript / desktop). A vendor-neutral authoring skill +is bundled in this repository for any AI agent: + +- **Start here:** `.agent-skills/codename-one/SKILL.md` +- **Topical references:** `.agent-skills/codename-one/references/` +- **Runnable utilities (Java 17 single-file source mode):** `.agent-skills/codename-one/tools/` + +Tool integrations (Claude Code, Cursor, etc.) may also pick this skill up via +their own conventions; the canonical source of truth is `.agent-skills/`. + +## Quick orientation for an agent + +- App source lives in `common/src/main/java/`. +- Theme/styling lives in `common/src/main/css/theme.css` (Codename One CSS — a + deliberate subset, see `.agent-skills/codename-one/references/css.md`). +- Run the simulator with `mvn -pl common cn1:run`. +- Run tests with `mvn -pl common cn1:test` (on Linux CI use `xvfb-run -a`). +- You can drive the RUNNING simulator yourself over MCP (read the screen, type, + tap) - see `.agent-skills/codename-one/references/mcp-agent-control.md`. +- A bug that only reproduces on an Android phone or an iPhone is still debuggable: + attach a Java debugger to the device build and drive it over MCP the same way; + see `.agent-skills/codename-one/references/on-device-debugging.md`. +- Native cloud builds use `mvn -pl package -Dcodename1.platform=... -Dcodename1.buildTarget=...`. + +When in doubt, open `.agent-skills/codename-one/SKILL.md` and follow the +reference table at the bottom. diff --git a/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md b/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md new file mode 100644 index 00000000000..294692ef1a0 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md @@ -0,0 +1,18 @@ +--- +name: codename-one +description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven, ParparVM/Android/iOS/JavaScript). Use when the project contains a `common/codenameone_settings.properties`, depends on `com.codenameone:codenameone-core`, edits CSS files under `common/src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build`, references `com.codename1.ui.*` / `com.codename1.testing.*`, or when the user asks to build a UI, write screen tests, generate screenshots, or compare to Swing/HTML/Android. +metadata: + type: skill +--- + +# Codename One — App and UI Authoring Skill (Claude Code stub) + +This file exists so Claude Code can index the Codename One authoring skill. +The actual skill content is **vendor-neutral** and lives in this repository at: + +- `.agent-skills/codename-one/SKILL.md` — top-level cheat sheet +- `.agent-skills/codename-one/references/*.md` — deep-dive references +- `.agent-skills/codename-one/tools/` — runnable Java 17 utilities (`isApiSupported`, `isCssValid`, ...) + +**Read `.agent-skills/codename-one/SKILL.md` next.** All the guidance you need to +build, style, test, debug, and port to Codename One is in that directory. diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index 815af7dca98..250541d892c 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -318,6 +318,7 @@ If you cannot run the simulator (e.g. headless environment), **say so explicitly | "Store an LLM API key" / non-prompting SecureStorage | `references/ai-and-speech.md` | | "Build against a Codename One SNAPSHOT from git" | `references/snapshot-builds.md` | | "Debug a faulty screen — attach `jdb` to the simulator" | `references/debugging.md` | +| "It only breaks on the phone" / "attach a debugger to the Android/iOS build" / `android.onDeviceDebug`, `ios.onDeviceDebug` / drive the app on a device over MCP | `references/on-device-debugging.md` | | "Try the flow" / "fill in this form and press submit" / "drive the running app" / MCP | `references/mcp-agent-control.md` | | Quick yes/no check: "is this `java.*` class supported", "does my `theme.css` compile" | `tools/` directory — `java tools/IsApiSupported.java ` / `java tools/IsCssValid.java ` | | "Score this screen against a mockup" / "Import a Figma/Sketch/XD design" | `tools/` directory — `java tools/CompareToMockup.java ` / `java tools/DesignImport.java ` (see `references/mockup-comparison.md`) | diff --git a/scripts/initializr/common/src/main/resources/skill/references/debugging.md b/scripts/initializr/common/src/main/resources/skill/references/debugging.md index 6dd6143ac8b..b65e2bf7616 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/debugging.md +++ b/scripts/initializr/common/src/main/resources/skill/references/debugging.md @@ -157,4 +157,4 @@ For UI-rendering bugs (a label shows the wrong text, a colour is off, a layout h For build / compile errors there's no JVM running yet. Use `mvn -pl common compile -e -X` for verbose Maven diagnostics; consult `references/build-and-run.md` for the common build error patterns. -For native-side bugs on iOS / Android the simulator's JVM doesn't apply at all. Use Xcode's debugger (for `ios-source` builds) or Android Studio attached to the running APK. +For a bug that only reproduces on real hardware, the simulator's JVM doesn't apply at all. You can still attach a **Java** debugger to the build running on the device, and drive it over MCP while you are there — see `references/on-device-debugging.md`. For the native layer below that (Objective-C, NDK C/C++), use Xcode's debugger on an `ios-source` build or Android Studio attached to the running APK; those attaches are independent of the JDWP one and can run beside it. diff --git a/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md b/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md index 4ddef4b5582..4e641d4de0a 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md +++ b/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md @@ -28,6 +28,12 @@ If you are running the app yourself rather than through the menu, the same switc MCP.startSocketServer(8765); ``` +## Attaching to a build on a device + +The socket server is not simulator-only. It binds wherever the platform can bind a loopback port, which includes an app running on an Android phone or an iPhone, so the same five tools drive the real build — semantic identifiers instead of coordinates guessed off a screenshot. The port is on the *device's* loopback rather than yours, so it needs a forward: `adb forward tcp:8765 tcp:8765` on Android, nothing at all on the native iOS simulator (it shares your machine's network stack), and a usbmux relay on a physical iPhone. + +`references/on-device-debugging.md` has the whole sequence for both platforms, alongside the JDWP attach that usually goes with it. + ## What you can call Every server publishes these, with no work from the app: diff --git a/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md b/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md new file mode 100644 index 00000000000..81168f39238 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md @@ -0,0 +1,136 @@ +# Debugging on a Real Device — Android and iOS + +`references/debugging.md` attaches `jdb` to the **simulator**, which is a plain JVM on your own machine. This file is the other case: the bug only shows up in a build running on an Android phone, an iPhone, or one of the native emulators, and you need a Java debugger — and optionally an MCP session — against *that* process. + +**Reach for the simulator first.** It is faster, it has no device-side moving parts, and every debugger feature works there. Come here only when the behaviour genuinely differs on a device: ParparVM threading, a native interface, iOS layout under the modern theme, memory pressure, or timing around UIKit and the Android lifecycle. + +Both platforms need a build made with an on-device-debug build hint, because the flag is baked into the binary. A release build cannot be attached to. + +## Android + +Android's runtime already exposes a JDWP socket per debuggable process, so there is no proxy — the Maven goal just drives `adb`. + +**Prerequisites.** Android platform-tools installed, with `adb` reachable through `ANDROID_HOME`, `ANDROID_SDK_ROOT`, or `PATH`. The device needs USB debugging enabled and the per-machine RSA prompt accepted. + +**1. Turn the hint on** in `common/codenameone_settings.properties`: + +```properties +codename1.arg.android.onDeviceDebug=true +``` + +That flips the generated `AndroidManifest.xml` to `debuggable="true"` and disables R8/proguard so symbols and locals survive. + +**2. Build a debuggable APK**, either through the cloud builder or entirely locally: + +```bash +# Cloud build. Forces the hint for this one build, so you can skip step 1. +mvn cn1:buildAndroidOnDeviceDebug + +# Or fully local: generate the Gradle project and assemble it yourself. +mvn cn1:buildAndroidGradleProject +cd android/target/*-android-source && ./gradlew assembleDebug && cd - +``` + +**3. Install, launch, forward JDWP, and tail logcat** — one goal does all four: + +```bash +mvn cn1:android-on-device-debugging +``` + +It prints the adb it picked, the target device, the app's PID, and a banner confirming `localhost:5005` is forwarded. Logcat, filtered to that PID, then streams through the same terminal prefixed with `[device]`. + +**4. Attach a debugger** from a second terminal (or point your IDE's remote-JVM config at the same port): + +```bash +jdb -attach localhost:5005 \ + -sourcepath common/src/main/java +``` + +By default the goal runs `am set-debug-app -w`, so the app sits paused at startup until you attach. Everything the simulator debugger does works here — this is the real Android runtime, not a proxy. + +**Flags worth knowing:** + +| Flag | Use it when | +| --- | --- | +| `-Dcn1.android.onDeviceDebug.deviceSerial=` | More than one device is online (`adb devices` lists serials). | +| `-Dcn1.android.onDeviceDebug.wireless=` | Run `adb connect ` first — covers both the Android 11+ *Wireless debugging* pairing flow and the older `adb tcpip` one. | +| `-Dcn1.android.onDeviceDebug.apk=` | Skip APK autodetection and install this file. | +| `-Dcn1.android.onDeviceDebug.jdwpPort=` | Something else already holds 5005. | +| `-Dcn1.android.onDeviceDebug.skipInstall=true` | The build is already on the device. | +| `-Dcn1.android.onDeviceDebug.waitForAttach=false` | You want the app to boot immediately instead of blocking for a debugger — the right choice when what you actually want is the MCP session below, not a breakpoint. | + +## iOS + +iOS has no JDWP socket of its own, so Codename One adds a listener thread to the ParparVM-generated binary and a desktop proxy speaks JDWP on the IDE's behalf. **The app dials out to the proxy**, which is why the build has to know where your machine is. + +**1. Turn the hints on** in `common/codenameone_settings.properties`: + +```properties +codename1.arg.ios.onDeviceDebug=true +codename1.arg.ios.onDeviceDebug.proxyHost=127.0.0.1 +codename1.arg.ios.onDeviceDebug.proxyPort=55333 +# Optional: hold the app at startup until the debugger attaches. +codename1.arg.ios.onDeviceDebug.waitForAttach=true +``` + +**`waitForAttach=true` blocks an MCP-only session.** It does not merely show a "waiting for debugger" overlay: the app delegate defers the VM callback that boots Codename One until the proxy reports an IDE attached, so `start()` never runs and a `MCP.startSocketServer` call inside it never fires. Set it to `false` whenever the point of the session is driving the app rather than a breakpoint. The Android goal has the same trap and the same answer (`waitForAttach=false`). + +`proxyHost` stays `127.0.0.1` for the **native iOS simulator**, which shares your machine's loopback. For a **physical iPhone** it must be your machine's LAN address (`ifconfig` / `ipconfig`), reachable from the phone's Wi-Fi network — the phone opens the connection, so a host that only answers on loopback will never be reached. + +A LAN address means the app is reaching the **local network**, which iOS 14 gates behind a consent prompt. The build declares the purpose string for you (it injects `NSLocalNetworkUsageDescription` whenever the proxy host is not loopback), but the prompt still has to be **accepted on the device** the first time the app launches, or it never reaches the proxy. If the proxy sits there reporting nothing connected, check the phone for a dialog before you check anything else. The simulator never asks, because loopback is not the local network. + +**2. Build:** + +```bash +# Cloud build for a physical device. Forces the hint for this one build. +mvn cn1:buildIosOnDeviceDebug + +# Or generate a local Xcode project (macOS with Xcode only) and run it from there. +mvn cn1:buildIosXcodeProject +``` + +**3. Start the proxy** and leave it running: + +```bash +mvn cn1:ios-on-device-debugging +``` + +**4. Launch the app**, then wait for the proxy to print that the device connected and the symbols loaded. The binary carries its own compressed symbol table and streams it over on connect, so there is no sidecar file to find. + +**5. Attach** once the handshake lines appear: + +```bash +jdb -attach localhost:8000 \ + -sourcepath common/src/main/java +``` + +**Two ports, and mixing them up is the usual mistake:** the *app* dials the proxy on **55333**; the *debugger* attaches to the proxy on **8000**. Override them with `-Dcn1.onDeviceDebug.devicePort` and `-Dcn1.onDeviceDebug.jdwpPort` if either is taken. + +**iOS limits you will hit.** Breakpoints, stepping, stack walking, locals, instance fields, arrays, threads, and method invocation on framework and user classes all work. These do not: constructor invocation (`new Foo(...)`), hot-swap, and static field reads. Method invocation is also skipped for `java.io.*`, `java.net.*`, `java.nio.*`, and `com.codename1.impl.*`. If an expression silently refuses to evaluate, that list is usually why — report it rather than concluding the value is wrong. + +## Driving the app on the device over MCP + +The MCP server from `references/mcp-agent-control.md` is not simulator-only: it binds a loopback port anywhere the platform can bind one, which includes a build running on a device. That gives you `ui_snapshot` / `ui_find` / `ui_activate` / `ui_set_text` against the real thing — stable semantic identifiers instead of guessed screen coordinates. + +Start it from your own code, gated so it cannot reach a shipped build: + +```java +// In MyAppName.start() +if (Display.getInstance().isDebuggableBuild()) { + MCP.startSocketServer(8765); +} +``` + +The port is on the **device's** loopback, not yours, so it needs a forward: + +- **Android device or emulator** — `adb forward tcp:8765 tcp:8765`, then connect to `127.0.0.1:8765` on your machine. Tear it down with `adb forward --remove tcp:8765` when you are done. With more than one device online `adb` refuses both commands rather than guessing, so pass the same serial the debug goal used: `adb -s forward tcp:8765 tcp:8765` and `adb -s forward --remove tcp:8765`. +- **Native iOS simulator** — it shares your machine's network stack, so `127.0.0.1:8765` already *is* the app's port. Nothing to forward. +- **Physical iPhone** — the port is on the phone's own loopback and there is no Codename One goal that tunnels it. The route is a usbmux TCP relay such as `iproxy 8765 8765` from libimobiledevice. If you do not have that tooling, use the iOS simulator for the MCP loop and keep the physical device for the JDWP session above. + +**Set `waitForAttach=false` on both platforms.** It is a convenience on Android and a requirement on iOS, where the VM callback that boots the app is itself deferred until a debugger attaches, so with it left on, the starter above never runs and nothing is listening to forward to. + +**Security, unchanged from the simulator case:** loopback is not authentication. Everything on the device can reach the port, which is exactly why `startSocketServer` refuses on a release build. Do not lift that gate to make a device session work, do not leave the starter in shipping code, and remove the forward when the session ends. + +## Say what you could not check + +Both flows need hardware, an SDK, and — on iOS — a machine on the same network as the phone. If you do not have the device, the platform tools, or a build with the hint, say so plainly instead of reporting that the behaviour was verified. diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java index 8018bb57811..641d0c0d2bc 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java @@ -170,6 +170,7 @@ private void validateClaudeSkillBundled() throws Exception { ".agent-skills/codename-one/references/snapshot-builds.md", ".agent-skills/codename-one/references/debugging.md", ".agent-skills/codename-one/references/mcp-agent-control.md", + ".agent-skills/codename-one/references/on-device-debugging.md", ".agent-skills/codename-one/references/ai-and-speech.md", ".agent-skills/codename-one/tools/README.md", ".agent-skills/codename-one/tools/IsApiSupported.java", @@ -200,6 +201,10 @@ private void validateClaudeSkillBundled() throws Exception { // screenshots, so the pointer to the MCP loop belongs in the root file too. assertContains(agentsMd, "references/mcp-agent-control.md", "AGENTS.md should point agents at the MCP control loop"); + // Same reasoning for the device loops: an agent that never learns the Android/iOS + // build is attachable will give up at "cannot reproduce in the simulator". + assertContains(agentsMd, "references/on-device-debugging.md", + "AGENTS.md should point agents at the on-device debug/MCP loops"); String claudeStub = getText(entries, ".claude/skills/codename-one/SKILL.md"); assertContains(claudeStub, "name: codename-one", "Claude stub must keep the skill frontmatter"); diff --git a/vm/backend/impl/parparvm/com/codename1/backend/Reactor.java b/vm/backend/impl/parparvm/com/codename1/backend/Reactor.java index 30ffe282ffa..b0f5ca7d428 100644 --- a/vm/backend/impl/parparvm/com/codename1/backend/Reactor.java +++ b/vm/backend/impl/parparvm/com/codename1/backend/Reactor.java @@ -62,14 +62,16 @@ public static Reactor create() throws IOException { } public void add(int fd, int events) throws IOException { - if(registerImpl(poller, fd, events, false) != 0) { - throw new IOException("Could not watch fd " + fd); + int err = registerImpl(poller, fd, events, false); + if(err != 0) { + throw new IOException("Could not watch fd " + fd + " (errno " + (-err) + ")"); } } public void modify(int fd, int events) throws IOException { - if(registerImpl(poller, fd, events, true) != 0) { - throw new IOException("Could not re-arm fd " + fd); + int err = registerImpl(poller, fd, events, true); + if(err != 0) { + throw new IOException("Could not re-arm fd " + fd + " (errno " + (-err) + ")"); } } diff --git a/vm/backend/native/cn1_backend_server.c b/vm/backend/native/cn1_backend_server.c index 33935982e15..a9cd90df106 100644 --- a/vm/backend/native/cn1_backend_server.c +++ b/vm/backend/native/cn1_backend_server.c @@ -814,7 +814,45 @@ JAVA_INT com_codename1_backend_Reactor_registerImpl___int_int_int_boolean_R_int( // the DEL + ADD that path pays, and it costs no cross-thread wake. ev.events |= EPOLLONESHOT; } - return epoll_ctl(poller, modify ? EPOLL_CTL_MOD : EPOLL_CTL_ADD, fd, &ev) == 0 ? 0 : -1; + /* + * ADD and MOD both mean "this descriptor should now be watched for these + * events", and which one is correct depends on whether the kernel already + * holds it -- which the caller tracks in a flag of its own, per host, from + * several threads. Getting that wrong is not a wrong flag: epoll answers + * EEXIST for an ADD it already has and ENOENT for a MOD it does not, the + * caller sees an IOException, and it drops a connection it has already read + * a request from WITHOUT writing a response. That is an empty reply to a + * valid request, and it is what BackendHttpIntegrationTest kept catching + * intermittently on Linux. + * + * Only on Linux, and that is the tell. The kqueue branch below has always + * been idempotent -- EV_ADD on a knote that exists updates it rather than + * refusing -- so the same mistaken flag costs nothing on macOS and the + * development loop never saw any of this. The asymmetry was the bug: the + * caller's flag is an optimisation, saving a syscall on the common path, and + * only epoll was treating it as a precondition. + * + * So each falls back to the other, and both platforms now mean the same + * thing by this call. The flag still keeps the fast path fast; it just no + * longer decides correctness. + */ + if(epoll_ctl(poller, modify ? EPOLL_CTL_MOD : EPOLL_CTL_ADD, fd, &ev) == 0) { + return 0; + } + if(!modify && errno == EEXIST) { + if(epoll_ctl(poller, EPOLL_CTL_MOD, fd, &ev) == 0) { + return 0; + } + } else if(modify && errno == ENOENT) { + if(epoll_ctl(poller, EPOLL_CTL_ADD, fd, &ev) == 0) { + return 0; + } + } + /* -errno rather than -1: what is left is a real failure, and EBADF (closed + under us) and EPERM (not pollable) ask for different answers from a reader + of the log. A bare -1 could not tell them apart, and a CI failure saying + only "could not watch fd 20" cost a round to classify. */ + return errno > 0 ? -errno : -1; #elif defined(CN1_HAVE_KQUEUE) struct kevent ev[2]; int n = 0;