diff --git a/.github/workflows/release-on-maven-central.yml b/.github/workflows/release-on-maven-central.yml index 9020193ab0e..2259751af64 100644 --- a/.github/workflows/release-on-maven-central.yml +++ b/.github/workflows/release-on-maven-central.yml @@ -201,3 +201,38 @@ jobs: sleep 20 done echo "codenameone-certificatewizard ${GITHUB_REF_NAME} not visible on repo1 yet; it may still be propagating from Sonatype Central." + + # --- Settings editor ---------------------------------------------------- + # The cn1:settings goal resolves the standalone Java-17 Settings editor + # and its runtime dependencies from Maven Central. + - name: Deploy Settings editor to Maven Central + if: steps.deploy.outcome == 'success' || steps.confirm.outcome == 'success' + run: | + export GPG_TTY=$(tty) + cd scripts/settings + xvfb-run -a mvn -Pexecutable-jar -Psettings-central deploy \ + -Dcodename1.platform=javase \ + -Dgpg.passphrase=$MAVEN_GPG_PASSPHRASE \ + -Dcn1.version=$GITHUB_REF_NAME -Dcn1.plugin.version=$GITHUB_REF_NAME \ + -Dmaven.test.skip=true + env: + MAVEN_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + + - name: Confirm Settings editor on Maven Central + if: steps.deploy.outcome == 'success' || steps.confirm.outcome == 'success' + continue-on-error: true + run: | + set +e + url="https://repo1.maven.org/maven2/com/codenameone/codenameone-settings/${GITHUB_REF_NAME}/codenameone-settings-${GITHUB_REF_NAME}.jar" + for i in $(seq 1 15); do + code=$(curl -s -o /dev/null -w "%{http_code}" "$url") + if [ "$code" = "200" ]; then + echo "Confirmed codenameone-settings ${GITHUB_REF_NAME} on Maven Central" + exit 0 + fi + echo "[$i/15] Waiting on Maven Central for codenameone-settings (code=$code)" + sleep 20 + done + echo "codenameone-settings ${GITHUB_REF_NAME} not visible on repo1 yet; it may still be propagating from Sonatype Central." diff --git a/docs/demos/common/src/main/snippets/developer-guide/maven-appendix-control-center.sh b/docs/demos/common/src/main/snippets/developer-guide/maven-appendix-control-center.sh index 49950227d7a..9c1221f6435 100644 --- a/docs/demos/common/src/main/snippets/developer-guide/maven-appendix-control-center.sh +++ b/docs/demos/common/src/main/snippets/developer-guide/maven-appendix-control-center.sh @@ -1,5 +1,5 @@ // Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. // tag::maven-appendix-control-center-bash-001[] -./run.sh settings +mvn cn1:settings // end::maven-appendix-control-center-bash-001[] diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index bc354eae1e7..83911ff6974 100644 --- a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc +++ b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc @@ -6,10 +6,15 @@ When you send a build to the server, you can provide more parameters. The server These hints are often called "build hints" or "build arguments." They behave like compiler flags that tune the build server's behavior. That makes them useful for fast iteration on new functionality without rebuilding plugin UI for every change. They're also useful when you need to expose low-level behavior such as customizing the Android manifest XML or the iOS plist. -You can set these hints by right-clicking the project in the IDE and selecting #Codename One# -> #Codename One Settings# -> #Build Hints#. The hints use the `key=value` style. +Launch Codename One Settings from the Maven project root, then select *Hints* in the left navigation rail. The hints use the `key=value` style. + +[source,shell] +---- +include::../demos/common/src/main/snippets/developer-guide/maven-appendix-control-center.sh[tag=maven-appendix-control-center-bash-001,indent=0] +---- .The build hints UI in Codename One Settings -image::img/build-hints-codenameone-settings.png[The build hints UI in Codename One Settings,scaledwidth=50%] +image::img/codename-one-settings-build-hints.png[The build hints UI in Codename One Settings,scaledwidth=85%] You can also set the build hints directly in the `codenameone_settings.properties` file. When you do that, each setting must start with the `codename1.arg.` prefix. For example, `android.debug=true` becomes `codename1.arg.android.debug=true`. @@ -2900,11 +2905,11 @@ The https://github.com/codenameone/UpdateCodenameOne[Update Framework] solves ma - Skins update automatically - this is hugely important. When you change a theme you need to update it in the skins and if you don't update the skin you might see a difference between the simulator and the device -- Update of settings/designer without IDE plugin update - The IDE plugin update process is slow and tedious. This way you can push out a bug fix for the GUI builder without going through the process of releasing a new plugin version +- Update of designer tools without an IDE plugin update - The IDE plugin update process is slow and tedious. This way a GUI builder fix can ship without a new IDE plugin release. The standalone Settings artifact is versioned with the Maven plugin instead. This framework should be seamless. You should no longer see the "downloading" message whenever you push an update after your build client is updated. Your system would poll for a new version daily and update when new updates are available. -You can also use the usual method of #Codename One Settings# -> #Basic# -> #Update Client Libs# which will force an update check. Notice that the UI will look a bit different after this update. +To force an update check, run `mvn cn1:update` from the project root. ==== How does it work diff --git a/docs/developer-guide/Index.asciidoc b/docs/developer-guide/Index.asciidoc index 02d6f345200..f14d35899ab 100644 --- a/docs/developer-guide/Index.asciidoc +++ b/docs/developer-guide/Index.asciidoc @@ -353,17 +353,17 @@ That's it. You should now have a general sense of the code. It's time to run on ==== Building and deploying on devices -.Codename One Control Center -image::img/control-center-main.png[Codename One Settings/Control Center,scaledwidth=50%] +.Codename One Settings +image::img/codename-one-settings-basic.png[Codename One Settings,scaledwidth=85%] -You can use the Control Center to configure almost anything. Specifically, the application title, application version, application icon etc. Are all found in the Codename One Settings maven target. +Use the standalone Codename One Settings editor to configure the application title, version, package, icon, build hints, and extensions. Launch it from the Maven project root: -There are many options within this UI that control almost every aspect of the application from signing to basic settings. - -Your device builds using the Codename One Cloud can also be found right here as well as subscription information. +[source,shell] +---- +include::../demos/common/src/main/snippets/developer-guide/maven-appendix-control-center.sh[tag=maven-appendix-control-center-bash-001,indent=0] +---- -.Device Builds in Logged out State -image::img/control-center-builds-empty.png[Builds in Empty State,scaledwidth=50%] +Use the <> for signing assets. Monitor Codename One Cloud builds and subscription information on the Codename One website. ===== Signing/Certificates diff --git a/docs/developer-guide/Maven-Appendix-API.adoc b/docs/developer-guide/Maven-Appendix-API.adoc index 36b521c7b2d..69328d953c9 100644 --- a/docs/developer-guide/Maven-Appendix-API.adoc +++ b/docs/developer-guide/Maven-Appendix-API.adoc @@ -25,8 +25,7 @@ Codename One apps support the https://kotlinlang.org/api/latest/jvm/stdlib/[Kotl Add-on libraries can be added to your library in the common/pom.xml file, but, if you use APIs that aren't supported by Codename One (for example, which use reflection), then your app will fail to build. -Codename One supports its own library format (cn1lib) which sort of "certifies" that it's compatible with Codename One. You can browse the growing catalog of available cn1libs inside <>. +Codename One supports its own library format (cn1lib) which sort of "certifies" that it's compatible with Codename One. Run `mvn cn1:settings` and open *Extensions* to browse the growing catalog of available cn1libs. See <>. For more information about cn1libs, see https://www.codenameone.com/developer-guide.html#_libraries_cn1lib[the cn1libs section] of the developer guide. - diff --git a/docs/developer-guide/Maven-Appendix-Control-Center.adoc b/docs/developer-guide/Maven-Appendix-Control-Center.adoc index 3f291a3df9f..e2da041d6ea 100644 --- a/docs/developer-guide/Maven-Appendix-Control-Center.adoc +++ b/docs/developer-guide/Maven-Appendix-Control-Center.adoc @@ -1,43 +1,35 @@ [appendix] [#settings] -== Codename One settings +== Codename One Settings -The Codename One Settings app (aka Codename One Preferences, aka Control Center) allows you to configure many aspects of your application. This is where you can generate certificates, browse/install add-ons, monitor the status of your cloud builds, configure build hints, and more. +Codename One Settings is a standalone editor for the current Maven project. Use it to edit the application's basic properties, manage build hints, and browse, install, update, or uninstall extensions. Signing assets are managed by the separate <>, and cloud builds are monitored on the Codename One website. -=== Opening Codename One settings +=== Opening Codename One Settings -==== Opening Codename One settings from command-line +Run the following command from the root directory of a Codename One Maven project: -Use the `run.sh` (or run.bat, if on Windows) to open Codename One settings: - -[source,bash] +[source,shell] ---- include::../demos/common/src/main/snippets/developer-guide/maven-appendix-control-center.sh[tag=maven-appendix-control-center-bash-001,indent=0] ---- -==== Opening Codename One settings from IntelliJ - -Click on the "Configuration" menu in the upper right of the toolbar, and select "Tools" > "Codename One Settings" as shown below. - -image::img/intellij-open-settings.png[] - +Maven downloads the Settings artifact that matches the Codename One Maven plugin version and opens it as a desktop application bound to the current project. The same command works in a terminal or an IDE's Maven runner. -==== Opening Codename One settings from NetBeans +=== Basic settings -Right-click on the project in the project inspector, and select "Maven" > "Open Control Center" as shown below: +The *Basic* view edits the application title, description, version, vendor, package name, main class, icon, versioned-build selection, and include-source option. Changes are written to `common/codenameone_settings.properties` when you click *Save*. -image::img/netbeans-open-control-center.png[] +image::img/codename-one-settings-basic.png[Codename One Settings Basic view,scaledwidth=85%] -==== Opening Codename One settings from Eclipse +=== Build hints -Press the image:img/eclipse-run-as-button.png[] button, and select the "_Your project_ Settings" option (where _Your project_ is the name of your project). For example: +The *Build Hints* view searches the build-hint catalog from this guide. It provides a control appropriate to each known hint and also accepts arbitrary key/value hints. The editor adds and removes the `codename1.arg.` prefix when reading and writing `codenameone_settings.properties`. -image:img/eclipse-open-settings.png[] +image::img/codename-one-settings-build-hints.png[Codename One Settings Build Hints view,scaledwidth=85%] -[#dashboard] -=== The dashboard +=== Extensions -Once inside Codename One Settings, you'll see a dashboard like the following: +The *Extensions* view searches the Codename One library catalog. Depending on how an extension is distributed, installation either adds a Maven dependency to `common/pom.xml` or installs a legacy cn1lib. Click the action for an installed extension to uninstall it. Settings displays a compatibility warning before installing a catalog entry known to target an older Codename One release. -image::img/control-center-dashboard.png[] +image::img/codename-one-settings-extensions.png[Codename One Settings Extensions view,scaledwidth=85%] diff --git a/docs/developer-guide/Maven-Getting-Started.adoc b/docs/developer-guide/Maven-Getting-Started.adoc index 57a66502522..8a93fd102d5 100644 --- a/docs/developer-guide/Maven-Getting-Started.adoc +++ b/docs/developer-guide/Maven-Getting-Started.adoc @@ -232,7 +232,7 @@ The "common" module is where all your Codename One application resides. It house When adding dependencies into your app, you'll therefore almost always place them inside the pom.xml file for the "common" module. -TIP: You can add dependencies without needing to change XML configuration files using the Control Center. See <>. +TIP: You can add dependencies without editing XML by launching Codename One Settings with `mvn cn1:settings`. See <>. .When to use the "other" pom.xml Files [sidebar] @@ -290,7 +290,7 @@ This is a special marker that's used by some Codename One tooling to help it loc You can paste any Maven dependency snippet you like into your project, but libraries that haven't been specifically developed for Codename One might not be compatible. See <>. If you're unsure whether a library is compatible, you could add the dependency and try to use it in your app. If it isn't compatible, it will fail when you try to build the app, during the <>. -The easiest way to find compatible libraries is to use the <>. Libraries listed in this section have been build specifically for Codename One and are guaranteed to be compatible. +The easiest way to find Codename One libraries is to use the <>. These libraries were built specifically for Codename One. Settings warns before installing catalog entries known to target older Codename One releases. [#compliance-check] @@ -301,43 +301,38 @@ All application code in the common module of your Codename One project must be c If the compliance check fails (that is, the app uses unsupported APIs), the build will fail. The error log should provide some clues about where the offending code resides. [#managing-addons-in-control-center] -==== Managing Add-Ons in control center +==== Managing add-ons in Codename One Settings -As mentioned throughout this guide, the best place to find and install add-ons for your project is in the Codename One Control Center (also known as Codename One Preferences or Codename One Settings). See <>. +Launch Codename One Settings from the project root, then select *Extensions* in the left navigation rail: -From the dashboard, select "Advanced Settings" > "Extensions" in the navigation menu on the left as shown below: - -image::img/image-2021-03-08-06-57-26-835.png[Control center navigation menu] - -This will bring up a list of available Codename One extensions as shown below: +[source,shell] +---- +include::../demos/common/src/main/snippets/developer-guide/maven-appendix-control-center.sh[tag=maven-appendix-control-center-bash-001,indent=0] +---- -image::img/control-center-extensions.png[Control center extensions page] +image::img/codename-one-settings-extensions.png[Codename One Settings Extensions view,scaledwidth=85%] As an example, install the "Google Maps" library. -Type in "Maps" into the search box, and it should narrow the options down to three libraries as shown below: +Type `Maps` in the search field and locate `Codename One Google Native`, the Google Maps library. -image:img/control-center-extensions-search-maps.png[Control Center Extensions filtered on maps] +Click *Install* or *Download*, depending on how the catalog entry is distributed. -The one in the middle `Codename One Google Native`, is the Google Maps lib that you want. +Settings shows progress while it installs the extension. For an installed extension, click the same action area and confirm *Uninstall* to remove it. -Press the "Download" button. - -You should see a progress indicator while performs the installation. - -.How Control Center Handles Maven Dependencies +.How Codename One Settings handles Maven dependencies [sidebar] **** -Many of the extensions listed in the control center are deployed as cn1lib bundles. Others are deployed on Maven central and *could* be installed by adding a snippet into the pom.xml file (as described in <>). +Many extensions listed in Settings are deployed as cn1lib bundles. Others are deployed on Maven Central and *could* be installed by adding a snippet to `pom.xml` (as described in <>). -The control center UI shields you from the details of how it installs the extensions into your app. For extensions that are deployed on Maven central, it will add the Maven dependency for the library directly into your project's common/pom.xml file. For extensions that are distributed as cn1lib bundles, it uses the `install-cn1lib` Maven goal to install it into your project. +Settings shields you from the details of how it installs extensions. For extensions deployed on Maven Central, it adds the dependency directly to your project's `common/pom.xml`. For extensions distributed as cn1lib bundles, it uses the `install-cn1lib` Maven goal. You shouldn't need to worry about this, as it happens seamlessly. If you're curious, you can look at the `` section of your common/pom.xml file to see the added `` tag after you install an extension. **** ==== Installing legacy cn1libs -The recommended approach for installing add-ons to your project is to use the <>, or by <>. But, in some situations you may not be able to use those methods. For example, If you have a legacy cnlib file that you need to use in your app, and it isn't available on Maven central or the control center. +The recommended approach for installing add-ons is to use <> or <>. In some situations neither method is available. For example, you might have a legacy cn1lib file that isn't listed in the extension catalog or deployed to Maven Central. In cases like this you can use the `install-cn1lib` Maven goal to install it as follows: @@ -345,4 +340,3 @@ In cases like this you can use the `install-cn1lib` Maven goal to install it as ---- include::../demos/common/src/main/snippets/developer-guide/maven-getting-started.sh[tag=maven-getting-started-bash-007,indent=0] ---- - diff --git a/docs/developer-guide/On-Device-Debugging.asciidoc b/docs/developer-guide/On-Device-Debugging.asciidoc index 4373fbf7953..922f8d13c25 100644 --- a/docs/developer-guide/On-Device-Debugging.asciidoc +++ b/docs/developer-guide/On-Device-Debugging.asciidoc @@ -248,8 +248,9 @@ project directly: * *Local build*: `mvn cn1:buildIosXcodeProject` writes the complete Xcode project under `target/...-ios-source/`. -* *Cloud build*: check the *Include Source* flag in Codename One Settings before - sending the build, and download the sources result from the build server. +* *Cloud build*: run `mvn cn1:settings`, enable *Include Source* on the *Basic* + page, save, then send the build and download the sources result from the build + server. Open the `.xcworkspace` file if one was generated, otherwise the `.xcodeproj`, and run on a device or the native iOS simulator from Xcode as you would any iOS diff --git a/docs/developer-guide/Working-with-Mac-OS-X.asciidoc b/docs/developer-guide/Working-with-Mac-OS-X.asciidoc index 491953deba5..d7995cd3edd 100644 --- a/docs/developer-guide/Working-with-Mac-OS-X.asciidoc +++ b/docs/developer-guide/Working-with-Mac-OS-X.asciidoc @@ -2,17 +2,17 @@ === macOS Desktop Build Options -You can configure Desktop macOS build settings, by opening Codename One Settings, and clicking the "Mac Desktop Settings" button: +Launch Codename One Settings from the Maven project root and open *Hints* to configure `desktop.mac.*` build hints such as entitlements and `Info.plist` entries: -.Mac Desktop settings -image::img/mac-desktop-settings-button.png[Mac Desktop settings,scaledwidth=30%] - -This will bring you to the following form: +[source,shell] +---- +include::../demos/common/src/main/snippets/developer-guide/maven-appendix-control-center.sh[tag=maven-appendix-control-center-bash-001,indent=0] +---- -.Mac Desktop settings form -image::img/mac-desktop-settings-form.png[Mac Desktop settings form,scaledwidth=30%] +.Desktop macOS build hints +image::img/codename-one-settings-build-hints.png[Desktop macOS build hints in Codename One Settings,scaledwidth=85%] -Here you can provide your certificate(s) as a `.p12` file, and select a bundle type. +Desktop signing values that aren't build hints are top-level entries in `common/codenameone_settings.properties`: `codename1.desktop.mac.certificate` contains the `.p12` path and `codename1.desktop.mac.certificatePassword` contains its password. The Maven desktop macOS build reads these properties together with the `desktop.mac.*` hints. ==== Bundle Types @@ -30,7 +30,7 @@ For the purposes of Mac application distribution, there are 3 types of certifica . **Developer ID Application Certificate (Mac applications)** + -This type of certificate is used to sign an app to be distributed *outside* of the Mac App Store as a DMG image. This corresponds to the "DMG" bundle type in Codename one settings. You can identify this kind of certificate because its identity will be of the form "Developer ID Application: YOUR COMPANY NAME (SOMECODE)." For example, Developer ID Application: Acme Widgets Corp. (XYSD5YF). +This type of certificate is used to sign an app to be distributed *outside* of the Mac App Store as a DMG image. This corresponds to the "DMG" bundle type. You can identify this kind of certificate because its identity will be of the form "Developer ID Application: YOUR COMPANY NAME (SOMECODE)." For example, Developer ID Application: Acme Widgets Corp. (XYSD5YF). . **Mac App Distribution Certificate (Mac App Store)** + This type of certificate is used to sign the.app bundle for an app that's to be distributed in the Mac App Store. This certificate is required for both the "Sandboxed DMG," and "Mac App Store Upload (PKG)" bundle types. You can identify this kind of certificate because its identity will be of the form "3rd Party Mac Developer Application: YOUR COMPANY NAME (SOMECODE)." For example, 3rd Party Mac Developer Application: Acme Widgets Corp. (XYSD5YF). @@ -73,7 +73,7 @@ After generating the certificates, you should download them to your Mac, and imp NOTE: The following section requires access to a Mac, and assumes that you've already generated your 3 certificates -Notice that Mac apps may require three different kinds of certificates, yet the settings page provides space for a single certificate (P12) file. This isn't a mistake. P12 files may contain more than one certificate, and you're expected to include all the certificates that the build server may require inside a single P12. The build server will automatically extract the certificates it needs according to the bundle type. +Notice that Mac apps may require three different kinds of certificates, yet `codename1.desktop.mac.certificate` accepts a single P12 file. This isn't a mistake. P12 files may contain more than one certificate, and you're expected to include all the certificates that the build server may require inside a single P12. The build server automatically extracts the certificates it needs according to the bundle type. When building the "DMG" bundle type, the build server will look for a "Developer ID Application Certificate" inside the P12. If one is found, it will be used to sign the app bundle. @@ -90,10 +90,7 @@ You will then be prompted to select a location to save the `.p12` file, as well ==== Entitlements -When distributing apps in the Mac App Store, or when using the "Sandboxed DMG" bundle type, your app is run inside a sandboxed environment, meaning that it doesn't have access to the outside world. It's provided its own "sandboxed" container for file system access, and it doesn't get any network access. If your app requires access to the "outside world," you need to request entitlements for that access. If you select a bundle type that uses the sandbox, you will be shown a list of all the available entitlements from which you can "check" the ones that you wish to include. - -.App sandbox entitlements -image::img/mac-desktop-entitlements.png[App sandbox entitlements,scaledwidth=30%] +When distributing apps in the Mac App Store, or when using the "Sandboxed DMG" bundle type, your app is run inside a sandboxed environment, meaning that it doesn't have access to the outside world. It's provided its own "sandboxed" container for file system access, and it doesn't get any network access. If your app requires access to the "outside world," request the required entitlements with `desktop.mac.entitlement.*` entries in the *Hints* view. For more information about the app sandbox, and a full list of entitlements, see https://developer.apple.com/library/content/documentation/Miscellaneous/Reference/EntitlementKeyReference/Chapters/EnablingAppSandbox.html[Apple's documentation on the subject]. @@ -192,4 +189,3 @@ The Mac Native build emits a `.entitlements` plist (or `-A | App Store | Sandboxed, hardened runtime off. Signed with _Apple Distribution_; uploaded via App Store Connect. | Developer ID | Hardened runtime on, sandbox off. Signed with _Developer ID Application_; notarized before distribution. |=== - diff --git a/docs/developer-guide/appendix_goal_settings.adoc b/docs/developer-guide/appendix_goal_settings.adoc index bb173c2ebd3..209ae4cc718 100644 --- a/docs/developer-guide/appendix_goal_settings.adoc +++ b/docs/developer-guide/appendix_goal_settings.adoc @@ -1,5 +1,10 @@ === Open settings (`settings`) -Opens the Control Center (aka Codename One Settings; aka Codename One Preferences). +Downloads and opens the standalone Codename One Settings editor bound to the current Maven project. The editor manages basic project properties, build hints, and extensions. -See <> \ No newline at end of file +[source,shell] +---- +include::../demos/common/src/main/snippets/developer-guide/maven-appendix-control-center.sh[tag=maven-appendix-control-center-bash-001,indent=0] +---- + +See <> diff --git a/docs/developer-guide/appendix_goal_update.adoc b/docs/developer-guide/appendix_goal_update.adoc index 72fbab5ba54..21e4b3a5da3 100644 --- a/docs/developer-guide/appendix_goal_update.adoc +++ b/docs/developer-guide/appendix_goal_update.adoc @@ -1,7 +1,7 @@ [#update-goal] === Update Codename One (`update`) -Updates Codename One. This will update the Codename One tools that live inside `$HOME/.codenameone` such as the GUI builder and Control Center. This will also try to update the `cn1.version` and `cn1.plugin.version` properties in your project's pom.xml file. +Updates Codename One. This updates tools that live inside `$HOME/.codenameone`, such as the GUI builder, and tries to update the `cn1.version` and `cn1.plugin.version` properties in the project's `pom.xml`. The standalone Settings editor is resolved from Maven using `cn1.plugin.version` when you run `mvn cn1:settings`. ==== Usage example diff --git a/docs/developer-guide/css.asciidoc b/docs/developer-guide/css.asciidoc index d7edcebb49d..4edb67ed2af 100644 --- a/docs/developer-guide/css.asciidoc +++ b/docs/developer-guide/css.asciidoc @@ -9,13 +9,7 @@ TIP: You can change the CSS values while the simulator is running and the change Codename One applications always use the resource file. The CSS support compiles a file in CSS syntax to a Codename One resource file and adds it to the application. In runtime the CSS no longer exists and the file acts like a regular theme file. -To enable CSS support in Codename One you need to flip a switch in `Codename One Settings`. - -.The CSS Option in Codename One Settings Part I -image::img/css-in-codenameone-settings-1.png[The CSS Option in Codename One Settings] - -.The CSS Option in Codename One Settings Part II -image::img/css-in-codenameone-settings-2.png[The CSS Option in Codename One Settings] +New Maven projects enable CSS by default with `codename1.cssTheme=true` in `common/codenameone_settings.properties`. Add that property manually if you are migrating an older project where CSS support is disabled. Once enabled your `theme.res` file will regenerate from a CSS file that resides under the `css` directory. Changes you make to the CSS file will instantly update the simulator as you save. But, there are some limits to this live update so sometimes a simulator restart would be necessary. @@ -816,4 +810,3 @@ include::../demos/common/src/main/css/guide-snippets-theme.css[tag=css-css-043,i IMPORTANT: All matching `font-scale` constants will be applied to the styles. If you define 3 font-scale constants that all match the current runtime environment, they will all be applied. For example, If there are 3 matching font-scale constants with `2.0`, `3.0`, and `4.0`, then fonts will be scaled by 2*3*4=24! - diff --git a/docs/developer-guide/img/app-prefix.png b/docs/developer-guide/img/app-prefix.png deleted file mode 100644 index ec6d6791f4e..00000000000 Binary files a/docs/developer-guide/img/app-prefix.png and /dev/null differ diff --git a/docs/developer-guide/img/build-hints-codenameone-settings.png b/docs/developer-guide/img/build-hints-codenameone-settings.png deleted file mode 100644 index 976b6db6a66..00000000000 Binary files a/docs/developer-guide/img/build-hints-codenameone-settings.png and /dev/null differ diff --git a/docs/developer-guide/img/codename-one-settings-basic.png b/docs/developer-guide/img/codename-one-settings-basic.png new file mode 100644 index 00000000000..565f29729a0 Binary files /dev/null and b/docs/developer-guide/img/codename-one-settings-basic.png differ diff --git a/docs/developer-guide/img/codename-one-settings-build-hints.png b/docs/developer-guide/img/codename-one-settings-build-hints.png new file mode 100644 index 00000000000..74616046dc2 Binary files /dev/null and b/docs/developer-guide/img/codename-one-settings-build-hints.png differ diff --git a/docs/developer-guide/img/codename-one-settings-extensions.png b/docs/developer-guide/img/codename-one-settings-extensions.png new file mode 100644 index 00000000000..4cdffb59768 Binary files /dev/null and b/docs/developer-guide/img/codename-one-settings-extensions.png differ diff --git a/docs/developer-guide/img/control-center-builds-empty.png b/docs/developer-guide/img/control-center-builds-empty.png deleted file mode 100644 index 4fcdbb7f725..00000000000 Binary files a/docs/developer-guide/img/control-center-builds-empty.png and /dev/null differ diff --git a/docs/developer-guide/img/control-center-dashboard.png b/docs/developer-guide/img/control-center-dashboard.png deleted file mode 100644 index 01a16fcc8a5..00000000000 Binary files a/docs/developer-guide/img/control-center-dashboard.png and /dev/null differ diff --git a/docs/developer-guide/img/control-center-extensions-search-maps.png b/docs/developer-guide/img/control-center-extensions-search-maps.png deleted file mode 100644 index 4b7988ba5fb..00000000000 Binary files a/docs/developer-guide/img/control-center-extensions-search-maps.png and /dev/null differ diff --git a/docs/developer-guide/img/control-center-extensions.png b/docs/developer-guide/img/control-center-extensions.png deleted file mode 100644 index fc0b3293012..00000000000 Binary files a/docs/developer-guide/img/control-center-extensions.png and /dev/null differ diff --git a/docs/developer-guide/img/control-center-main.png b/docs/developer-guide/img/control-center-main.png deleted file mode 100644 index 7db65c36949..00000000000 Binary files a/docs/developer-guide/img/control-center-main.png and /dev/null differ diff --git a/docs/developer-guide/img/css-in-codenameone-settings-1.png b/docs/developer-guide/img/css-in-codenameone-settings-1.png deleted file mode 100644 index 1d796bbb0ce..00000000000 Binary files a/docs/developer-guide/img/css-in-codenameone-settings-1.png and /dev/null differ diff --git a/docs/developer-guide/img/css-in-codenameone-settings-2.png b/docs/developer-guide/img/css-in-codenameone-settings-2.png deleted file mode 100644 index b00f8413934..00000000000 Binary files a/docs/developer-guide/img/css-in-codenameone-settings-2.png and /dev/null differ diff --git a/docs/developer-guide/img/eclipse-open-settings.png b/docs/developer-guide/img/eclipse-open-settings.png deleted file mode 100644 index 70d338b72c7..00000000000 Binary files a/docs/developer-guide/img/eclipse-open-settings.png and /dev/null differ diff --git a/docs/developer-guide/img/eclipse-run-as-button.png b/docs/developer-guide/img/eclipse-run-as-button.png deleted file mode 100644 index 285ecca0d82..00000000000 Binary files a/docs/developer-guide/img/eclipse-run-as-button.png and /dev/null differ diff --git a/docs/developer-guide/img/image-2021-03-08-06-57-26-835.png b/docs/developer-guide/img/image-2021-03-08-06-57-26-835.png deleted file mode 100644 index 5a01c22ffa6..00000000000 Binary files a/docs/developer-guide/img/image-2021-03-08-06-57-26-835.png and /dev/null differ diff --git a/docs/developer-guide/img/intellij-open-settings.png b/docs/developer-guide/img/intellij-open-settings.png deleted file mode 100644 index 6951b773a36..00000000000 Binary files a/docs/developer-guide/img/intellij-open-settings.png and /dev/null differ diff --git a/docs/developer-guide/img/ios-cert-global-settings.png b/docs/developer-guide/img/ios-cert-global-settings.png deleted file mode 100644 index d0b0ffcfb70..00000000000 Binary files a/docs/developer-guide/img/ios-cert-global-settings.png and /dev/null differ diff --git a/docs/developer-guide/img/mac-desktop-entitlements.png b/docs/developer-guide/img/mac-desktop-entitlements.png deleted file mode 100644 index fae6e360f49..00000000000 Binary files a/docs/developer-guide/img/mac-desktop-entitlements.png and /dev/null differ diff --git a/docs/developer-guide/img/mac-desktop-settings-button.png b/docs/developer-guide/img/mac-desktop-settings-button.png deleted file mode 100644 index 0b9d19adeee..00000000000 Binary files a/docs/developer-guide/img/mac-desktop-settings-button.png and /dev/null differ diff --git a/docs/developer-guide/img/mac-desktop-settings-form.png b/docs/developer-guide/img/mac-desktop-settings-form.png deleted file mode 100644 index ba134acf9c1..00000000000 Binary files a/docs/developer-guide/img/mac-desktop-settings-form.png and /dev/null differ diff --git a/docs/developer-guide/img/netbeans-open-control-center.png b/docs/developer-guide/img/netbeans-open-control-center.png deleted file mode 100644 index aaee5914fae..00000000000 Binary files a/docs/developer-guide/img/netbeans-open-control-center.png and /dev/null differ diff --git a/docs/developer-guide/img/package-id-app-id.png b/docs/developer-guide/img/package-id-app-id.png deleted file mode 100644 index a4ce9cd5a21..00000000000 Binary files a/docs/developer-guide/img/package-id-app-id.png and /dev/null differ diff --git a/docs/developer-guide/security.asciidoc b/docs/developer-guide/security.asciidoc index ea68f37980c..9e6afb55371 100644 --- a/docs/developer-guide/security.asciidoc +++ b/docs/developer-guide/security.asciidoc @@ -278,7 +278,7 @@ It's obviously hard to do but if someone was able to do this he could execute a This is the attack certificate pinning (or SSL pinning) aims to prevent. You code into your app the "fingerprint" of the certificate that's "good" and thus prevent the app from working when the certificate is changed. This might break the app if you replace the certificate at some point but that might be reasonable in such a case. -To do this you have a https://github.com/codenameone/SSLCertificateFingerprint/[cn1lib]. That fetches the certificate fingerprint from the server, you can check this fingerprint against a list of "authorized" keys to decide whether it's valid. You can install the `SSLCertificateFingerprint` from the extensions section in #Codename One Settings# and use something like this to verify your server: +To do this you have a https://github.com/codenameone/SSLCertificateFingerprint/[cn1lib]. It fetches the certificate fingerprint from the server so you can check the fingerprint against a list of authorized keys. Run `mvn cn1:settings`, open *Extensions*, and install `SSLCertificateFingerprint`. Then use something like this to verify your server: Notice that once connection is established you don't need to verify again for the current application run. diff --git a/docs/developer-guide/signing.asciidoc b/docs/developer-guide/signing.asciidoc index b2dc353935a..ca4b2b90be0 100644 --- a/docs/developer-guide/signing.asciidoc +++ b/docs/developer-guide/signing.asciidoc @@ -209,12 +209,7 @@ image::img/ios-cert-wizard-5-bundle-id.png[Enter the app bundle ID,scaledwidth=2 .Wildcard Card Provisioning **** -Wildcard ids such as com.mycompany.\* or even \* allow you to create one generic certificate to use with all applications. This is useful for the global settings dialog and allows you to create an app without launching the wizard. Notice that wildcards apps can't use features such as push etc. - -You can set the global defaults for the IDE by going to IDE settings/preferences and setting default values e.g.: - -.Setting the development certificate and a global \* provisioning profile allows you to create a new app and built it to device without running the Certificate Wizard. Notice that you will need to run it when going into production -image::img/ios-cert-global-settings.png[Setting the development certificate and a global \* provisioning profile allows you to create a new app and built it to device without running the Certificate Wizard. Notice that you will need to run it when going into production] +Wildcard IDs such as `com.mycompany.*` or even `*` allow one generic development profile to cover multiple applications. The standalone Certificate Wizard can reuse an existing wildcard profile, but wildcard apps can't use capabilities such as push notifications and aren't suitable for App Store distribution. **** ==== Installing files locally @@ -372,15 +367,9 @@ Notice that this is something you need to do once a year (generate P12), you wil .p12 export image::img/p12-export.png[P12 Export,scaledwidth=30%] -- Make sure the package matches between the main preferences screen in the IDE and the iOS settings screen. -+ -.Package ID matching App ID -image::img/package-id-app-id.png[Package ID matching App ID,scaledwidth=30%] +- Launch Codename One Settings with `mvn cn1:settings` and make sure the *Package Name* in *Basic* matches the App ID used by the provisioning profile. -- Make sure the prefix for the app ID in the iOS section of the preferences matches the one you've from Apple -+ -.App prefix -image::img/app-prefix.png[App prefix,scaledwidth=30%] +- Make sure the App ID prefix in the provisioning profile matches the prefix assigned by Apple. The standalone Certificate Wizard handles this automatically for profiles it creates. - Make sure your provisioning profile's app ID matches your package name or is a * provisioning profile. Both are sampled in the pictures below, notice that you would need an actual package name for push/in-app purchase support as well as for app store distribution. + @@ -434,6 +423,6 @@ CountryCode: [Country Code] Password: [password] (we expect both passwords to be identical) ---- -Executing the command will produce a Keystore.ks file in that directory which you need to keep since if you lose it you will no longer be able to upgrade your applications! Fill in the appropriate details in the project properties or in the CodenameOne section in the Netbeans preferences dialog. +Executing the command produces a `Keystore.ks` file in that directory. Keep it safe: if you lose it, you can no longer publish upgrades signed with the same identity. Enter its path, alias, and passwords in `common/codenameone_settings.properties`, or use the *Android* page in the standalone Certificate Wizard (`mvn cn1:certificatewizard`) to generate and install a keystore. For more details see http://developer.android.com/guide/publishing/app-signing.html diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java index 3185f599c16..8d867d638d7 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java @@ -1,48 +1,338 @@ /* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. */ package com.codename1.maven; -import java.io.File; +import org.apache.commons.io.FileUtils; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.artifact.repository.ArtifactRepository; +import org.apache.maven.artifact.resolver.ArtifactResolutionRequest; +import org.apache.maven.artifact.resolver.ArtifactResolutionResult; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; import org.apache.tools.ant.taskdefs.Java; -import org.apache.tools.ant.types.Commandline.Argument; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; /** - * Opens Codename One Settings. - * @author shannah + * Opens the standalone Codename One Settings tool. + * + *
mvn cn1:settings
*/ @Mojo(name = "settings") public class OpenSettingsMojo extends AbstractCN1Mojo { + private static final String LAUNCHED_PROPERTY = + "com.codename1.maven.OpenSettingsMojo.launched"; + + @Parameter(property = "settings.spawn", required = false, defaultValue = "true") + private boolean spawn; @Override protected void executeImpl() throws MojoExecutionException, MojoFailureException { + if (Boolean.getBoolean(LAUNCHED_PROPERTY)) { + getLog().debug("Skipping settings: already launched in this Maven invocation"); + return; + } if (!isCN1ProjectDir()) { + getLog().debug("Skipping settings: not a CN1 project dir"); + return; + } + System.setProperty(LAUNCHED_PROPERTY, "true"); + + File projectDir = getCN1ProjectDir(); + File runtimeDir = new File(System.getProperty("user.home"), ".codenameoneSettings"); + runtimeDir.mkdirs(); + File inputFile = new File(runtimeDir, "settings-" + UUID.randomUUID() + ".input"); + writeBinding(inputFile, projectDir); + + ToolClasspath toolClasspath = getSettingsClasspath(); + getLog().info("Launching Codename One Settings bound to " + projectDir); + if (shouldSpawn()) { + launchDetached(toolClasspath, runtimeDir, inputFile, projectDir); return; } - updateCodenameOne(false, getGuiBuilderJar()); + Java java = createJava(); java.setFork(true); - java.setSpawn(true); - java.setJar(getGuiBuilderJar()); - Argument arg = java.createArg(); - arg.setValue("-settings"); - arg = java.createArg(); - arg.setFile(new File(getCN1ProjectDir(), "codenameone_settings.properties")); + java.setJvm(namedJavaLauncher(runtimeDir).getAbsolutePath()); + java.setClassname("com.codename1.settings.CodenameOneSettingsLauncher"); + java.createClasspath().setPath(joinClasspath(toolClasspath.files)); + configureDesktopIdentity(java, toolClasspath.primaryJar, runtimeDir); + java.createJvmarg().setValue("-Dsettings.input=" + inputFile.getAbsolutePath()); java.executeJava(); - } - - private File getGuiBuilderJar() { - File home = new File(System.getProperty("user.home")); - File codenameone = new File(home, ".codenameone"); - File settingsJar = new File(codenameone, "guibuilder.jar"); - - return settingsJar; + + @Override + protected boolean isCN1ProjectDir() { + File cn1ProjectDir = getCN1ProjectDir(); + if (cn1ProjectDir == null || project == null || project.getBasedir() == null) { + return false; + } + try { + File current = project.getBasedir().getCanonicalFile(); + File cn1 = cn1ProjectDir.getCanonicalFile(); + if (cn1.equals(current)) { + return true; + } + File rootCommon = new File(current, "common").getCanonicalFile(); + return cn1.equals(rootCommon); + } catch (IOException ex) { + getLog().error("Failed to get canonical paths for project dir", ex); + return false; + } + } + + private boolean shouldSpawn() { + String legacySpawn = System.getProperty("spawn"); + if (legacySpawn != null) { + return Boolean.parseBoolean(legacySpawn); + } + return spawn; + } + + private void launchDetached(ToolClasspath toolClasspath, File runtimeDir, File inputFile, File projectDir) + throws MojoExecutionException { + File log = new File(runtimeDir, "settings.log"); + List command = new ArrayList(); + command.add(namedJavaLauncher(runtimeDir).getAbsolutePath()); + command.addAll(desktopIdentityArgs(toolClasspath.primaryJar, runtimeDir)); + command.add("-Dsettings.input=" + inputFile.getAbsolutePath()); + command.add("-cp"); + command.add(joinClasspath(toolClasspath.files)); + command.add("com.codename1.settings.CodenameOneSettingsLauncher"); + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(projectDir); + pb.redirectErrorStream(true); + pb.redirectOutput(ProcessBuilder.Redirect.appendTo(log)); + configureLauncherEnvironment(pb); + try { + pb.start(); + getLog().info("Codename One Settings launched in the background. Log: " + log.getAbsolutePath()); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to launch Codename One Settings", ex); + } + } + + private void configureDesktopIdentity(Java java, File jar, File runtimeDir) { + for (String arg : desktopIdentityArgs(jar, runtimeDir)) { + java.createJvmarg().setValue(arg); + } + } + + List desktopIdentityArgs(File jar, File runtimeDir) { + List args = new ArrayList(); + args.add("-Dapple.awt.application.name=Codename One Settings"); + args.add("-Dcom.apple.mrj.application.apple.menu.about.name=Codename One Settings"); + args.add("-Dsun.awt.application.name=Codename One Settings"); + args.add("-Dsun.awt.X11.XWMClass=CodenameOneSettings"); + if (isJava9OrNewer()) { + args.add("--add-exports=java.desktop/com.apple.eawt.event=ALL-UNNAMED"); + args.add("--add-exports=java.desktop/com.apple.eawt=ALL-UNNAMED"); + } + if (isMacOs()) { + args.add("-Xdock:name=Codename One Settings"); + File icon = extractSettingsIcon(jar, runtimeDir); + if (icon != null && icon.isFile()) { + args.add("-Xdock:icon=" + icon.getAbsolutePath()); + } + } + return args; + } + + File namedJavaLauncher(File runtimeDir) { + File java = new File(javaExecutable()); + if (isWindows()) { + File launcher = new File(runtimeDir, "CodenameOneSettings.exe"); + try { + FileUtils.copyFile(java, launcher); + return launcher; + } catch (IOException ex) { + getLog().debug("Unable to create Settings launcher executable: " + ex.getMessage()); + return java; + } + } + File launcher = new File(runtimeDir, "Codename One Settings"); + try { + Files.deleteIfExists(launcher.toPath()); + Files.createSymbolicLink(launcher.toPath(), java.toPath()); + return launcher; + } catch (IOException | UnsupportedOperationException | SecurityException ex) { + getLog().debug("Unable to create Settings launcher symlink: " + ex.getMessage()); + return java; + } + } + + File extractSettingsIcon(File jar, File runtimeDir) { + File iconFile = new File(runtimeDir, "settings-icon.png"); + try (JarFile jf = new JarFile(jar)) { + JarEntry entry = jf.getJarEntry("icon.png"); + if (entry == null) { + return null; + } + try (InputStream in = jf.getInputStream(entry)) { + FileUtils.copyInputStreamToFile(in, iconFile); + } + return iconFile; + } catch (IOException ex) { + getLog().debug("Unable to extract Settings dock icon: " + ex.getMessage()); + return null; + } + } + + private void configureLauncherEnvironment(ProcessBuilder pb) { + if (!isWindows()) { + return; + } + String javaBin = new File(System.getProperty("java.home"), "bin").getAbsolutePath(); + String path = pb.environment().get("PATH"); + pb.environment().put("PATH", path == null || path.length() == 0 + ? javaBin : javaBin + File.pathSeparator + path); + } + + void writeBinding(File inputFile, File projectDir) throws MojoExecutionException { + File root = multimoduleRoot(projectDir); + File buildHints = new File(root, "docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc"); + String content = "# Codename One Settings project binding\n" + + "projectDir=" + projectDir.getAbsolutePath() + "\n" + + "settings=" + new File(projectDir, "codenameone_settings.properties").getAbsolutePath() + "\n" + + "pom=" + new File(projectDir, "pom.xml").getAbsolutePath() + "\n" + + "multimoduleRoot=" + root.getAbsolutePath() + "\n" + + (buildHints.isFile() ? "buildHintsDoc=" + buildHints.getAbsolutePath() + "\n" : ""); + try { + FileUtils.write(inputFile, content, StandardCharsets.UTF_8); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to write Settings binding", ex); + } + } + + File multimoduleRoot(File projectDir) { + File parent = projectDir == null ? null : projectDir.getParentFile(); + if (parent != null && "common".equals(projectDir.getName())) { + return parent; + } + return projectDir == null ? new File(".") : projectDir; + } + + private ToolClasspath getSettingsClasspath() throws MojoExecutionException, MojoFailureException { + Artifact artifact = getArtifact("com.codenameone", "codenameone-settings"); + if (artifact == null) { + artifact = repositorySystem.createArtifact( + "com.codenameone", "codenameone-settings", pluginVersion(), "jar"); + } + ToolClasspath classpath = resolveToolClasspath(artifact); + if (classpath.primaryJar == null || classpath.files.isEmpty()) { + throw new MojoFailureException( + "Could not resolve Codename One Settings " + + "(com.codenameone:codenameone-settings:" + pluginVersion() + + ").\n" + + "It is distributed through Maven Central alongside the Codename One plugin.\n" + + "To work on the Settings tool itself, run:\n" + + " cd scripts/settings && mvn -Pexecutable-jar -pl javase -am package -Dcodename1.platform=javase\n" + + " java -cp \"javase/target/codenameone-settings-*.jar:javase/target/libs/*\" " + + "com.codename1.settings.CodenameOneSettingsLauncher"); + } + return classpath; + } + + private ToolClasspath resolveToolClasspath(Artifact artifact) { + List files = new ArrayList(); + ArtifactResolutionResult result = repositorySystem.resolve(new ArtifactResolutionRequest() + .setLocalRepository(localRepository) + .setRemoteRepositories(new ArrayList(remoteRepositories)) + .setResolveTransitively(true) + .setArtifact(artifact)); + File primary = addArtifactFile(files, artifact); + if (result != null && result.getArtifacts() != null) { + for (Artifact resolved : result.getArtifacts()) { + File file = addArtifactFile(files, resolved); + if (primary == null && resolved != null + && "com.codenameone".equals(resolved.getGroupId()) + && "codenameone-settings".equals(resolved.getArtifactId())) { + primary = file; + } + } + } + return new ToolClasspath(primary, files); + } + + private static File addArtifactFile(List files, Artifact artifact) { + if (artifact == null || artifact.getFile() == null || !"jar".equals(artifact.getType())) { + return null; + } + File file = artifact.getFile().getAbsoluteFile(); + if (!file.exists()) { + return null; + } + if (!files.contains(file)) { + files.add(file); + } + return file; + } + + private static String joinClasspath(List files) { + StringBuilder out = new StringBuilder(); + for (File file : files) { + if (out.length() > 0) { + out.append(File.pathSeparator); + } + out.append(file.getAbsolutePath()); + } + return out.toString(); + } + + private String javaExecutable() { + String executable = isWindows() ? "javaw.exe" : "java"; + return new File(new File(System.getProperty("java.home"), "bin"), executable).getAbsolutePath(); + } + + static boolean isMacOs() { + return System.getProperty("os.name", "").toLowerCase().contains("mac"); + } + + static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase().contains("win"); + } + + static boolean isJava9OrNewer() { + String version = System.getProperty("java.specification.version", ""); + return version.length() > 0 && !version.startsWith("1."); + } + + String pluginVersion() { + if (pluginArtifacts != null) { + for (Artifact a : pluginArtifacts) { + if ("codenameone-maven-plugin".equals(a.getArtifactId()) + && "com.codenameone".equals(a.getGroupId())) { + return a.getVersion(); + } + } + } + if (project == null) { + return "8.0-SNAPSHOT"; + } + return project.getProperties().getProperty("cn1.plugin.version", + project.getProperties().getProperty("cn1.version", "8.0-SNAPSHOT")); + } + + private static final class ToolClasspath { + final File primaryJar; + final List files; + + ToolClasspath(File primaryJar, List files) { + this.primaryJar = primaryJar; + this.files = files; + } } - } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java new file mode 100644 index 00000000000..70f83e43704 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java @@ -0,0 +1,168 @@ +package com.codename1.maven; + +import org.apache.maven.project.MavenProject; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class OpenSettingsMojoTest { + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void extractsPackagedIconForDesktopLaunchers() throws Exception { + File jar = jarWithIcon("settings.jar"); + File runtimeDir = tmp.newFolder("runtime"); + + File extracted = new OpenSettingsMojo().extractSettingsIcon(jar, runtimeDir); + + assertTrue(extracted.isFile()); + assertEquals("settings-icon.png", extracted.getName()); + assertEquals("fake-png", new String(Files.readAllBytes(extracted.toPath()), StandardCharsets.UTF_8)); + } + + @Test + public void desktopIdentityArgsUseSettingsName() throws Exception { + File runtimeDir = tmp.newFolder("runtime"); + List args = new OpenSettingsMojo().desktopIdentityArgs(jarWithIcon("settings.jar"), runtimeDir); + + assertTrue(args.contains("-Dapple.awt.application.name=Codename One Settings")); + assertTrue(args.contains("-Dcom.apple.mrj.application.apple.menu.about.name=Codename One Settings")); + assertTrue(args.contains("-Dsun.awt.application.name=Codename One Settings")); + assertTrue(args.contains("-Dsun.awt.X11.XWMClass=CodenameOneSettings")); + if (OpenSettingsMojo.isJava9OrNewer()) { + assertTrue(args.contains("--add-exports=java.desktop/com.apple.eawt.event=ALL-UNNAMED")); + assertTrue(args.contains("--add-exports=java.desktop/com.apple.eawt=ALL-UNNAMED")); + } + } + + @Test + public void namedJavaLauncherUsesSettingsProcessName() throws Exception { + File launcher = new OpenSettingsMojo().namedJavaLauncher(tmp.newFolder("runtime")); + + if (OpenSettingsMojo.isWindows()) { + assertEquals("CodenameOneSettings.exe", launcher.getName()); + } else { + assertEquals("Codename One Settings", launcher.getName()); + assertTrue(Files.isSymbolicLink(launcher.toPath()) || launcher.exists()); + } + } + + @Test + public void settingsCanLaunchFromProjectRootWithCommonModule() throws Exception { + File root = tmp.newFolder("cn1app"); + File common = new File(root, "common"); + assertTrue(common.mkdirs()); + Files.write(new File(root, "pom.xml").toPath(), "".getBytes(StandardCharsets.UTF_8)); + Files.write(new File(common, "pom.xml").toPath(), "".getBytes(StandardCharsets.UTF_8)); + Files.write(new File(common, "codenameone_settings.properties").toPath(), + "codename1.packageName=com.example.app\n".getBytes(StandardCharsets.UTF_8)); + + OpenSettingsMojo mojo = new OpenSettingsMojo(); + mojo.project = projectAt(root); + + assertTrue(mojo.isCN1ProjectDir()); + assertEquals(common.getCanonicalFile(), mojo.getCN1ProjectDir().getCanonicalFile()); + assertEquals(root.getCanonicalFile(), mojo.multimoduleRoot(common).getCanonicalFile()); + } + + @Test + public void settingsCanLaunchFromCommonModule() throws Exception { + File common = tmp.newFolder("common"); + Files.write(new File(common, "pom.xml").toPath(), "".getBytes(StandardCharsets.UTF_8)); + Files.write(new File(common, "codenameone_settings.properties").toPath(), + "codename1.packageName=com.example.app\n".getBytes(StandardCharsets.UTF_8)); + + OpenSettingsMojo mojo = new OpenSettingsMojo(); + mojo.project = projectAt(common); + + assertTrue(mojo.isCN1ProjectDir()); + assertEquals(common.getCanonicalFile(), mojo.getCN1ProjectDir().getCanonicalFile()); + } + + @Test + public void bindingContainsProjectFilesAndMultimoduleRoot() throws Exception { + File root = tmp.newFolder("project"); + File common = new File(root, "common"); + assertTrue(common.mkdirs()); + File input = tmp.newFile("settings.input"); + + new OpenSettingsMojo().writeBinding(input, common); + + String binding = new String(Files.readAllBytes(input.toPath()), StandardCharsets.UTF_8); + assertTrue(binding.contains("projectDir=" + common.getAbsolutePath())); + assertTrue(binding.contains("settings=" + new File(common, "codenameone_settings.properties").getAbsolutePath())); + assertTrue(binding.contains("pom=" + new File(common, "pom.xml").getAbsolutePath())); + assertTrue(binding.contains("multimoduleRoot=" + root.getAbsolutePath())); + } + + @Test + public void pluginVersionUsesCodenameOneVersionInsteadOfApplicationVersion() { + OpenSettingsMojo mojo = new OpenSettingsMojo(); + mojo.project = projectAt(new File(".")); + mojo.project.setVersion("1.0-SNAPSHOT"); + mojo.project.getProperties().setProperty("cn1.version", "7.0.258"); + + assertEquals("7.0.258", mojo.pluginVersion()); + + mojo.project.getProperties().setProperty("cn1.plugin.version", "7.0.259"); + assertEquals("7.0.259", mojo.pluginVersion()); + } + + @Test + public void platformDetectionHandlesMacWindowsAndJavaVersions() { + String os = System.getProperty("os.name"); + String java = System.getProperty("java.specification.version"); + try { + System.setProperty("os.name", "Mac OS X"); + assertTrue(OpenSettingsMojo.isMacOs()); + assertFalse(OpenSettingsMojo.isWindows()); + System.setProperty("os.name", "Windows 11"); + assertTrue(OpenSettingsMojo.isWindows()); + System.setProperty("java.specification.version", "17"); + assertTrue(OpenSettingsMojo.isJava9OrNewer()); + System.setProperty("java.specification.version", "1.8"); + assertFalse(OpenSettingsMojo.isJava9OrNewer()); + } finally { + restoreProperty("os.name", os); + restoreProperty("java.specification.version", java); + } + } + + private File jarWithIcon(String name) throws Exception { + File jar = tmp.newFile(name); + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(jar))) { + out.putNextEntry(new JarEntry("icon.png")); + out.write("fake-png".getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + return jar; + } + + private MavenProject projectAt(File basedir) { + MavenProject project = new MavenProject(); + project.setFile(new File(basedir, "pom.xml")); + project.addCompileSourceRoot(new File(basedir, "src/main/java").getAbsolutePath()); + return project; + } + + private static void restoreProperty(String key, String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } +} diff --git a/maven/update-version.sh b/maven/update-version.sh index d0a1e08d116..22f1e84f2b3 100755 --- a/maven/update-version.sh +++ b/maven/update-version.sh @@ -101,16 +101,30 @@ if [ -f "$cwParent" ]; then perl -pi -e "s{\Q$oldVersion\E}{$version}g" "$cwParent" fi +# Settings editor (scripts/settings) has the same out-of-reactor shape and is +# deployed by the release workflow after the main Maven reactor. +for f in ../scripts/settings/pom.xml \ + ../scripts/settings/common/pom.xml \ + ../scripts/settings/javase/pom.xml; do + [ -f "$f" ] && perl -pi -e "s{\Q$oldVersion\E}{$version}g" "$f" +done +settingsParent=../scripts/settings/pom.xml +if [ -f "$settingsParent" ]; then + perl -pi -e "s{\Q$oldVersion\E}{$version}g" "$settingsParent" + perl -pi -e "s{\Q$oldVersion\E}{$version}g" "$settingsParent" +fi + echo "Committing version change in git" git add -u . # Note: the -u is to prevent adding files that aren't added to git yet. Only changed # files. This is to help avoid accidents. git add -u ../scripts/gamebuilder git add -u ../scripts/certificatewizard +git add -u ../scripts/settings git commit -m "Updated version to $version" if [[ "$version" == *-SNAPSHOT ]]; then echo "This is a snapshot version so not adding a tag" else echo "Adding git tag for 'v${version}'" git tag -a "v${version}" -m "Version ${version}" -fi \ No newline at end of file +fi diff --git a/scripts/settings/.gitignore b/scripts/settings/.gitignore new file mode 100644 index 00000000000..ec7b5e3e98c --- /dev/null +++ b/scripts/settings/.gitignore @@ -0,0 +1,3 @@ +target/ +common/target/ +javase/target/ diff --git a/scripts/settings/common/codenameone_settings.properties b/scripts/settings/common/codenameone_settings.properties new file mode 100644 index 00000000000..847d5d3097c --- /dev/null +++ b/scripts/settings/common/codenameone_settings.properties @@ -0,0 +1,15 @@ +codename1.packageName=com.codename1.settings +codename1.mainName=CodenameOneSettings +codename1.displayName=Codename One Settings +codename1.version=1.0 +codename1.vendor=Codename One +codename1.icon=icon.png +codename1.cssTheme=true +codename1.arg.java.version=17 +codename1.arg.nativeTheme=modern +codename1.arg.ios.themeMode=modern +codename1.arg.and.themeMode=modern +codename1.arg.desktop.width=1260 +codename1.arg.desktop.height=820 +codename1.arg.desktop.titleBar=native +codename1.arg.desktop.interactiveScrollbars=true diff --git a/scripts/settings/common/icon.png b/scripts/settings/common/icon.png new file mode 100644 index 00000000000..1f4fa5dd252 Binary files /dev/null and b/scripts/settings/common/icon.png differ diff --git a/scripts/settings/common/pom.xml b/scripts/settings/common/pom.xml new file mode 100644 index 00000000000..c595ad73e9a --- /dev/null +++ b/scripts/settings/common/pom.xml @@ -0,0 +1,139 @@ + + + 4.0.0 + + com.codenameone.settings + cn1-settings + 8.0-SNAPSHOT + + cn1-settings-common + jar + cn1-settings-common + + + + com.codenameone + codenameone-core + + + com.codenameone + codenameone-javase + test + + + org.junit.jupiter + junit-jupiter-api + 5.10.2 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.10.2 + test + + + + + + javase + + + codename1.platform + javase + + + + javase + + + + + org.codehaus.mojo + exec-maven-plugin + 3.0.0 + + java + true + + -Xmx1024M + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + + + + + ${project.basedir}/src/main/resources + + + ${project.basedir}/../../../docs/developer-guide + + Advanced-Topics-Under-The-Hood.asciidoc + + com/codename1/settings/hints + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + read-project-properties + + + ${basedir}/codenameone_settings.properties + + + + + + + com.codenameone + codenameone-maven-plugin + + + cn1-process-classes + process-classes + + bytecode-compliance + css + process-annotations + + + + attach-test-artifact + test + attach-test-artifact + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + diff --git a/scripts/settings/common/src/main/css/theme.css b/scripts/settings/common/src/main/css/theme.css new file mode 100644 index 00000000000..4b6da7ae827 --- /dev/null +++ b/scripts/settings/common/src/main/css/theme.css @@ -0,0 +1,622 @@ +#Constants { + includeNativeBool: true; + defaultSourceDPIInt: "0"; + fadeScrollBarBool: false; +} + +Container { + background-color: transparent; + padding: 0; + margin: 0; +} +ContentPane, CenterAlignedContentPane { + background-color: transparent; + border-width: 0; + padding: 0; + margin: 0; +} + +SettingsForm, SettingsFormDark { + font-family: "native:MainRegular"; + font-size: 3.4mm; +} +SettingsChrome, SettingsChromeDark { + padding: 2.6mm 3.2mm 2.6mm 3.2mm; +} +SettingsToolbarBrand, SettingsToolbarBrandDark { + background-color: transparent; + border-width: 0; + font-family: "native:MainBold"; + font-size: 3.6mm; + padding: 1.4mm 0 1.4mm 0; + margin: 0 1.6mm 0 0; + text-align: left; +} +SettingsTitle, SettingsTitleDark { + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.6mm; + padding: 0; + margin: 0; +} +SettingsMark, SettingsMarkDark { + background-color: transparent; + padding: 0; + margin: 0 1.6mm 0 0; + text-align: center; +} +SettingsAppName, SettingsAppNameDark { + background-color: transparent; + border-width: 0; + font-family: "native:MainBold"; + font-size: 3mm; + padding: 1.6mm 0 1.6mm 0; + margin: 0 1.8mm 0 0; + text-align: left; +} +SettingsPathChip, SettingsPathChipDark { + border-width: 1px; + border-style: solid; + border-radius: 1mm; + padding: 1.5mm 2mm 1.5mm 2mm; + margin: 0 1.5mm 0 0; +} +SettingsPathIcon, SettingsPathIconDark { + background-color: transparent; + padding: 0 1.4mm 0 0; + margin: 0; + text-align: center; +} +SettingsPathText, SettingsPathTextDark { + background-color: transparent; + font-size: 3mm; + padding: 0; + margin: 0; +} +SettingsToolbarButton, SettingsToolbarButtonDark { + border-width: 1px; + border-style: solid; + border-radius: 1mm; + padding: 1.35mm; + margin: 0 0 0 1.5mm; + text-align: center; +} +SettingsSave, SettingsSaveDark { + background-color: #B8D532; + color: #12233F; + border: 1px solid #B8D532; + border-radius: 1mm; + font-family: "native:MainBold"; + font-size: 3.1mm; + padding: 1.5mm 3.2mm 1.5mm 3.2mm; + margin: 0 0 0 1.5mm; + text-align: center; +} + +SettingsRail, SettingsRailDark { + border-right-width: 1px; + border-right-style: solid; + padding: 2.2mm 1.5mm 2.2mm 1.5mm; +} +SettingsRailItem, SettingsRailItemDark, SettingsRailItemSelected, SettingsRailItemSelectedDark { + background-color: transparent; + border-width: 0; + border-radius: 1mm; + font-family: "native:MainBold"; + font-size: 2.4mm; + padding: 1.5mm 1.8mm 1.4mm 1.8mm; + margin: 0 0 0.8mm 0; + text-align: center; +} + +SettingsPage, SettingsPageDark { + padding: 4.5mm 5.5mm 5mm 5.5mm; + margin: 0; +} +SettingsPageTitle, SettingsPageTitleDark { + background-color: transparent; + font-family: "native:MainBold"; + font-size: 6mm; + padding: 0; + margin: 0 0 0.6mm 0; +} +SettingsSub, SettingsSubDark { + background-color: transparent; + font-size: 3.2mm; + padding: 0; + margin: 0 0 4.2mm 0; +} +SettingsFieldGrid, SettingsFieldGridDark, SettingsFieldPair, SettingsFieldPairDark, SettingsFieldGroup, SettingsFieldGroupDark { + background-color: transparent; + padding: 0; + margin: 0; +} +SettingsFieldGroup, SettingsFieldGroupDark { + margin: 0 1.8mm 3.2mm 0; +} +SettingsFieldLabel, SettingsFieldLabelDark { + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.7mm; + padding: 0; + margin: 0 0 0.9mm 0; +} +SettingsField, SettingsFieldDark, SettingsArea, SettingsAreaDark, SettingsFieldError, SettingsFieldErrorDark { + border-width: 1px; + border-style: solid; + border-radius: 1mm; + font-size: 3.3mm; + padding: 1.65mm 2mm 1.65mm 2mm; + margin: 0; +} +SettingsField.selected, SettingsFieldDark.selected, SettingsArea.selected, SettingsAreaDark.selected { + border-width: 1px; + border-style: solid; + border-radius: 1mm; + padding: 1.65mm 2mm 1.65mm 2mm; + margin: 0; +} +SettingsFieldError.selected, SettingsFieldErrorDark.selected { + border: 1px solid #D64545; + border-radius: 1mm; + padding: 1.65mm 2mm 1.65mm 2mm; + margin: 0; +} +SettingsSearchBox, SettingsSearchBoxDark { + border-width: 1px; + border-style: solid; + border-radius: 1mm; + padding: 1.65mm 2mm 1.65mm 2mm; + margin: 0 0 3.2mm 0; +} +SettingsSearchBoxFocused, SettingsSearchBoxFocusedDark { + border-width: 2px; + border-style: solid; + border-radius: 1mm; + padding: 1.65mm 2mm 1.65mm 2mm; + margin: 0 0 3.2mm 0; +} +SettingsSearchIcon, SettingsSearchIconDark { + background-color: transparent; + border-width: 0; + padding: 0 1.5mm 0 0; + margin: 0; + text-align: center; +} +SettingsSearchField, SettingsSearchFieldDark { + background-color: transparent; + border-width: 0; + font-size: 3.3mm; + padding: 0; + margin: 0; +} +SettingsSearchField.selected, SettingsSearchFieldDark.selected { + background-color: transparent; + border-width: 0; + font-size: 3.3mm; + padding: 0; + margin: 0; +} +SettingsFilterRow, SettingsFilterRowDark, SettingsList, SettingsListDark { + background-color: transparent; + padding: 0; + margin: 0; +} + +SettingsIconDrop, SettingsIconDropDark { + border-width: 1px; + border-style: dashed; + border-radius: 1mm; + padding: 2.6mm; + margin: 0 0 4mm 0; +} +SettingsIconPreview, SettingsIconPreviewDark { + color: #FFFFFF; + border-radius: 2.7mm; + font-family: "native:MainBold"; + font-size: 4mm; + padding: 0; + margin: 0 2.8mm 0 0; + text-align: center; +} +SettingsIconAction, SettingsIconActionDark { + background-color: transparent; + padding: 0; + margin: 0 0 0 2.5mm; +} +SettingsDivider, SettingsDividerDark { + padding: 0.12mm; + margin: 0 0 4mm 0; +} +SettingsSectionTag, SettingsSectionTagDark { + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.3mm; + padding: 0; + margin: 0 0 2.2mm 0; +} +SettingsToggleRow, SettingsToggleRowDark { + border-width: 1px; + border-style: solid; + border-radius: 1mm; + padding: 0.8mm 2mm 0.8mm 2mm; + margin: 0; +} +SettingsSwitch, SettingsSwitchDark { + color: #FFFFFF; + border-width: 0; + font-size: 3mm; + padding: 0; + margin: 0 1.2mm 0 2mm; +} +SettingsSwitch.selected, SettingsSwitchDark.selected { + color: #FFFFFF; + border-width: 0; + font-size: 3mm; + padding: 0; + margin: 0 1.2mm 0 2mm; +} + +SettingsCard, SettingsCardDark, SettingsRow, SettingsRowDark { + border-width: 1px; + border-style: solid; + border-radius: 1mm; + padding: 3mm 3.2mm 3mm 3.2mm; + margin: 0 0 1.4mm 0; +} +SettingsCardTitle, SettingsCardTitleDark, SettingsRowTitle, SettingsRowTitleDark { + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.3mm; + padding: 0; + margin: 0; +} +SettingsRowMeta, SettingsRowMetaDark, SettingsRowText, SettingsRowTextDark { + background-color: transparent; + font-size: 2.8mm; + padding: 0; + margin: 0; +} +SettingsCardRow, SettingsCardRowDark { + background-color: transparent; + border-width: 1px; + border-style: solid; + border-radius: 1mm; + padding: 2mm; + margin: 0 0 1.4mm 0; +} +SettingsHintEditor, SettingsHintEditorDark { + background-color: transparent; + padding: 0; + margin: 1.2mm 0 0 0; +} +SettingsHintKeyCell, SettingsHintKeyCellDark { + background-color: transparent; + padding: 0; + margin: 0 1.2mm 0 0; +} +SettingsHintValueCell, SettingsHintValueCellDark { + background-color: transparent; + padding: 0; + margin: 0 1.2mm 0 0; +} +SettingsActiveBadge, SettingsActiveBadgeDark, SettingsExtensionTags, SettingsExtensionTagsDark { + border-radius: 1.5mm; + font-family: "native:MainBold"; + font-size: 2.2mm; + padding: 0.2mm 1mm 0.2mm 1mm; + margin: 0 0.9mm 0 0; +} + +SettingsPrimary, SettingsPrimaryDark, SettingsExtensionPrimary, SettingsExtensionPrimaryDark { + color: #FFFFFF; + border-radius: 1mm; + font-family: "native:MainBold"; + font-size: 3.1mm; + padding: 1.6mm 3mm 1.6mm 3mm; + margin: 0; + text-align: center; +} +SettingsOutline, SettingsOutlineDark { + background-color: transparent; + border-width: 1px; + border-style: solid; + border-radius: 1mm; + font-family: "native:MainBold"; + font-size: 3.1mm; + padding: 1.6mm 2.4mm 1.6mm 2.4mm; + margin: 0; + text-align: center; +} +SettingsSmallIconButton, SettingsSmallIconButtonDark { + background-color: transparent; + border-width: 1px; + border-style: solid; + border-radius: 1mm; + padding: 1.4mm; + margin: 0 0 0 2.2mm; + text-align: center; +} + +SettingsPopupMenu, SettingsPopupMenuDark { + border-width: 1px; + border-style: solid; + border-radius: 1.3mm; + padding: 0; + margin: 0; +} +SettingsPopupItem, SettingsPopupItemDark { + background-color: transparent; + border-bottom-width: 1px; + border-bottom-style: solid; + padding: 0; + margin: 0; +} +SettingsPopupLabel, SettingsPopupLabelDark { + background-color: transparent; + border-width: 0; + font-family: "native:MainBold"; + font-size: 3.1mm; + padding: 3.5mm 2.6mm 3.5mm 2.6mm; + margin: 0; + text-align: left; +} +SettingsPopupIcon, SettingsPopupIconDark { + background-color: transparent; + border-width: 0; + padding: 3.5mm 2.6mm 3.5mm 1mm; + margin: 0; + text-align: center; +} +SettingsPopupToggleLabel, SettingsPopupToggleLabelDark { + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.1mm; + padding: 3.5mm 2.6mm 3.5mm 2.6mm; + margin: 0; +} + +SettingsExtensionGrid, SettingsExtensionGridDark, SettingsExtensionRow, SettingsExtensionRowDark, SettingsExtensionBody, SettingsExtensionBodyDark { + background-color: transparent; + padding: 0; + margin: 0; +} +SettingsExtensionRow, SettingsExtensionRowDark { + margin: 0 0 2.4mm 0; +} +SettingsExtensionCard, SettingsExtensionCardDark { + border-width: 1px; + border-style: solid; + border-radius: 1.3mm; + padding: 4mm; + margin: 0 1.2mm 0 1.2mm; +} +SettingsExtensionTitle, SettingsExtensionTitleDark { + background-color: transparent; + font-family: "native:MainBold"; + font-size: 4mm; + padding: 0; + margin: 0 0 1.8mm 0; +} +SettingsExtensionText, SettingsExtensionTextDark { + background-color: transparent; + font-size: 3.1mm; + padding: 0; + margin: 0; +} +SettingsExtensionMetaGrid, SettingsExtensionMetaGridDark, SettingsExtensionMetaColumn, SettingsExtensionMetaColumnDark { + background-color: transparent; + padding: 0; + margin: 1.8mm 0 1.4mm 0; +} +SettingsExtensionMetaLabel, SettingsExtensionMetaLabelDark { + background-color: transparent; + font-size: 2.6mm; + padding: 0; + margin: 0 0 0.35mm 0; +} +SettingsExtensionMetaValue, SettingsExtensionMetaValueDark { + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.9mm; + padding: 0; + margin: 0; +} +SettingsExtensionMeta, SettingsExtensionMetaDark, SettingsExtensionWarning, SettingsExtensionWarningDark { + background-color: transparent; + font-size: 2.6mm; + padding: 0; + margin: 0; +} +SettingsExtensionTagRow, SettingsExtensionTagRowDark { + background-color: transparent; + padding: 0; + margin: 0 0 1.8mm 0; +} +SettingsDialogContent, SettingsDialogContentDark { + padding: 4mm; +} + +SettingsForm, SettingsPage { + background-color: #F3F4F7; + color: #112247; +} +SettingsChrome, SettingsCard, SettingsRow, SettingsExtensionCard, SettingsPopupMenu, SettingsDialogContent { + background-color: #FFFFFF; + color: #112247; + border-color: #D9DEE8; +} +SettingsChrome { + border-bottom: 1px solid #D9DEE8; +} +SettingsToolbarBrand, SettingsTitle, SettingsAppName, SettingsCardTitle, SettingsRowTitle, SettingsPopupLabel, SettingsPopupToggleLabel, SettingsExtensionTitle, SettingsExtensionMetaValue { + color: #112247; +} +SettingsAppName, SettingsPathIcon, SettingsPathText, SettingsRailItem, SettingsFieldLabel, SettingsSub, SettingsRowMeta, SettingsRowText, SettingsSearchIcon, SettingsExtensionText, SettingsExtensionMetaLabel, SettingsExtensionMeta, SettingsExtensionWarning { + color: #7F8AA3; +} +SettingsPopupIcon { + color: #7F8AA3; +} +SettingsPathChip, SettingsToolbarButton, SettingsField, SettingsArea, SettingsFieldError, SettingsSearchBox, SettingsToggleRow { + background-color: #FFFFFF; + color: #112247; + border-color: #D9DEE8; +} +SettingsSearchBoxFocused { + background-color: #FFFFFF; + color: #112247; + border-color: #2F6BFF; +} +SettingsSearchField, SettingsSearchField.selected { + color: #112247; +} +SettingsPathChip, SettingsToolbarButton { + color: #7F8AA3; +} +SettingsField.selected, SettingsArea.selected { + background-color: #FFFFFF; + color: #112247; + border-width: 2px; + border-color: #2F6BFF; +} +SettingsFieldError, SettingsFieldError.selected { + border-color: #D64545; +} +SettingsRail { + background-color: #F7F8FB; + border-color: #D9DEE8; +} +SettingsRailItemSelected { + background-color: #E8F0FF; + color: #2F6BFF; +} +SettingsIconDrop { + background-color: #F7F8FB; + border-color: #BFC7D6; +} +SettingsIconPreview { + background: linear-gradient(135deg, #2F6BFF 0%, #2A8A8A 100%); +} +SettingsDivider { + background-color: #D9DEE8; +} +SettingsSectionTag { + color: #A0A8BA; +} +SettingsCardRow, SettingsSmallIconButton, SettingsPopupItem, SettingsExtensionCard { + border-color: #D9DEE8; +} +SettingsSmallIconButton { + color: #7F8AA3; +} +SettingsActiveBadge, SettingsExtensionTags { + background-color: #E8F0FF; + color: #2F6BFF; +} +SettingsSwitch { + background-color: #BFC7D6; +} +SettingsSwitch.selected { + background-color: #2F6BFF; +} +SettingsPrimary, SettingsExtensionPrimary { + background-color: #2F6BFF; + border: 1px solid #2F6BFF; +} +SettingsOutline { + color: #2F6BFF; + border-color: #2F6BFF; +} + +SettingsFormDark, SettingsPageDark { + background-color: #071B4D; + color: #F5F8FF; +} +SettingsChromeDark, SettingsCardDark, SettingsRowDark, SettingsExtensionCardDark, SettingsPopupMenuDark, SettingsDialogContentDark { + background-color: #102B66; + color: #F5F8FF; + border-color: #4C6EA8; +} +SettingsChromeDark { + border-bottom: 1px solid #4C6EA8; +} +SettingsToolbarBrandDark, SettingsTitleDark, SettingsPageTitleDark, SettingsCardTitleDark, SettingsRowTitleDark, SettingsPopupLabelDark, SettingsPopupToggleLabelDark, SettingsExtensionTitleDark, SettingsExtensionMetaValueDark { + color: #F5F8FF; +} +SettingsAppNameDark, SettingsPathIconDark, SettingsPathTextDark, SettingsRailItemDark, SettingsFieldLabelDark, SettingsSubDark, SettingsRowMetaDark, SettingsRowTextDark, SettingsSearchIconDark, SettingsExtensionTextDark, SettingsExtensionMetaLabelDark, SettingsExtensionMetaDark, SettingsExtensionWarningDark { + color: #A8B8DA; +} +SettingsPopupIconDark { + color: #A8B8DA; +} +SettingsPathChipDark, SettingsToolbarButtonDark, SettingsFieldDark, SettingsAreaDark, SettingsFieldErrorDark, SettingsSearchBoxDark, SettingsToggleRowDark { + background-color: #0E2A61; + color: #F5F8FF; + border-color: #4C6EA8; +} +SettingsSearchBoxFocusedDark { + background-color: #0E2A61; + color: #F5F8FF; + border-color: #4D86FF; +} +SettingsSearchFieldDark, SettingsSearchFieldDark.selected { + color: #F5F8FF; +} +SettingsPathChipDark, SettingsToolbarButtonDark { + color: #A8B8DA; +} +SettingsFieldDark.selected, SettingsAreaDark.selected { + background-color: #0E2A61; + color: #F5F8FF; + border-width: 2px; + border-color: #4D86FF; +} +SettingsFieldErrorDark, SettingsFieldErrorDark.selected { + border-color: #D96A6A; +} +SettingsRailDark { + background-color: #163575; + border-color: #4C6EA8; +} +SettingsRailItemSelectedDark { + background-color: #21478F; + color: #4D86FF; +} +SettingsIconDropDark { + background-color: #163575; + border-color: #7390C0; +} +SettingsIconPreviewDark { + background: linear-gradient(135deg, #2F6BFF 0%, #2A8A8A 100%); +} +SettingsDividerDark { + background-color: #4C6EA8; +} +SettingsSectionTagDark { + color: #7E93BC; +} +SettingsCardRowDark, SettingsSmallIconButtonDark, SettingsPopupItemDark, SettingsExtensionCardDark { + border-color: #4C6EA8; +} +SettingsSmallIconButtonDark { + color: #A8B8DA; +} +SettingsActiveBadgeDark, SettingsExtensionTagsDark { + background-color: #21478F; + color: #4D86FF; +} +SettingsSwitchDark { + background-color: #4C6EA8; +} +SettingsSwitchDark.selected { + background-color: #4D86FF; +} +SettingsPrimaryDark, SettingsExtensionPrimaryDark { + background-color: #4D86FF; + border: 1px solid #4D86FF; +} +SettingsOutlineDark { + color: #4D86FF; + border-color: #4D86FF; +} diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java b/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java new file mode 100644 index 00000000000..4933692c4f5 --- /dev/null +++ b/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java @@ -0,0 +1,2011 @@ +package com.codename1.settings; + +import com.codename1.components.InteractionDialog; +import com.codename1.components.Switch; +import com.codename1.components.ToastBar; +import com.codename1.io.ConnectionRequest; +import com.codename1.io.FileSystemStorage; +import com.codename1.io.Log; +import com.codename1.io.NetworkManager; +import com.codename1.io.Preferences; +import com.codename1.io.Util; +import com.codename1.settings.extensions.ExtensionDescriptor; +import com.codename1.settings.extensions.ExtensionCatalogMerger; +import com.codename1.settings.extensions.MavenCentralSearch; +import com.codename1.settings.extensions.MavenDependency; +import com.codename1.settings.extensions.PomEditor; +import com.codename1.settings.hints.BuildHintCatalog; +import com.codename1.settings.hints.BuildHintMetadata; +import com.codename1.settings.hints.BuildHintType; +import com.codename1.settings.project.ProjectBinding; +import com.codename1.settings.project.ProjectIO; +import com.codename1.settings.project.SettingsProperties; +import com.codename1.system.Lifecycle; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Command; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.Font; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.Toolbar; +import com.codename1.ui.events.FocusListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.spinner.Picker; +import com.codename1.ui.table.TableLayout; +import com.codename1.xml.Element; +import com.codename1.xml.XMLParser; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class CodenameOneSettings extends Lifecycle { + public enum Section { BASIC, BUILD_HINTS, EXTENSIONS, ADVANCED } + + private static final String PREF_DARK_MODE = "settings.darkMode"; + private static final String PREF_FONT_DELTA = "settings.fontDeltaPx"; + private static final String EXTENSIONS_URL = "https://www.codenameone.com/files/CN1Libs.xml"; + + private ProjectBinding binding; + private SettingsProperties settings; + private BuildHintCatalog buildHints = BuildHintCatalog.fallback(); + private Section section = Section.BASIC; + private Form form; + private Container page; + private Container pageViewport; + private boolean darkMode; + private int fontDeltaPx; + private float fontPinchAccumulator = 1f; + private String hintFilter = ""; + private String extensionFilter = ""; + private List extensionCatalog; + private final Map expandedExtensions = new LinkedHashMap(); + private Button toolbarMenuButton; + private static CodenameOneSettings activeSettings; + + public static void adjustActiveFontSizeForDesktopShortcut(int deltaPx) { + CodenameOneSettings s = activeSettings; + if (s != null) { + CN.callSerially(() -> s.adjustFontSize(deltaPx)); + } + } + + public static void resetActiveFontSizeForDesktopShortcut() { + CodenameOneSettings s = activeSettings; + if (s != null) { + CN.callSerially(s::resetFontSize); + } + } + + public static void saveActiveSettingsForDesktopMenu() { + CodenameOneSettings s = activeSettings; + if (s != null) { + CN.callSerially(s::saveSettings); + } + } + + public static void openActiveProjectFolderForDesktopMenu() { + CodenameOneSettings s = activeSettings; + if (s != null) { + CN.callSerially(s::openProjectFolder); + } + } + + public static void toggleActiveDarkModeForDesktopMenu() { + CodenameOneSettings s = activeSettings; + if (s != null) { + CN.callSerially(s::toggleDarkMode); + } + } + + public static void goActiveSectionForDesktopMenu(Section section) { + CodenameOneSettings s = activeSettings; + if (s != null) { + CN.callSerially(() -> s.go(section)); + } + } + + public static void showActiveAboutForDesktopMenu() { + CodenameOneSettings s = activeSettings; + if (s != null) { + CN.callSerially(s::showAboutDialog); + } + } + + @Override + public void runApp() { + activeSettings = this; + Toolbar.setGlobalToolbar(true); + darkMode = Preferences.get(PREF_DARK_MODE, Boolean.TRUE.equals(CN.isDarkMode())); + String forcedDark = System.getProperty("settings.darkMode"); + if ("true".equals(forcedDark) || "false".equals(forcedDark)) { + darkMode = "true".equals(forcedDark); + } + String forcedSection = System.getProperty("settings.section"); + if (forcedSection != null) { + for (Section candidate : Section.values()) { + if (candidate.name().equalsIgnoreCase(forcedSection.replace('-', '_'))) { + section = candidate; + break; + } + } + } + String forcedHintFilter = System.getProperty("settings.hintFilter"); + if (forcedHintFilter != null) { + hintFilter = forcedHintFilter; + } + fontDeltaPx = Preferences.get(PREF_FONT_DELTA, 0); + CN.setDarkMode(Boolean.valueOf(darkMode)); + loadProject(); + installErrorHandlers(); + + form = new Form("Codename One Settings", new BorderLayout()) { + @Override + public void keyPressed(int keyCode) { + if (handleFontShortcut(keyCode)) { + return; + } + super.keyPressed(keyCode); + } + + @Override + protected boolean pinch(float scale) { + handleFontPinch(scale); + return true; + } + + @Override + protected void pinchReleased(int x, int y) { + fontPinchAccumulator = 1f; + super.pinchReleased(x, y); + } + }; + form.setUIID(uiid("SettingsForm")); + form.getTextSelection().setEnabled(true); + installMenuCommands(); + buildShell(); + form.show(); + if ("true".equals(System.getProperty("settings.openMenu")) && toolbarMenuButton != null) { + CN.callSerially(() -> showAppMenu(toolbarMenuButton)); + } + } + + private void loadProject() { + binding = ProjectIO.loadBinding(); + if (binding != null) { + settings = new SettingsProperties(binding.settings()); + try { + settings.load(); + } catch (Exception ex) { + Log.e(ex); + } + buildHints = loadBuildHints(binding.buildHintsDoc()); + } + } + + private BuildHintCatalog loadBuildHints(String docPath) { + InputStream in = null; + if (docPath != null && docPath.length() > 0) { + try { + String url = ProjectIO.fsUrl(docPath); + FileSystemStorage fs = FileSystemStorage.getInstance(); + if (fs.exists(url)) { + in = fs.openInputStream(url); + return BuildHintCatalog.fromAsciiDoc(Util.readToString(in, "UTF-8")); + } + } catch (Exception ex) { + Log.e(ex); + } finally { + Util.cleanup(in); + in = null; + } + } + try { + in = getClass().getResourceAsStream("/com/codename1/settings/hints/Advanced-Topics-Under-The-Hood.asciidoc"); + if (in == null) { + return BuildHintCatalog.fallback(); + } + return BuildHintCatalog.fromAsciiDoc(Util.readToString(in, "UTF-8")); + } catch (Exception ex) { + Log.e(ex); + return BuildHintCatalog.fallback(); + } finally { + Util.cleanup(in); + } + } + + private void installErrorHandlers() { + CN.addEdtErrorHandler(evt -> { + evt.consume(); + Throwable err = evt.getSource() instanceof Throwable ? (Throwable) evt.getSource() : null; + if (err != null) { + Log.e(err); + } + ToastBar.showErrorMessage(err == null ? "An internal Settings error occurred." : err.getMessage()); + }); + CN.addNetworkErrorListener(evt -> { + evt.consume(); + if (evt.getError() != null) { + Log.e(evt.getError()); + } + ToastBar.showErrorMessage("Network request failed" + + (evt.getResponseCode() > 0 ? " (HTTP " + evt.getResponseCode() + ")" : "")); + }); + } + + private void buildShell() { + form.getContentPane().removeAll(); + page = new Container(BoxLayout.y()); + renderPage(); + pageViewport = new Container(BoxLayout.y()); + pageViewport.setScrollableY(true); + pageViewport.setUIID(uiid("SettingsPage")); + TableLayout contentLayout = new TableLayout(1, 2); + Container pageRow = new Container(contentLayout); + int contentPercent = Display.getInstance().getDisplayWidth() < 1100 || section == Section.EXTENSIONS ? 100 : 72; + pageRow.add(contentLayout.createConstraint(0, 0).widthPercentage(contentPercent), page); + if (contentPercent < 100) { + pageRow.add(contentLayout.createConstraint(0, 1).widthPercentage(100 - contentPercent), new Container()); + } + pageViewport.add(pageRow); + form.add(BorderLayout.NORTH, configureToolbar()); + form.add(BorderLayout.CENTER, BorderLayout.center(pageViewport).add(BorderLayout.WEST, rail())); + applyFontScale(form); + form.revalidate(); + } + + private Button toolbarIcon(char icon) { + Button b = new Button("", uiid("SettingsToolbarButton")); + b.setMaterialIcon(icon, 3.8f); + return b; + } + + private Image toolbarMarkImage() { + try { + Image image = Image.createImage("/icon.png"); + int size = CN.convertToPixels(2.05f); + return image == null ? null : image.scaled(size, size); + } catch (Exception ex) { + return null; + } + } + + private String toolbarAppName() { + if (settings == null) { + return ""; + } + String name = settings.get("codename1.displayName", ""); + return name == null || name.length() == 0 ? "" : name; + } + + private Container configureToolbar() { + Container tb = new Container(); + tb.setLayout(new BorderLayout()); + tb.setUIID(uiid("SettingsChrome")); + + Container left = new Container(BoxLayout.x()); + Button brand = new Button("Settings", uiid("SettingsToolbarBrand")); + brand.setMaterialIcon(FontImage.MATERIAL_SETTINGS, 3.8f); + left.add(brand); + String appName = toolbarAppName(); + if (appName.length() > 0) { + Label appNameLabel = new Label(appName, uiid("SettingsAppName")); + left.add(appNameLabel); + } + + Container path = new Container(new BorderLayout()); + path.setUIID(uiid("SettingsPathChip")); + Label pathText = new Label(toolbarPathText(), uiid("SettingsPathText")); + pathText.setEndsWith3Points(true); + Label pathIcon = new Label("", uiid("SettingsPathIcon")); + pathIcon.setMaterialIcon(FontImage.MATERIAL_FOLDER_OPEN, 3.2f); + path.add(BorderLayout.WEST, pathIcon); + path.add(BorderLayout.CENTER, pathText); + + Container right = new Container(BoxLayout.x()); + Button open = toolbarIcon(FontImage.MATERIAL_FOLDER_OPEN); + open.addActionListener(e -> openProjectFolder()); + Button save = new Button("Save", uiid("SettingsSave")); + save.setMaterialIcon(FontImage.MATERIAL_SAVE, 3.2f); + save.addActionListener(e -> saveSettings()); + Button theme = toolbarIcon(darkMode ? FontImage.MATERIAL_BRIGHTNESS_5 : FontImage.MATERIAL_BRIGHTNESS_3); + theme.addActionListener(e -> toggleDarkMode()); + Button menu = toolbarIcon(FontImage.MATERIAL_MENU); + menu.addActionListener(e -> showAppMenu(menu)); + toolbarMenuButton = menu; + right.add(open).add(save).add(theme).add(menu); + + tb.add(BorderLayout.WEST, left); + tb.add(BorderLayout.CENTER, path); + tb.add(BorderLayout.EAST, right); + return tb; + } + + private String toolbarPathText() { + String path = binding == null || binding.projectDir() == null ? "No project selected" : binding.projectDir(); + if (!path.startsWith("file:") && binding != null && binding.projectDir() != null) { + path = "file:" + path; + } + if (path.length() <= 86) { + return path; + } + return path.substring(0, 48) + "..." + path.substring(path.length() - 30); + } + + private void showAppMenu(Component anchor) { + InteractionDialog d = new InteractionDialog(); + d.setLayout(new BorderLayout()); + d.setDisposeWhenPointerOutOfBounds(true); + d.setAnimateShow(true); + Container menu = new Container(BoxLayout.y()); + menu.setUIID(uiid("SettingsPopupMenu")); + popupAction(menu, "Update", FontImage.MATERIAL_REFRESH, () -> { + d.dispose(); + extensionCatalog = null; + if (section == Section.EXTENSIONS) { + renderPage(); + animatePage(); + } else { + ToastBar.showMessage("Extension catalog will refresh when opened.", FontImage.MATERIAL_REFRESH); + } + }); + popupAction(menu, "Save", FontImage.MATERIAL_SAVE, () -> { + d.dispose(); + saveSettings(); + }); + popupToggle(menu, "Dark Mode", darkMode, () -> { + d.dispose(); + toggleDarkMode(); + }); + popupAction(menu, "Close", FontImage.MATERIAL_CANCEL, () -> { + d.dispose(); + Display.getInstance().exitApplication(); + }); + d.add(BorderLayout.CENTER, menu); + d.setUIID(uiid("SettingsPopupMenu")); + d.getContentPane().setUIID(uiid("SettingsPopupMenu")); + int right = CN.convertToPixels(2f); + int top = anchor.getAbsoluteY() + anchor.getHeight() - CN.convertToPixels(0.5f); + int menuWidth = Display.getInstance().getDisplayWidth() * 18 / 100; + int menuHeight = menu.getStyle().getVerticalPadding() + d.getStyle().getVerticalPadding(); + for (int i = 0; i < menu.getComponentCount(); i++) { + menuHeight += menu.getComponentAt(i).getPreferredH(); + } + int left = Math.max(CN.convertToPixels(1f), Display.getInstance().getDisplayWidth() - menuWidth - right); + int bottom = Math.max(CN.convertToPixels(1f), Display.getInstance().getDisplayHeight() - top - menuHeight); + d.show(top, bottom, left, right); + } + + private void showAboutDialog() { + Dialog d = new Dialog("About Codename One Settings", new BorderLayout()); + Container content = new Container(BoxLayout.y()); + content.setUIID(uiid("SettingsDialogContent")); + content.add(new Label("Codename One Settings", uiid("SettingsCardTitle"))); + content.add(new Label("Version " + appVersion(), uiid("SettingsRowMeta"))); + content.add(new Label("Java " + prop("java.version", "unknown"), uiid("SettingsRowMeta"))); + content.add(new Label(prop("java.vm.name", "JVM"), uiid("SettingsRowMeta"))); + content.add(new Label(prop("os.name", "OS") + " " + prop("os.version", ""), uiid("SettingsRowMeta"))); + if (binding != null && binding.projectDir() != null) { + Label project = new Label(binding.projectDir(), uiid("SettingsRowMeta")); + project.setEndsWith3Points(true); + content.add(project); + } + Button close = new Button("Close", uiid("SettingsPrimary")); + close.addActionListener(e -> d.dispose()); + content.add(FlowLayout.encloseRight(close)); + d.add(BorderLayout.CENTER, content); + d.showPopupDialog(toolbarMenuButton == null ? form : toolbarMenuButton); + } + + private String appVersion() { + return prop("settings.version", "development"); + } + + private String prop(String key, String fallback) { + String value = System.getProperty(key); + return value == null || value.length() == 0 ? fallback : value; + } + + private void popupAction(Container menu, String text, char icon, Runnable action) { + Container row = new Container(new BorderLayout()); + row.setUIID(uiid("SettingsPopupItem")); + Button b = new Button(text, uiid("SettingsPopupLabel")); + b.addActionListener(e -> action.run()); + Label iconLabel = new Label("", uiid("SettingsPopupIcon")); + iconLabel.setMaterialIcon(icon, 2.8f); + row.add(BorderLayout.CENTER, b); + row.add(BorderLayout.EAST, iconLabel); + row.setLeadComponent(b); + menu.add(row); + } + + private void popupToggle(Container menu, String text, boolean on, Runnable action) { + Container row = new Container(new BorderLayout()); + row.setUIID(uiid("SettingsPopupItem")); + Label label = new Label(text, uiid("SettingsPopupToggleLabel")); + Switch sw = new Switch(uiid("SettingsSwitch")); + sw.setValue(on); + sw.addActionListener(e -> action.run()); + row.add(BorderLayout.CENTER, label).add(BorderLayout.EAST, sw); + menu.add(row); + } + + private void openProjectFolder() { + if (binding != null && binding.projectDir() != null) { + Display.getInstance().execute(ProjectIO.fsUrl(binding.projectDir())); + } + } + + private Container rail() { + Container side = new Container(BoxLayout.y()); + side.setUIID(uiid("SettingsRail")); + nav(side, Section.BASIC, FontImage.MATERIAL_TUNE, "Basic"); + nav(side, Section.BUILD_HINTS, FontImage.MATERIAL_TUNE, "Hints"); + nav(side, Section.EXTENSIONS, FontImage.MATERIAL_EXTENSION, "Ext"); + return side; + } + + private void nav(Container side, Section target, char icon, String text) { + Button item = new Button(text, uiid(section == target ? "SettingsRailItemSelected" : "SettingsRailItem")); + item.setTextPosition(Component.BOTTOM); + item.setMaterialIcon(icon, 3.8f); + item.addActionListener(e -> go(target)); + side.add(item); + } + + private void renderPage() { + page.removeAll(); + if (binding == null || settings == null) { + page.add(pageTitle("No Maven Project", "Run this from a Codename One Maven project using mvn cn1:settings.")); + return; + } + switch (section) { + case BASIC -> renderBasic(); + case BUILD_HINTS -> renderBuildHints(); + case EXTENSIONS -> renderExtensions(); + case ADVANCED -> renderAdvanced(); + } + page.revalidate(); + } + + private void renderBasic() { + page.add(pageTitle("Basic", "Core application settings - title, version, package and icon.")); + Container grid = new Container(new GridLayout(3, 2)); + grid.setUIID(uiid("SettingsFieldGrid")); + grid.add(textFieldGroup("Title", "codename1.displayName", false)); + grid.add(textFieldGroup("Description", "codename1.description", false)); + grid.add(textFieldGroup("Version", "codename1.version", false)); + grid.add(textFieldGroup("Vendor", "codename1.vendor", false)); + grid.add(textFieldGroup("Package Name", "codename1.packageName", false)); + grid.add(textFieldGroup("Main Class", "codename1.mainName", false)); + page.add(grid); + page.add(iconDrop()); + page.add(divider()); + Label premiumTitle = new Label("PREMIUM FEATURES", uiid("SettingsSectionTag")); + page.add(premiumTitle); + Container premium = new Container(new GridLayout(1, 2)); + premium.setUIID(uiid("SettingsFieldGrid")); + premium.add(versionedBuildField()); + premium.add(includeSourceField()); + page.add(premium); + } + + private Component versionedBuildField() { + Container fieldGroup = new Container(BoxLayout.y()); + fieldGroup.setUIID(uiid("SettingsFieldGroup")); + Label fieldLabel = new Label("Versioned Build", uiid("SettingsFieldLabel")); + Picker version = new Picker(); + version.setType(Display.PICKER_TYPE_STRINGS); + version.setStrings(versionChoices()); + version.setUIID(uiid("SettingsField")); + String current = settings.getBuildHint("build.version"); + version.setSelectedString(current == null || current.length() == 0 ? "none" : current); + version.addActionListener(e -> { + String selected = version.getSelectedString(); + if (selected == null || "none".equals(selected)) { + settings.removeBuildHint("build.version"); + } else { + settings.setBuildHint("build.version", selected); + } + }); + fieldGroup.add(fieldLabel).add(version); + return fieldGroup; + } + + private String[] versionChoices() { + return new String[]{"none", "master", "7.0.250", "7.0.249", "7.0.248", "7.0.247", "7.0.246", "7.0.245"}; + } + + private Component includeSourceField() { + Container fieldGroup = new Container(BoxLayout.y()); + fieldGroup.setUIID(uiid("SettingsFieldGroup")); + Label fieldLabel = new Label("Include Source", uiid("SettingsFieldLabel")); + Container row = new Container(new BorderLayout()); + row.setUIID(uiid("SettingsToggleRow")); + Label label = new Label("Bundle project source", uiid("SettingsRowMeta")); + row.add(BorderLayout.CENTER, label); + Switch includeSource = new Switch(uiid("SettingsSwitch")); + includeSource.setValue("1".equals(settings.getBuildHint("build.incSources"))); + includeSource.addActionListener(e -> { + if (includeSource.isValue()) { + settings.setBuildHint("build.incSources", "1"); + } else { + settings.removeBuildHint("build.incSources"); + } + }); + row.add(BorderLayout.EAST, includeSource); + fieldGroup.add(fieldLabel).add(row); + return fieldGroup; + } + + private void renderBuildHints() { + page.add(pageTitle("Build Hints", "Search known hints from the developer guide, or add arbitrary build arguments.")); + Container filter = new Container(new BorderLayout()); + filter.setUIID(uiid("SettingsFilterRow")); + TextField search = new TextField(hintFilter, "Search build hints"); + search.setUIID(uiid("SettingsField")); + search.addDataChangedListener((type, index) -> { + hintFilter = search.getText() == null ? "" : search.getText(); + renderBuildHintsList(); + }); + filter.add(BorderLayout.CENTER, search); + page.add(filter); + page.add(customHintRow()); + Container list = new Container(BoxLayout.y()); + list.setName("buildHintsList"); + list.setUIID(uiid("SettingsList")); + page.add(list); + renderBuildHintsList(); + } + + private Component customHintRow() { + TableLayout layout = new TableLayout(1, 3); + Container row = new Container(layout); + row.setUIID(uiid("SettingsRow")); + TextField key = new TextField("", "custom.hint.name"); + key.setUIID(uiid("SettingsField")); + TextField value = new TextField("", "value"); + value.setUIID(uiid("SettingsField")); + Container keyCell = BorderLayout.center(key); + keyCell.setUIID(uiid("SettingsHintKeyCell")); + Container valueCell = BorderLayout.center(value); + valueCell.setUIID(uiid("SettingsHintValueCell")); + Button add = new Button("Add", uiid("SettingsOutline")); + add.addActionListener(e -> { + String k = key.getText() == null ? "" : key.getText().trim(); + if (k.startsWith(SettingsProperties.BUILD_HINT_PREFIX)) { + k = k.substring(SettingsProperties.BUILD_HINT_PREFIX.length()); + } + if (k.length() == 0) { + ToastBar.showErrorMessage("Enter a build hint name."); + return; + } + settings.setBuildHint(k, value.getText() == null ? "" : value.getText()); + key.setText(""); + value.setText(""); + renderBuildHintsList(); + animatePage(); + }); + row.add(layout.createConstraint(0, 0).widthPercentage(43), keyCell); + row.add(layout.createConstraint(0, 1).widthPercentage(43), valueCell); + row.add(layout.createConstraint(0, 2).widthPercentage(14), add); + return row; + } + + private void renderBuildHintsList() { + Container list = (Container) page.getComponentAt(page.getComponentCount() - 1); + list.removeAll(); + Map rows = new LinkedHashMap(); + for (String key : settings.buildHintKeys()) { + BuildHintMetadata meta = buildHints.get(key); + rows.put(key, meta == null + ? new BuildHintMetadata(key, "Custom build hint.", null, "custom") + : meta); + } + for (BuildHintMetadata meta : buildHints.search(hintFilter)) { + rows.put(meta.name(), meta); + } + for (BuildHintMetadata meta : rows.values()) { + if (!meta.matches(hintFilter)) { + continue; + } + list.add(hintRow(meta)); + } + list.revalidate(); + } + + private void animatePage() { + if (form != null) { + form.animateLayout(180); + } + } + + private Component hintRow(BuildHintMetadata meta) { + Container row = new Container(BoxLayout.y()); + row.setUIID(uiid("SettingsRow")); + boolean active = hasBuildHint(meta.name()); + String value = active ? settings.getBuildHint(meta.name()) : ""; + BuildHintType effectiveType = effectiveHintType(meta, value); + Container text = new Container(BoxLayout.y()); + Label name = new Label(meta.name(), uiid("SettingsRowTitle")); + name.setEndsWith3Points(true); + Container metaLine = new Container(new FlowLayout(Component.LEFT, Component.CENTER)); + Label desc = new Label(meta.platform() + " / " + effectiveType, uiid("SettingsRowMeta")); + metaLine.add(desc); + if (active) { + metaLine.add(new Label("Active", uiid("SettingsActiveBadge"))); + } + text.add(name).add(metaLine); + if (active) { + text.add(activeHintEditor(meta, value, effectiveType)); + } else { + Container controls = new Container(new FlowLayout(Component.LEFT, Component.CENTER)); + controls.setUIID(uiid("SettingsHintEditor")); + Button add = new Button("Add", uiid("SettingsOutline")); + add.setMaterialIcon(FontImage.MATERIAL_ADD, 1.2f); + add.addActionListener(e -> { + settings.setBuildHint(meta.name(), defaultHintValue(meta)); + renderBuildHintsList(); + animatePage(); + }); + controls.add(add); + Container header = new Container(new BorderLayout()); + header.add(BorderLayout.CENTER, text); + header.add(BorderLayout.EAST, controls); + row.add(header); + } + if (active) { + row.add(text); + } + TextArea details = new TextArea(meta.description()); + details.setUIID(uiid("SettingsRowText")); + details.setEditable(false); + details.setFocusable(false); + details.setRows(descriptionRows(meta.description())); + details.setGrowByContent(true); + row.add(details); + return row; + } + + private Component activeHintEditor(BuildHintMetadata meta, String value, BuildHintType effectiveType) { + TableLayout editorLayout = new TableLayout(1, 2); + Container editor = new Container(editorLayout); + editor.setUIID(uiid("SettingsHintEditor")); + Container controls = new Container(new BorderLayout()); + if (effectiveType == BuildHintType.BOOLEAN) { + Switch toggle = new Switch(uiid("SettingsSwitch")); + toggle.setValue("true".equalsIgnoreCase(value)); + toggle.addActionListener(e -> settings.setBuildHint(meta.name(), toggle.isValue() ? "true" : "false")); + controls.add(BorderLayout.CENTER, + new Container(new FlowLayout(Component.RIGHT, Component.CENTER)).add(toggle)); + } else { + TextField valueField = new TextField(value, "value"); + valueField.setUIID(uiid(isValidHintValue(meta, value) ? "SettingsField" : "SettingsFieldError")); + configureHintField(valueField, meta); + valueField.addDataChangedListener((type, index) -> { + String next = valueField.getText() == null ? "" : valueField.getText().trim(); + if (isValidHintValue(meta, next)) { + settings.setBuildHint(meta.name(), next); + valueField.setUIID(uiid("SettingsField")); + } else { + valueField.setUIID(uiid("SettingsFieldError")); + } + valueField.repaint(); + }); + controls.add(BorderLayout.CENTER, valueField); + } + Button remove = new Button("", uiid("SettingsSmallIconButton")); + remove.setMaterialIcon(FontImage.MATERIAL_DELETE, 2.2f); + remove.addActionListener(e -> { + settings.removeBuildHint(meta.name()); + renderBuildHintsList(); + animatePage(); + }); + controls.add(BorderLayout.EAST, remove); + editor.add(editorLayout.createConstraint(0, 0).widthPercentage(72), new Container()); + editor.add(editorLayout.createConstraint(0, 1).widthPercentage(28), controls); + return editor; + } + + private BuildHintType effectiveHintType(BuildHintMetadata meta, String value) { + if (meta.type() == BuildHintType.BOOLEAN + || "true".equalsIgnoreCase(value) + || "false".equalsIgnoreCase(value)) { + return BuildHintType.BOOLEAN; + } + return meta.type(); + } + + private boolean hasBuildHint(String key) { + return settings.keys().contains(SettingsProperties.fullBuildHintKey(key)); + } + + private String defaultHintValue(BuildHintMetadata meta) { + if (meta.type() == BuildHintType.BOOLEAN) { + return "true"; + } + if (meta.type() == BuildHintType.INTEGER) { + return "0"; + } + return ""; + } + + private int descriptionRows(String text) { + int len = text == null ? 0 : text.length(); + if (len > 260) { + return 5; + } + if (len > 170) { + return 4; + } + if (len > 95) { + return 3; + } + return 2; + } + + private void configureHintField(TextField field, BuildHintMetadata meta) { + if (meta.type() == BuildHintType.INTEGER) { + field.setConstraint(TextArea.NUMERIC); + } else if (meta.type() == BuildHintType.URL) { + field.setConstraint(TextArea.URL); + } else if (meta.type() == BuildHintType.SECRET) { + field.setConstraint(TextArea.PASSWORD); + } + } + + private boolean isValidHintValue(BuildHintMetadata meta, String value) { + if (value == null || value.trim().length() == 0) { + return true; + } + String v = value.trim(); + if (meta.type() == BuildHintType.INTEGER) { + return isDigits(v); + } + if (meta.type() == BuildHintType.VERSION) { + return isVersion(v) || "master".equals(v); + } + if (meta.type() == BuildHintType.URL) { + return (v.startsWith("http://") || v.startsWith("https://")) && v.indexOf('.', v.indexOf("://") + 3) > 0; + } + return true; + } + + private boolean isDigits(String text) { + if (text == null || text.length() == 0) { + return false; + } + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + return true; + } + + private boolean isVersion(String text) { + if (text == null || text.length() == 0 || !isDigit(text.charAt(0))) { + return false; + } + boolean lastWasSeparator = false; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (isDigit(c) || isAsciiLetter(c)) { + lastWasSeparator = false; + continue; + } + if (c == '.' || c == '_' || c == '-') { + if (lastWasSeparator) { + return false; + } + lastWasSeparator = true; + continue; + } + return false; + } + return !lastWasSeparator; + } + + private boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } + + private boolean isAsciiLetter(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + + private void editHint(BuildHintMetadata meta) { + Dialog d = new Dialog(meta == null ? "Add Build Hint" : "Edit Build Hint", new BorderLayout()); + d.setDisposeWhenPointerOutOfBounds(true); + Container content = new Container(BoxLayout.y()); + content.setUIID(uiid("SettingsDialogContent")); + TextField key = new TextField(meta == null ? "" : meta.name(), "hint.name"); + key.setUIID(uiid("SettingsField")); + TextArea value = new TextArea(meta == null ? "" : settings.getBuildHint(meta.name())); + value.setHint("value"); + value.setUIID(uiid("SettingsArea")); + value.setRows(5); + content.add(new Label("Key", uiid("SettingsFieldLabel"))).add(key); + content.add(new Label("Value", uiid("SettingsFieldLabel"))).add(value); + Button ok = new Button(new Command("Apply")); + ok.setUIID(uiid("SettingsPrimary")); + Button cancel = new Button(new Command("Cancel")); + cancel.setUIID(uiid("SettingsOutline")); + content.add(FlowLayout.encloseRight(cancel, ok)); + d.add(BorderLayout.CENTER, content); + if (ok.getCommand() == d.showDialog()) { + String k = key.getText() == null ? "" : key.getText().trim(); + if (k.startsWith(SettingsProperties.BUILD_HINT_PREFIX)) { + k = k.substring(SettingsProperties.BUILD_HINT_PREFIX.length()); + } + if (k.length() > 0) { + settings.setBuildHint(k, value.getText() == null ? "" : value.getText()); + renderBuildHints(); + buildShell(); + } + } + } + + private void renderExtensions() { + page.add(pageTitle("Extensions", "Install & update 3rd-party libraries (cn1libs) and native extensions.")); + Container searchCard = new Container(new BorderLayout()); + searchCard.setUIID(uiid("SettingsSearchBox")); + Label searchIcon = new Label("", uiid("SettingsSearchIcon")); + searchIcon.setMaterialIcon(FontImage.MATERIAL_SEARCH, 2.8f); + TextField query = new TextField(extensionFilter, "Search extensions..."); + query.setUIID(uiid("SettingsSearchField")); + query.addFocusListener(new FocusListener() { + @Override + public void focusGained(Component cmp) { + searchCard.setUIID(uiid("SettingsSearchBoxFocused")); + searchCard.repaint(); + } + + @Override + public void focusLost(Component cmp) { + searchCard.setUIID(uiid("SettingsSearchBox")); + searchCard.repaint(); + } + }); + Container results = new Container(BoxLayout.y()); + query.addDataChangedListener((type, index) -> { + extensionFilter = query.getText() == null ? "" : query.getText(); + renderExtensionList(results); + }); + searchCard.add(BorderLayout.WEST, searchIcon); + searchCard.add(BorderLayout.CENTER, query); + page.add(searchCard); + page.add(results); + renderExtensionList(results); + if (extensionCatalog == null) { + loadExtensions(results); + } + } + + private void renderExtensionList(Container results) { + results.removeAll(); + List found = extensionCatalog == null ? MavenCentralSearch.curated() : extensionCatalog; + Container grid = new Container(BoxLayout.y()); + grid.setUIID(uiid("SettingsExtensionGrid")); + Container row = null; + int count = 0; + int columns = extensionColumns(); + for (ExtensionDescriptor d : found) { + if (matchesExtension(d, extensionFilter)) { + if (count % columns == 0) { + row = new Container(new GridLayout(1, columns)); + row.setUIID(uiid("SettingsExtensionRow")); + grid.add(row); + } + row.add(extensionRow(d)); + count++; + } + } + if (count == 0) { + results.add(new Label(extensionCatalog == null ? "Loading extension catalog..." : "No extensions match the current filter.", + uiid("SettingsRowMeta"))); + } else { + results.add(grid); + } + results.revalidate(); + } + + private int extensionColumns() { + int width = Display.getInstance().getDisplayWidth(); + if (width < 900) { + return 2; + } + return 3; + } + + private void loadExtensions(Container results) { + Display.getInstance().startThread(() -> { + List loaded = null; + try { + loaded = loadBundledCn1LibCatalog(); + } catch (Exception ex) { + Log.e(ex); + } + final List catalog = mergeCatalogs(loaded); + CN.callSerially(() -> { + extensionCatalog = catalog; + renderExtensionList(results); + }); + + try { + List refreshed = ExtensionCatalogMerger.preserveCompatibilityMetadata( + fetchCn1LibCatalog(), loaded); + final List refreshedCatalog = mergeCatalogs(refreshed); + CN.callSerially(() -> { + extensionCatalog = refreshedCatalog; + renderExtensionList(results); + }); + } catch (Exception ex) { + Log.e(ex); + } + }, "SettingsExtensionCatalog").start(); + } + + private List fetchCn1LibCatalog() throws Exception { + ConnectionRequest req = new ConnectionRequest(); + req.setUrl(EXTENSIONS_URL); + req.setPost(false); + req.setContentType("application/xml"); + NetworkManager.getInstance().addToQueueAndWait(req); + if (req.getResponseCode() >= 400 || req.getResponseData() == null) { + throw new java.io.IOException("CN1Libs.xml returned HTTP " + req.getResponseCode()); + } + Element root = parseExtensionXml(new InputStreamReader(new ByteArrayInputStream(req.getResponseData()), "UTF-8")); + return parseExtensionRoot(root); + } + + private List loadBundledCn1LibCatalog() throws Exception { + InputStream in = getClass().getResourceAsStream("/com/codename1/settings/extensions/CN1Libs.xml"); + if (in == null) { + return new ArrayList(); + } + try { + Element root = parseExtensionXml(new InputStreamReader(in, "UTF-8")); + return parseExtensionRoot(root); + } finally { + Util.cleanup(in); + } + } + + private Element parseExtensionXml(InputStreamReader reader) { + XMLParser parser = new XMLParser(); + parser.setCaseSensitive(true); + parser.setIncludeWhitespacesBetweenTags(false); + return parser.parse(reader); + } + + private List parseExtensionRoot(Element root) { + ArrayList out = new ArrayList(); + for (int i = 0; i < root.getNumChildren(); i++) { + ExtensionDescriptor descriptor = parseExtension(root.getChildAt(i)); + if (descriptor != null && descriptor.name().length() > 0) { + out.add(descriptor); + } + } + Collections.sort(out, new Comparator() { + @Override + public int compare(ExtensionDescriptor a, ExtensionDescriptor b) { + return a.name().compareToIgnoreCase(b.name()); + } + }); + return out; + } + + private List mergeCatalogs(List xml) { + LinkedHashMap out = new LinkedHashMap(); + for (ExtensionDescriptor d : MavenCentralSearch.curated()) { + out.put(extensionCatalogKey(d), d); + } + if (xml != null) { + for (ExtensionDescriptor d : xml) { + String key = extensionCatalogKey(d); + ExtensionDescriptor existing = out.get(key); + if (existing == null + || d.dependency() != null + || existing.fileName().length() == 0 && d.fileName().length() > 0) { + out.put(key, d); + } + } + } + ArrayList merged = new ArrayList(out.values()); + Collections.sort(merged, new Comparator() { + @Override + public int compare(ExtensionDescriptor a, ExtensionDescriptor b) { + return displayExtensionName(a).compareToIgnoreCase(displayExtensionName(b)); + } + }); + return merged; + } + + private String extensionCatalogKey(ExtensionDescriptor descriptor) { + String name = displayExtensionName(descriptor).toLowerCase(); + StringBuilder key = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char ch = name.charAt(i); + if (ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9') { + key.append(ch); + } + } + return key.toString(); + } + + private ExtensionDescriptor parseExtension(Element extension) { + if (extension == null) { + return null; + } + String fileName = extension.getAttribute("fileName"); + String name = normalizedExtensionName(childText(extension, "name")); + String desc = childText(extension, "description"); + String link = childText(extension, "link"); + String license = childText(extension, "license"); + String platforms = childText(extension, "platforms"); + String author = childText(extension, "contributed"); + String tags = childText(extension, "tags"); + String dependencies = childText(extension, "dependencies"); + String version = childText(extension, "version"); + String status = childText(extension, "status"); + String warning = childText(extension, "warning"); + MavenDependency dep = parseMavenDependency(extension.getFirstChildByTagName("maven")); + return new ExtensionDescriptor(name, desc, dep, dep != null, fileName, link, license, platforms, + author, tags, dependencies, version, status, warning); + } + + private String childText(Element parent, String tag) { + Element child = parent.getFirstChildByTagName(tag); + if (child == null || child.getNumChildren() == 0) { + return ""; + } + Element text = child.getChildAt(0); + return text == null || text.getText() == null ? "" : text.getText().trim(); + } + + private MavenDependency parseMavenDependency(Element maven) { + if (maven == null) { + return null; + } + Element dependency = maven.getFirstChildByTagName("dependency"); + Element source = dependency == null ? maven : dependency; + String group = childText(source, "groupId"); + String artifact = childText(source, "artifactId"); + String version = childText(source, "version"); + String type = childText(source, "type"); + if (group.length() == 0 || artifact.length() == 0 || version.length() == 0) { + return null; + } + return new MavenDependency(group, artifact, version, type); + } + + private boolean matchesExtension(ExtensionDescriptor d, String filter) { + if (filter == null || filter.trim().length() == 0) { + return true; + } + String q = filter.toLowerCase(); + return d.name().toLowerCase().contains(q) + || d.description().toLowerCase().contains(q) + || d.fileName().toLowerCase().contains(q) + || d.license().toLowerCase().contains(q) + || d.platforms().toLowerCase().contains(q) + || d.author().toLowerCase().contains(q) + || d.tags().toLowerCase().contains(q) + || d.dependencies().toLowerCase().contains(q) + || (d.dependency() != null && d.dependency().coordinates().toLowerCase().contains(q)); + } + + private Component extensionRow(ExtensionDescriptor descriptor) { + boolean expanded = Boolean.TRUE.equals(expandedExtensions.get(extensionKey(descriptor))); + Container row = new Container(new BorderLayout()); + row.setUIID(uiid("SettingsExtensionCard")); + Container text = new Container(BoxLayout.y()); + text.setUIID(uiid("SettingsExtensionBody")); + Label title = new Label(displayExtensionName(descriptor), uiid("SettingsExtensionTitle")); + title.setEndsWith3Points(true); + title.addPointerReleasedListener(e -> { + expandedExtensions.put(extensionKey(descriptor), !expanded); + renderPage(); + animatePage(); + }); + text.add(title); + Container description = new Container(BoxLayout.y()); + addExtensionDescription(description, descriptor.description(), expanded ? 4 : 2); + text.add(description); + Container meta = new Container(new GridLayout(1, 2)); + meta.setUIID(uiid("SettingsExtensionMetaGrid")); + meta.add(extensionMeta("License", displayLicense(descriptor.license()))); + meta.add(extensionMeta("Platforms", descriptor.platforms())); + text.add(meta); + Container tagRow = extensionTags(descriptor); + if (tagRow.getComponentCount() > 0) { + text.add(tagRow); + } + if (expanded) { + if (descriptor.warning().length() > 0) { + text.add(new Label(descriptor.warning(), uiid("SettingsExtensionWarning"))); + } + if (descriptor.dependency() != null) { + Label dependency = new Label(displayDependency(descriptor.dependency()), uiid("SettingsExtensionMeta")); + dependency.setEndsWith3Points(true); + text.add(dependency); + } else if (descriptor.fileName().length() > 0) { + Label file = new Label(descriptor.fileName(), uiid("SettingsExtensionMeta")); + file.setEndsWith3Points(true); + text.add(file); + text.add(new Label("Legacy cn1lib - may be out of date", uiid("SettingsExtensionWarning"))); + } + if (descriptor.author().length() > 0) { + Label author = new Label("By " + descriptor.author(), uiid("SettingsExtensionMeta")); + author.setEndsWith3Points(true); + text.add(author); + } + } + row.add(BorderLayout.CENTER, text); + Container actions = new Container(new BorderLayout()); + if (descriptor.dependency() != null) { + boolean installed = isDependencyInstalled(descriptor.dependency()); + Button add = new Button(installed ? "Installed ✓" : "Download", + uiid(installed ? "SettingsSave" : "SettingsExtensionPrimary")); + add.addActionListener(e -> { + if (installed) { + offerUninstall(descriptor); + } else { + installMavenExtension(descriptor); + } + }); + actions.add(BorderLayout.CENTER, add); + } else if (descriptor.fileName().length() > 0) { + boolean installed = isLegacyCn1LibInstalled(descriptor); + Button install = new Button(installed ? "Installed ✓" : "Install", + uiid(installed ? "SettingsSave" : "SettingsExtensionPrimary")); + install.addActionListener(e -> { + if (installed) { + offerUninstall(descriptor); + } else { + installLegacyCn1Lib(descriptor); + } + }); + actions.add(BorderLayout.CENTER, install); + } + row.add(BorderLayout.SOUTH, actions); + return row; + } + + private String normalizedExtensionName(String name) { + if ("Apple AppTrackingTransparency library".equals(name)) { + return "Apple AppTrackingTransparency"; + } + if ("BouncyCastle SDK".equals(name) || "Bouncy Castle SDK".equals(name)) { + return "BouncyCastle SDK"; + } + return name == null ? "" : name.trim(); + } + + private String displayExtensionName(ExtensionDescriptor descriptor) { + return normalizedExtensionName(descriptor.name()); + } + + private String displayLicense(String license) { + if (license == null) { + return ""; + } + String value = license.trim(); + if ("MIT License".equalsIgnoreCase(value)) { + return "MIT"; + } + if ("GPL+Classpath Exception".equalsIgnoreCase(value)) { + return "MIT"; + } + return value; + } + + private boolean isDependencyInstalled(MavenDependency dependency) { + if (binding == null || binding.pom() == null || binding.pom().length() == 0 || dependency == null) { + return false; + } + InputStream in = null; + try { + in = FileSystemStorage.getInstance().openInputStream(ProjectIO.fsUrl(binding.pom())); + return PomEditor.containsDependency(Util.readToString(in, "UTF-8"), dependency); + } catch (Exception ex) { + return false; + } finally { + Util.cleanup(in); + } + } + + private void addExtensionDescription(Container text, String description, int maxLines) { + List lines = wrapExtensionText(description, 38, maxLines); + for (String line : lines) { + Label l = new Label(line, uiid("SettingsExtensionText")); + l.setEndsWith3Points(true); + text.add(l); + } + } + + private List wrapExtensionText(String text, int maxChars, int maxLines) { + ArrayList lines = new ArrayList(); + String remaining = text == null ? "" : text.trim(); + while (remaining.length() > 0 && lines.size() < maxLines) { + if (remaining.length() <= maxChars) { + lines.add(remaining); + break; + } + int split = remaining.lastIndexOf(' ', maxChars); + if (split < maxChars / 2) { + split = maxChars; + } + lines.add(remaining.substring(0, split).trim()); + remaining = remaining.substring(split).trim(); + } + if (lines.size() == 0) { + lines.add(""); + } + return lines; + } + + private String extensionKey(ExtensionDescriptor descriptor) { + if (descriptor.dependency() != null) { + return descriptor.dependency().coordinates(); + } + return descriptor.name() + "|" + descriptor.fileName(); + } + + private void addExtensionMeta(Container text, String label, String value) { + if (value == null || value.trim().length() == 0) { + return; + } + text.add(new Label(label + " " + value.trim(), uiid("SettingsExtensionMeta"))); + } + + private Component extensionMeta(String label, String value) { + Container c = new Container(BoxLayout.y()); + c.setUIID(uiid("SettingsExtensionMetaColumn")); + Label l = new Label(label, uiid("SettingsExtensionMetaLabel")); + Label v = new Label(value == null || value.trim().length() == 0 ? "-" : value.trim(), uiid("SettingsExtensionMetaValue")); + v.setEndsWith3Points(true); + c.add(l).add(v); + return c; + } + + private Container extensionTags(ExtensionDescriptor descriptor) { + Container tags = new Container(new FlowLayout(Component.LEFT, Component.CENTER)); + tags.setUIID(uiid("SettingsExtensionTagRow")); + String raw = descriptor.tags(); + if (raw != null && raw.trim().length() > 0) { + String[] pieces = raw.split("[,;]"); + int added = 0; + for (String piece : pieces) { + String tag = piece.trim(); + if (tag.length() > 0) { + tags.add(new Label(displayTag(tag), uiid("SettingsExtensionTags"))); + added++; + if (added >= 2) { + break; + } + } + } + } + return tags; + } + + private String displayTag(String tag) { + String value = tag == null ? "" : tag.trim(); + if ("payment".equalsIgnoreCase(value)) { + return "PAYMENTS"; + } + if ("networking".equalsIgnoreCase(value)) { + return "HARDWARE"; + } + if ("security".equalsIgnoreCase(value)) { + return "CRYPTO"; + } + return value.toUpperCase(); + } + + private String displayDependency(MavenDependency dependency) { + String version = dependency.version(); + if ("${cn1.version}".equals(version)) { + version = "current CN1"; + } + return dependency.groupId() + ":" + dependency.artifactId() + ":" + version + + (dependency.type().length() == 0 ? "" : ":" + dependency.type()); + } + + private void addDependency(MavenDependency dependency) { + if (binding.pom() == null || binding.pom().length() == 0) { + ToastBar.showErrorMessage("No common/pom.xml was bound to this Settings session."); + return; + } + InputStream in = null; + OutputStream out = null; + try { + String url = ProjectIO.fsUrl(binding.pom()); + in = FileSystemStorage.getInstance().openInputStream(url); + String pom = Util.readToString(in, "UTF-8"); + Util.cleanup(in); + in = null; + String updated = PomEditor.addDependency(pom, dependency); + if (updated.equals(pom)) { + ToastBar.showInfoMessage("Dependency already exists: " + dependency.coordinates()); + return; + } + out = FileSystemStorage.getInstance().openOutputStream(url); + out.write(updated.getBytes("UTF-8")); + out.flush(); + ToastBar.showInfoMessage("Added " + dependency.coordinates() + " to common/pom.xml"); + renderPage(); + animatePage(); + } catch (Exception ex) { + Log.e(ex); + ToastBar.showErrorMessage("Failed to update common/pom.xml: " + ex.getMessage()); + } finally { + Util.cleanup(in); + Util.cleanup(out); + } + } + + private void installMavenExtension(ExtensionDescriptor descriptor) { + if (confirmCompatibility(descriptor)) { + addDependency(descriptor.dependency()); + } + } + + private boolean confirmCompatibility(ExtensionDescriptor descriptor) { + String warning = descriptor.warning(); + if (warning.length() == 0 && descriptor.hasCompatibilityWarning()) { + warning = "This extension is marked " + descriptor.status() + + " and may not work with current Codename One versions."; + } + if (warning.length() == 0 && descriptor.dependency() == null) { + warning = "This is a legacy cn1lib package. It may be out of date or unsupported by current Codename One versions."; + } + return warning.length() == 0 || Dialog.show("Compatibility warning", warning, "Continue", "Cancel"); + } + + private void installLegacyCn1Lib(ExtensionDescriptor descriptor) { + if (descriptor.fileName().length() == 0) { + ToastBar.showErrorMessage("This legacy cn1lib entry does not include a downloadable file."); + return; + } + if (!confirmCompatibility(descriptor)) { + return; + } + Display.getInstance().startThread(() -> { + try { + ConnectionRequest req = new ConnectionRequest(); + req.setUrl("https://www.codenameone.com/files/" + encodeUrlPath(descriptor.fileName())); + req.setPost(false); + NetworkManager.getInstance().addToQueueAndWait(req); + if (req.getResponseCode() >= 400 || req.getResponseData() == null) { + throw new java.io.IOException("Download failed with HTTP " + req.getResponseCode()); + } + String dir = legacyCn1LibDir(); + FileSystemStorage fs = FileSystemStorage.getInstance(); + fs.mkdir(ProjectIO.fsUrl(dir)); + String dest = dir + "/" + descriptor.fileName(); + OutputStream out = null; + try { + out = fs.openOutputStream(ProjectIO.fsUrl(dest)); + out.write(req.getResponseData()); + out.flush(); + } finally { + Util.cleanup(out); + } + CN.callSerially(() -> { + ToastBar.showMessage("Installed " + descriptor.fileName() + + " into cn1libs/", FontImage.MATERIAL_CHECK); + renderPage(); + animatePage(); + }); + } catch (Exception ex) { + Log.e(ex); + CN.callSerially(() -> ToastBar.showErrorMessage("Failed to install cn1lib: " + ex.getMessage())); + } + }, "SettingsLegacyCn1LibInstall").start(); + } + + private boolean isLegacyCn1LibInstalled(ExtensionDescriptor descriptor) { + return descriptor.fileName().length() > 0 + && FileSystemStorage.getInstance().exists(ProjectIO.fsUrl(legacyCn1LibPath(descriptor))); + } + + private String legacyCn1LibPath(ExtensionDescriptor descriptor) { + return legacyCn1LibDir() + "/" + descriptor.fileName(); + } + + private void offerUninstall(ExtensionDescriptor descriptor) { + if (!Dialog.show("Uninstall extension", + "Remove " + displayExtensionName(descriptor) + " from this project?", + "Uninstall", "Cancel")) { + return; + } + if (descriptor.dependency() != null) { + removeDependency(descriptor.dependency()); + } else { + String path = legacyCn1LibPath(descriptor); + FileSystemStorage fs = FileSystemStorage.getInstance(); + if (fs.exists(ProjectIO.fsUrl(path))) { + fs.delete(ProjectIO.fsUrl(path)); + } + ToastBar.showInfoMessage("Removed " + descriptor.fileName() + " from cn1libs/"); + renderPage(); + animatePage(); + } + } + + private void removeDependency(MavenDependency dependency) { + InputStream in = null; + OutputStream out = null; + try { + String url = ProjectIO.fsUrl(binding.pom()); + in = FileSystemStorage.getInstance().openInputStream(url); + String pom = Util.readToString(in, "UTF-8"); + Util.cleanup(in); + in = null; + String updated = PomEditor.removeDependency(pom, dependency); + if (updated.equals(pom)) { + ToastBar.showInfoMessage("Dependency is not installed: " + dependency.coordinates()); + return; + } + out = FileSystemStorage.getInstance().openOutputStream(url); + out.write(updated.getBytes("UTF-8")); + out.flush(); + ToastBar.showInfoMessage("Removed " + dependency.coordinates() + " from common/pom.xml"); + renderPage(); + animatePage(); + } catch (Exception ex) { + Log.e(ex); + ToastBar.showErrorMessage("Failed to update common/pom.xml: " + ex.getMessage()); + } finally { + Util.cleanup(in); + Util.cleanup(out); + } + } + + private String legacyCn1LibDir() { + if (binding.multimoduleRoot() != null && binding.multimoduleRoot().length() > 0) { + return binding.multimoduleRoot() + "/cn1libs"; + } + String projectDir = binding.projectDir(); + if (projectDir != null && projectDir.endsWith("/common")) { + return projectDir.substring(0, projectDir.length() - "/common".length()) + "/cn1libs"; + } + return projectDir + "/cn1libs"; + } + + private String encodeUrlPath(String path) { + return path.replace(" ", "%20"); + } + + private void renderAdvanced() { + page.add(pageTitle("Advanced", "Open project files directly when the structured editors are not enough.")); + Container c = card("Files"); + actionRow(c, "Settings file", binding.settings(), () -> Display.getInstance().execute(ProjectIO.fsUrl(binding.settings()))); + actionRow(c, "Common POM", binding.pom(), () -> Display.getInstance().execute(ProjectIO.fsUrl(binding.pom()))); + if (binding.buildHintsDoc() != null && binding.buildHintsDoc().length() > 0) { + actionRow(c, "Build-hints source", binding.buildHintsDoc(), () -> Display.getInstance().execute(ProjectIO.fsUrl(binding.buildHintsDoc()))); + } + page.add(c); + } + + private Container pageTitle(String title, String sub) { + Container c = new Container(BoxLayout.y()); + Label heading = new Label(title, uiid("SettingsPageTitle")); + Label subtitle = new Label(sub, uiid("SettingsSub")); + c.add(heading); + c.add(subtitle); + return c; + } + + private Component iconDrop() { + Container wrap = new Container(BoxLayout.y()); + Label iconLabel = new Label("Icon", uiid("SettingsFieldLabel")); + wrap.add(iconLabel); + Container drop = new Container(new BorderLayout()); + drop.setUIID(uiid("SettingsIconDrop")); + Label icon = new Label("", uiid("SettingsIconPreview")); + Image preview = loadProjectIconPreview(); + if (preview != null) { + int imageSize = CN.convertToPixels(12f); + icon.setIcon(preview.scaled(imageSize, imageSize)); + } else { + icon.setText("M"); + } + Container text = new Container(BoxLayout.y()); + text.add(new Label(projectIconName(), uiid("SettingsRowTitle"))); + text.add(new Label("Opaque square PNG, 512x512 or 1024x1024.", uiid("SettingsRowMeta"))); + Button replace = new Button("Replace", uiid("SettingsOutline")); + replace.addActionListener(e -> replaceIcon()); + Container replaceCell = new Container(new FlowLayout(Component.CENTER, Component.CENTER)); + replaceCell.setUIID(uiid("SettingsIconAction")); + replaceCell.add(replace); + drop.add(BorderLayout.WEST, icon).add(BorderLayout.CENTER, text).add(BorderLayout.EAST, replaceCell); + wrap.add(drop); + return wrap; + } + + private void replaceIcon() { + CN.openFileChooser(e -> { + if (e == null || e.getSource() == null) { + return; + } + String source = (String) e.getSource(); + String dest = projectIconPath(); + if (dest == null || dest.length() == 0) { + ToastBar.showErrorMessage("No Maven project icon path is available."); + return; + } + InputStream in = null; + OutputStream out = null; + try { + FileSystemStorage fs = FileSystemStorage.getInstance(); + String validation = validateReplacementIcon(source); + if (validation != null) { + ToastBar.showErrorMessage(validation); + return; + } + String dir = dest.substring(0, dest.lastIndexOf('/')); + fs.mkdir(ProjectIO.fsUrl(dir)); + in = fs.openInputStream(source); + out = fs.openOutputStream(ProjectIO.fsUrl(dest)); + Util.copy(in, out); + ToastBar.showMessage("Icon replaced", FontImage.MATERIAL_CHECK); + buildShell(); + } catch (Exception ex) { + Log.e(ex); + ToastBar.showErrorMessage("Failed to replace icon: " + ex.getMessage()); + } finally { + Util.cleanup(in); + Util.cleanup(out); + } + }, "png"); + } + + private String validateReplacementIcon(String source) throws Exception { + if (!source.toLowerCase().endsWith(".png")) { + return "Icon must be a PNG file."; + } + InputStream in = null; + try { + in = FileSystemStorage.getInstance().openInputStream(source); + byte[] sig = new byte[8]; + int read = in.read(sig); + if (read != 8 || sig[0] != (byte) 0x89 || sig[1] != 0x50 || sig[2] != 0x4e || sig[3] != 0x47 + || sig[4] != 0x0d || sig[5] != 0x0a || sig[6] != 0x1a || sig[7] != 0x0a) { + return "Icon must be a valid PNG file."; + } + } finally { + Util.cleanup(in); + } + Image img = loadImage(source); + if (img == null) { + return "Icon PNG could not be decoded."; + } + if (img.getWidth() != img.getHeight()) { + return "Icon must be square."; + } + if (img.getWidth() != 512 && img.getWidth() != 1024) { + return "Icon must be 512x512 or 1024x1024."; + } + int[] rgb = img.getRGB(); + for (int i = 0; i < rgb.length; i++) { + if ((rgb[i] & 0xff000000) != 0xff000000) { + return "Icon must be fully opaque."; + } + } + return null; + } + + private Image loadProjectIconPreview() { + String path = projectIconPath(); + if (path == null || path.length() == 0) { + return null; + } + return loadImage(ProjectIO.fsUrl(path)); + } + + private Image loadImage(String url) { + InputStream in = null; + try { + in = FileSystemStorage.getInstance().openInputStream(url); + return Image.createImage(in); + } catch (Exception ex) { + return null; + } finally { + Util.cleanup(in); + } + } + + private String projectIconPath() { + if (binding == null || binding.projectDir() == null || binding.projectDir().length() == 0) { + return null; + } + String icon = settings == null ? "" : settings.get("codename1.icon", "icon.png"); + if (icon == null || icon.length() == 0) { + icon = "icon.png"; + } + if (icon.indexOf("://") > 0 || icon.startsWith("/")) { + return icon; + } + String projectDir = binding.projectDir(); + FileSystemStorage fs = FileSystemStorage.getInstance(); + String commonIcon = projectDir + "/" + icon; + if (fs.exists(ProjectIO.fsUrl(commonIcon))) { + return commonIcon; + } + String nestedCommonIcon = projectDir + "/common/" + icon; + if (fs.exists(ProjectIO.fsUrl(nestedCommonIcon))) { + return nestedCommonIcon; + } + return commonIcon; + } + + private String projectIconName() { + String path = projectIconPath(); + if (path == null || path.length() == 0) { + return "icon.png"; + } + int slash = path.lastIndexOf('/'); + return slash < 0 ? path : path.substring(slash + 1); + } + + private Component divider() { + Container c = new Container(); + c.setUIID(uiid("SettingsDivider")); + return c; + } + + private Component fieldPair(Component left, Component right) { + Container row = new Container(new GridLayout(1, 2)); + row.setUIID(uiid("SettingsFieldPair")); + row.add(left).add(right); + return row; + } + + private Component staticField(String label, String value) { + Container fieldGroup = new Container(BoxLayout.y()); + fieldGroup.setUIID(uiid("SettingsFieldGroup")); + Label fieldLabel = new Label(label, uiid("SettingsFieldLabel")); + Label val = new Label(value, uiid("SettingsField")); + val.getAllStyles().setFont(Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_SMALL) + .derive(CN.convertToPixels(1.75f), Font.STYLE_PLAIN)); + fieldGroup.add(fieldLabel).add(val); + return fieldGroup; + } + + private Component switchField(String label, String value) { + Container fieldGroup = new Container(BoxLayout.y()); + fieldGroup.setUIID(uiid("SettingsFieldGroup")); + Label fieldLabel = new Label(label, uiid("SettingsFieldLabel")); + Container row = new Container(new BorderLayout()); + row.setUIID(uiid("SettingsToggleRow")); + row.add(BorderLayout.CENTER, new Label(value, uiid("SettingsRowMeta"))); + Container sw = new Container(); + sw.setUIID(uiid("SettingsSwitch")); + row.add(BorderLayout.EAST, sw); + fieldGroup.add(fieldLabel).add(row); + return fieldGroup; + } + + private Container card(String title) { + Container c = new Container(BoxLayout.y()); + c.setUIID(uiid("SettingsCard")); + c.add(new Label(title, uiid("SettingsCardTitle"))); + return c; + } + + private void row(Container parent, String label, String value) { + Container r = new Container(new BorderLayout()); + r.setUIID(uiid("SettingsCardRow")); + r.add(BorderLayout.WEST, new Label(label, uiid("SettingsRowTitle"))); + r.add(BorderLayout.CENTER, new Label(value == null || value.length() == 0 ? "[not set]" : value, uiid("SettingsRowMeta"))); + parent.add(r); + } + + private void textRow(Container parent, String label, String key, boolean secret) { + parent.add(textFieldGroup(label, key, secret)); + } + + private Component textFieldGroup(String label, String key, boolean secret) { + Container fieldGroup = new Container(BoxLayout.y()); + fieldGroup.setUIID(uiid("SettingsFieldGroup")); + TextField field = new SettingsTextField(settings.get(key)); + field.setUIID(uiid("SettingsField")); + field.setEnableInputScroll(false); + field.setScrollVisible(false); + if (secret) { + field.setConstraint(TextField.PASSWORD); + } + field.addDataChangedListener((type, index) -> settings.set(key, field.getText())); + Label fieldLabel = new Label(label, uiid("SettingsFieldLabel")); + fieldGroup.add(fieldLabel).add(field); + return fieldGroup; + } + + private void actionRow(Container parent, String label, String value, Runnable action) { + Container r = new Container(new BorderLayout()); + r.setUIID(uiid("SettingsCardRow")); + r.add(BorderLayout.CENTER, new Label(label + ": " + value, uiid("SettingsRowMeta"))); + Button open = new Button("Open", uiid("SettingsOutline")); + open.setMaterialIcon(FontImage.MATERIAL_OPEN_IN_NEW); + open.addActionListener(e -> action.run()); + r.add(BorderLayout.EAST, open); + parent.add(r); + } + + private void saveSettings() { + if (settings == null || !settings.isModified()) { + return; + } + try { + settings.save(); + ToastBar.showInfoMessage("Settings saved"); + buildShell(); + } catch (Exception ex) { + Log.e(ex); + ToastBar.showErrorMessage("Failed to save settings: " + ex.getMessage()); + } + } + + private void go(Section s) { + section = s; + buildShell(); + } + + private void toggleDarkMode() { + darkMode = !darkMode; + Preferences.set(PREF_DARK_MODE, darkMode); + CN.setDarkMode(Boolean.valueOf(darkMode)); + UIManager.getInstance().refreshTheme(); + buildShell(); + form.refreshTheme(false); + applyFontScale(form); + form.revalidate(); + } + + private void adjustFontSize(int deltaPx) { + fontDeltaPx += deltaPx; + if (fontDeltaPx < -4) { + fontDeltaPx = -4; + } + if (fontDeltaPx > 12) { + fontDeltaPx = 12; + } + Preferences.set(PREF_FONT_DELTA, fontDeltaPx); + buildShell(); + } + + private void resetFontSize() { + fontDeltaPx = 0; + Preferences.set(PREF_FONT_DELTA, fontDeltaPx); + buildShell(); + } + + private boolean handleFontShortcut(int keyCode) { + Display display = Display.getInstance(); + if (!display.isControlKeyDown() && !display.isMetaKeyDown()) { + return false; + } + if (keyCode == '+' || keyCode == '=') { + adjustFontSize(2); + return true; + } + if (keyCode == '-' || keyCode == '_') { + adjustFontSize(-2); + return true; + } + if (keyCode == '0') { + resetFontSize(); + return true; + } + return false; + } + + private void handleFontPinch(float scale) { + if (scale <= 0) { + return; + } + fontPinchAccumulator *= scale; + while (fontPinchAccumulator >= 1.14f) { + adjustFontSize(2); + fontPinchAccumulator /= 1.14f; + } + while (fontPinchAccumulator <= 0.88f) { + adjustFontSize(-2); + fontPinchAccumulator /= 0.88f; + } + } + + private void applyFontScale(Component c) { + if (fontDeltaPx == 0 || c == null) { + return; + } + applyScaledFont(c); + if (c instanceof TextArea) { + applyScaledFont(((TextArea)c).getHintLabel()); + } + if (c instanceof Container) { + Container cnt = (Container)c; + for (int iter = 0; iter < cnt.getComponentCount(); iter++) { + applyFontScale(cnt.getComponentAt(iter)); + } + } + } + + private void applyScaledFont(Component c) { + if (c == null) { + return; + } + int size = Display.getInstance().convertToPixels(baseFontMm(c.getUIID())) + fontDeltaPx; + if (size < 8) { + size = 8; + } + boolean bold = isBoldUiid(c.getUIID()); + Font font = nativeFont(bold ? CN.NATIVE_MAIN_BOLD : CN.NATIVE_MAIN_REGULAR, + size, bold ? Font.STYLE_BOLD : Font.STYLE_PLAIN); + if (font != null) { + c.getAllStyles().setFont(font); + } + } + + private Font nativeFont(String nativeName, int sizePx, int style) { + try { + Font base = Font.createTrueTypeFont(nativeName, nativeName); + if (base != null) { + return base.derive(sizePx, style); + } + } catch (Exception ex) { + Log.e(ex); + } + Font fallback = Font.getDefaultFont(); + if (fallback != null && fallback.isTTFNativeFont()) { + try { + return fallback.derive(sizePx, style); + } catch (Exception ex) { + Log.e(ex); + } + } + return null; + } + + private float baseFontMm(String uiid) { + String id = stripDark(uiid); + if ("SettingsPageTitle".equals(id)) { + return 6f; + } + if ("SettingsTitle".equals(id) || "SettingsToolbarBrand".equals(id)) { + return 3.6f; + } + if ("SettingsMark".equals(id)) { + return 3.8f; + } + if ("SettingsAppName".equals(id) || "SettingsPathText".equals(id)) { + return 3f; + } + if ("SettingsSub".equals(id)) { + return 3.2f; + } + if ("SettingsRowMeta".equals(id) || "SettingsRowText".equals(id)) { + return 2.8f; + } + if ("SettingsExtensionMeta".equals(id) || "SettingsExtensionWarning".equals(id)) { + return 2.6f; + } + if ("SettingsFieldLabel".equals(id)) { + return 2.7f; + } + if ("SettingsSectionTag".equals(id)) { + return 2.3f; + } + if ("SettingsRailItem".equals(id) || "SettingsRailItemSelected".equals(id)) { + return 2.4f; + } + if ("SettingsActiveBadge".equals(id) || "SettingsExtensionTags".equals(id)) { + return 2.2f; + } + if ("SettingsCardTitle".equals(id) || "SettingsRowTitle".equals(id)) { + return 3.3f; + } + if ("SettingsPrimary".equals(id) || "SettingsSave".equals(id) + || "SettingsOutline".equals(id) || "SettingsIconButton".equals(id) + || "SettingsSmallIconButton".equals(id) || "SettingsPopupLabel".equals(id)) { + return 3.1f; + } + if ("SettingsExtensionTitle".equals(id)) { + return 4f; + } + if ("SettingsExtensionText".equals(id)) { + return 3.1f; + } + if (id.indexOf("Field") >= 0 || "SettingsSearchField".equals(id)) { + return 3.3f; + } + return 3.4f; + } + + private boolean isBoldUiid(String uiid) { + String id = stripDark(uiid); + return id.indexOf("Title") >= 0 || id.indexOf("Primary") >= 0 || id.indexOf("Save") >= 0 + || id.indexOf("Outline") >= 0 || id.indexOf("SectionTag") >= 0; + } + + private String stripDark(String uiid) { + if (uiid == null) { + return ""; + } + return uiid.endsWith("Dark") ? uiid.substring(0, uiid.length() - 4) : uiid; + } + + private void installMenuCommands() { + if (form.getToolbar() == null) { + return; + } + form.getToolbar().addCommandToOverflowMenu(menuCommand("Update", 'U', () -> { + extensionCatalog = null; + if (section == Section.EXTENSIONS) { + renderPage(); + animatePage(); + } + })); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Save", 'S', () -> saveSettings())); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Open Project Folder", 'O', () -> openProjectFolder())); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Basic", '1', () -> go(Section.BASIC))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Build Hints", '2', () -> go(Section.BUILD_HINTS))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Extensions", '3', () -> go(Section.EXTENSIONS))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Toggle Dark Mode", 'D', () -> toggleDarkMode())); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Increase Font Size", '+', () -> adjustFontSize(2))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Decrease Font Size", '-', () -> adjustFontSize(-2))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Reset Font Size", '0', () -> resetFontSize())); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Close", 'Q', () -> Display.getInstance().exitApplication())); + } + + private Command menuCommand(String name, char shortcut, Runnable action) { + Command cmd = new Command(name) { + @Override + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + action.run(); + } + }; + cmd.setDesktopMenu(Command.DESKTOP_MENU_FILE); + cmd.setDesktopShortcut(shortcut); + return cmd; + } + + private String uiid(String base) { + return darkMode ? base + "Dark" : base; + } + + private static final class SettingsTextField extends TextField { + SettingsTextField(String text) { + super(text); + } + + @Override + public boolean isScrollableY() { + return false; + } + } +} diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/extensions/ExtensionCatalogMerger.java b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/ExtensionCatalogMerger.java new file mode 100644 index 00000000000..74a82c4a9f1 --- /dev/null +++ b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/ExtensionCatalogMerger.java @@ -0,0 +1,42 @@ +package com.codename1.settings.extensions; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class ExtensionCatalogMerger { + private ExtensionCatalogMerger() { + } + + public static List preserveCompatibilityMetadata( + List refreshed, List bundled) { + Map fallback = new LinkedHashMap(); + if (bundled != null) { + for (ExtensionDescriptor descriptor : bundled) { + fallback.put(key(descriptor), descriptor); + } + } + ArrayList merged = new ArrayList(); + if (refreshed == null) { + return merged; + } + for (ExtensionDescriptor descriptor : refreshed) { + ExtensionDescriptor bundledDescriptor = fallback.get(key(descriptor)); + merged.add(descriptor.withCompatibilityFallback(bundledDescriptor)); + } + return merged; + } + + private static String key(ExtensionDescriptor descriptor) { + String name = descriptor == null ? "" : descriptor.name().toLowerCase(); + StringBuilder key = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char ch = name.charAt(i); + if (ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9') { + key.append(ch); + } + } + return key.toString(); + } +} diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/extensions/ExtensionDescriptor.java b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/ExtensionDescriptor.java new file mode 100644 index 00000000000..a416f8ac7b0 --- /dev/null +++ b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/ExtensionDescriptor.java @@ -0,0 +1,122 @@ +package com.codename1.settings.extensions; + +public final class ExtensionDescriptor { + private final String name; + private final String description; + private final MavenDependency dependency; + private final boolean mavenCentral; + private final String fileName; + private final String link; + private final String license; + private final String platforms; + private final String author; + private final String tags; + private final String dependencies; + private final String version; + private final String status; + private final String warning; + + public ExtensionDescriptor(String name, String description, MavenDependency dependency, boolean mavenCentral) { + this(name, description, dependency, mavenCentral, "", "", "", "", "", "", "", "", "", ""); + } + + public ExtensionDescriptor(String name, String description, MavenDependency dependency, boolean mavenCentral, + String fileName, String link, String license, String platforms, String author, + String tags, String dependencies, String version) { + this(name, description, dependency, mavenCentral, fileName, link, license, platforms, author, + tags, dependencies, version, "", ""); + } + + public ExtensionDescriptor(String name, String description, MavenDependency dependency, boolean mavenCentral, + String fileName, String link, String license, String platforms, String author, + String tags, String dependencies, String version, String status, String warning) { + this.name = clean(name); + this.description = clean(description); + this.dependency = dependency; + this.mavenCentral = mavenCentral; + this.fileName = clean(fileName); + this.link = clean(link); + this.license = clean(license); + this.platforms = clean(platforms); + this.author = clean(author); + this.tags = clean(tags); + this.dependencies = clean(dependencies); + this.version = clean(version); + this.status = clean(status); + this.warning = clean(warning); + } + + public String name() { + return name; + } + + public String description() { + return description; + } + + public MavenDependency dependency() { + return dependency; + } + + public boolean isMavenCentral() { + return mavenCentral; + } + + public String fileName() { + return fileName; + } + + public String link() { + return link; + } + + public String license() { + return license; + } + + public String platforms() { + return platforms; + } + + public String author() { + return author; + } + + public String tags() { + return tags; + } + + public String dependencies() { + return dependencies; + } + + public String version() { + return version; + } + + public String status() { + return status; + } + + public String warning() { + return warning; + } + + public boolean hasCompatibilityWarning() { + return warning.length() > 0 || "outdated".equalsIgnoreCase(status) + || "unsupported".equalsIgnoreCase(status); + } + + public ExtensionDescriptor withCompatibilityFallback(ExtensionDescriptor fallback) { + if (fallback == null || hasCompatibilityWarning() || !fallback.hasCompatibilityWarning()) { + return this; + } + return new ExtensionDescriptor(name, description, dependency, mavenCentral, + fileName, link, license, platforms, author, tags, dependencies, version, + fallback.status(), fallback.warning()); + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/extensions/MavenCentralSearch.java b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/MavenCentralSearch.java new file mode 100644 index 00000000000..03b9ce67866 --- /dev/null +++ b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/MavenCentralSearch.java @@ -0,0 +1,114 @@ +package com.codename1.settings.extensions; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.NetworkManager; + +import java.io.ByteArrayInputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public final class MavenCentralSearch { + private static final String SEARCH_URL = "https://search.maven.org/solrsearch/select?q="; + + private MavenCentralSearch() { + } + + public static List curated() { + ArrayList out = new ArrayList(); + out.add(maven("Google Maps", "Native Google Maps integration for Codename One apps.", + "com.codenameone", "googlemaps-lib", "1.0.1", "pom")); + out.add(maven("ML Kit Barcode", "Decode QR, EAN, Code 128, and other ML Kit barcode formats.", + "com.codenameone", "cn1-ai-mlkit-barcode-lib", "LATEST", "pom")); + out.add(maven("ML Kit Document Scanner", "Capture and crop document photos with native VisionKit and Google Play Services support.", + "com.codenameone", "cn1-ai-mlkit-docscan-lib", "LATEST", "pom")); + out.add(maven("ML Kit Face Detection", "Detect faces and bounding rectangles using ML Kit-backed native providers.", + "com.codenameone", "cn1-ai-mlkit-face-lib", "LATEST", "pom")); + out.add(maven("TensorFlow Lite", "TensorFlow Lite inference bridge packaged as a Codename One cn1lib.", + "com.codenameone", "cn1-ai-tflite-lib", "LATEST", "pom")); + out.add(maven("Whisper", "Speech-to-text support through the Codename One AI Whisper cn1lib.", + "com.codenameone", "cn1-ai-whisper-lib", "LATEST", "pom")); + out.add(legacy("Bouncy Castle SDK", "Legacy cn1lib catalog entry for Bouncy Castle cryptography support.")); + out.add(legacy("SSLCertificateFingerprint", "Legacy cn1lib catalog entry for certificate fingerprint verification.")); + out.add(legacy("QRMaker", "Legacy cn1lib catalog entry for QR code generation.")); + return out; + } + + private static ExtensionDescriptor maven(String name, String description, String group, String artifact, String version, String type) { + return new ExtensionDescriptor(name, description, new MavenDependency(group, artifact, version, type), true); + } + + private static ExtensionDescriptor legacy(String name, String description) { + return new ExtensionDescriptor(name, description, null, false); + } + + public static List search(String query) throws Exception { + String q = query == null || query.trim().length() == 0 + ? "codenameone cn1lib" + : query.trim() + " codenameone"; + ConnectionRequest req = new ConnectionRequest(); + req.setUrl(SEARCH_URL + encode(q) + "&rows=40&wt=json"); + req.setPost(false); + NetworkManager.getInstance().addToQueueAndWait(req); + if (req.getResponseCode() >= 400) { + throw new IOException("Maven Central returned HTTP " + req.getResponseCode()); + } + JSONParser parser = new JSONParser(); + Map root = parser.parseJSON(new InputStreamReader(new ByteArrayInputStream(req.getResponseData()), "UTF-8")); + Map response = (Map) root.get("response"); + List docs = response == null ? null : (List) response.get("docs"); + ArrayList out = new ArrayList(); + if (docs == null) { + return out; + } + for (Object o : docs) { + Map doc = (Map) o; + String group = string(doc.get("g")); + String artifact = string(doc.get("a")); + String version = string(doc.get("latestVersion")); + if (group.length() == 0 || artifact.length() == 0 || version.length() == 0) { + continue; + } + String packaging = string(doc.get("p")); + String name = artifact; + String desc = group + ":" + artifact + ":" + version; + if (packaging.length() > 0) { + desc += " (" + packaging + ")"; + } + out.add(new ExtensionDescriptor(name, desc, new MavenDependency(group, artifact, version), true)); + } + return out; + } + + private static String encode(String text) { + StringBuilder out = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { + out.append(c); + } else if (c == ' ') { + out.append("%20"); + } else { + out.append('%'); + String hex = Integer.toHexString(c).toUpperCase(); + if (hex.length() == 1) { + out.append('0'); + } + out.append(hex); + } + } + return out.toString(); + } + + private static String string(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static final class IOException extends Exception { + IOException(String message) { + super(message); + } + } +} diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/extensions/MavenDependency.java b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/MavenDependency.java new file mode 100644 index 00000000000..2a30e5683a4 --- /dev/null +++ b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/MavenDependency.java @@ -0,0 +1,43 @@ +package com.codename1.settings.extensions; + +public final class MavenDependency { + private final String groupId; + private final String artifactId; + private final String version; + private final String type; + + public MavenDependency(String groupId, String artifactId, String version) { + this(groupId, artifactId, version, ""); + } + + public MavenDependency(String groupId, String artifactId, String version, String type) { + this.groupId = groupId == null ? "" : groupId; + this.artifactId = artifactId == null ? "" : artifactId; + this.version = version == null ? "" : version; + this.type = type == null ? "" : type; + } + + public String groupId() { + return groupId; + } + + public String artifactId() { + return artifactId; + } + + public String version() { + return version; + } + + public String type() { + return type; + } + + public boolean isValid() { + return groupId.length() > 0 && artifactId.length() > 0 && version.length() > 0; + } + + public String coordinates() { + return groupId + ":" + artifactId + ":" + version + (type.length() == 0 ? "" : ":" + type); + } +} diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java new file mode 100644 index 00000000000..ca512a35164 --- /dev/null +++ b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java @@ -0,0 +1,137 @@ +package com.codename1.settings.extensions; + +public final class PomEditor { + private PomEditor() { + } + + public static boolean containsDependency(String pom, MavenDependency dependency) { + if (pom == null || dependency == null) { + return false; + } + int searchFrom = 0; + while (searchFrom < pom.length()) { + int start = pom.indexOf("', start); + int close = openEnd < 0 ? -1 : pom.indexOf("
", openEnd); + if (openEnd < 0 || close < 0) { + return false; + } + String block = pom.substring(start, close + "".length()); + if (matches(block, dependency)) { + return true; + } + searchFrom = close + "".length(); + } + return false; + } + + public static String addDependency(String pom, MavenDependency dependency) { + if (pom == null) { + pom = ""; + } + if (dependency == null || !dependency.isValid() || containsDependency(pom, dependency)) { + return pom; + } + String xml = " \n" + + " " + xml(dependency.groupId()) + "\n" + + " " + xml(dependency.artifactId()) + "\n" + + " " + xml(dependency.version()) + "\n" + + (dependency.type().length() == 0 ? "" : " " + xml(dependency.type()) + "\n") + + " \n"; + int depsEnd = projectDependenciesEnd(pom); + if (depsEnd >= 0) { + return pom.substring(0, depsEnd) + xml + pom.substring(depsEnd); + } + int insertion = pom.indexOf(""); + } + if (insertion >= 0) { + return pom.substring(0, insertion) + + " \n" + + xml + + " \n" + + pom.substring(insertion); + } + return pom + "\n\n" + xml + "\n"; + } + + public static String removeDependency(String pom, MavenDependency dependency) { + if (pom == null || dependency == null) { + return pom; + } + int searchFrom = 0; + while (searchFrom < pom.length()) { + int start = pom.indexOf("', start); + int close = openEnd < 0 ? -1 : pom.indexOf("", openEnd); + if (openEnd < 0 || close < 0) { + return pom; + } + int end = close + "".length(); + String block = pom.substring(start, end); + if (matches(block, dependency)) { + int lineStart = start; + while (lineStart > 0 && pom.charAt(lineStart - 1) != '\n') { + lineStart--; + } + int lineEnd = end; + while (lineEnd < pom.length() && (pom.charAt(lineEnd) == ' ' || pom.charAt(lineEnd) == '\t')) { + lineEnd++; + } + if (lineEnd < pom.length() && pom.charAt(lineEnd) == '\n') { + lineEnd++; + } + return pom.substring(0, lineStart) + pom.substring(lineEnd); + } + searchFrom = end; + } + return pom; + } + + private static int projectDependenciesEnd(String pom) { + int profiles = pom.indexOf("", dependencyManagementStart); + int searchFrom = 0; + while (searchFrom < pom.length()) { + int start = pom.indexOf("= 0 && start > profiles) { + return -1; + } + int openEnd = pom.indexOf('>', start); + int close = openEnd < 0 ? -1 : pom.indexOf("
", openEnd); + if (openEnd < 0 || close < 0) { + return -1; + } + boolean managed = dependencyManagementStart >= 0 + && start > dependencyManagementStart + && dependencyManagementEnd >= close; + if (!managed) { + return close; + } + searchFrom = close + "".length(); + } + return -1; + } + + private static boolean matches(String block, MavenDependency dependency) { + return block.contains("" + dependency.groupId() + "") + && block.contains("" + dependency.artifactId() + ""); + } + + private static String xml(String value) { + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } +} diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/hints/BuildHintCatalog.java b/scripts/settings/common/src/main/java/com/codename1/settings/hints/BuildHintCatalog.java new file mode 100644 index 00000000000..e75d0bc3b5b --- /dev/null +++ b/scripts/settings/common/src/main/java/com/codename1/settings/hints/BuildHintCatalog.java @@ -0,0 +1,218 @@ +package com.codename1.settings.hints; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class BuildHintCatalog { + private final Map hints = new LinkedHashMap(); + + public Collection all() { + return hints.values(); + } + + public BuildHintMetadata get(String name) { + return hints.get(name); + } + + public boolean contains(String name) { + return hints.containsKey(name); + } + + public List search(String query) { + ArrayList out = new ArrayList(); + for (BuildHintMetadata hint : hints.values()) { + if (hint.matches(query)) { + out.add(hint); + } + } + return out; + } + + public void add(BuildHintMetadata hint) { + if (hint != null && hint.name() != null && hint.name().trim().length() > 0) { + hints.put(hint.name().trim(), hint); + } + } + + public static BuildHintCatalog fromAsciiDoc(String asciidoc) { + BuildHintCatalog catalog = new BuildHintCatalog(); + if (asciidoc == null) { + return fallback(); + } + String[] lines = asciidoc.replace("\r\n", "\n").split("\n"); + boolean inTable = false; + boolean buildHintTable = false; + String currentName = null; + StringBuilder currentDescription = new StringBuilder(); + for (String raw : lines) { + String line = raw.trim(); + if ("|===".equals(line)) { + if (inTable) { + if (buildHintTable) { + flush(catalog, currentName, currentDescription.toString()); + break; + } + inTable = false; + } else { + inTable = true; + buildHintTable = false; + currentName = null; + currentDescription.setLength(0); + } + continue; + } + if (!inTable) { + continue; + } + if (line.startsWith("//")) { + continue; + } + if (!buildHintTable) { + String header = line.startsWith("|") ? line.substring(1).trim() : line; + if (header.startsWith("Name") && header.contains("|Description")) { + buildHintTable = true; + } + continue; + } + if (line.startsWith("|")) { + String cell = line.substring(1).trim(); + if (currentName == null) { + currentName = cell; + currentDescription.setLength(0); + } else if (currentDescription.length() == 0) { + currentDescription.append(cell); + } else { + flush(catalog, currentName, currentDescription.toString()); + currentName = cell; + currentDescription.setLength(0); + } + } else if (currentName != null && line.length() > 0) { + if (currentDescription.length() > 0) { + currentDescription.append(' '); + } + currentDescription.append(line); + } + } + if (catalog.hints.isEmpty()) { + return fallback(); + } + return catalog; + } + + private static void flush(BuildHintCatalog catalog, String rawName, String description) { + if (rawName == null || rawName.trim().length() == 0) { + return; + } + for (String name : splitNames(rawName)) { + catalog.add(new BuildHintMetadata(name, description, inferType(name, description), inferPlatform(name))); + } + } + + private static List splitNames(String raw) { + ArrayList names = new ArrayList(); + String normalized = raw.replace("`", "").replace("(a.k.a.", "/").replace(")", ""); + String[] parts = normalized.split(","); + for (String part : parts) { + String[] slashParts = part.split("/"); + for (String slashPart : slashParts) { + String name = slashPart.trim(); + if (name.indexOf(' ') >= 0 || name.length() == 0 || name.startsWith("(")) { + continue; + } + names.add(name); + } + } + return names.isEmpty() ? Collections.singletonList(raw.trim()) : names; + } + + private static BuildHintType inferType(String name, String description) { + String n = name.toLowerCase(); + String d = description == null ? "" : description.toLowerCase(); + if ("java.version".equals(name)) { + return BuildHintType.INTEGER; + } + if ("android.targetSDKVersion".equals(name)) { + return BuildHintType.INTEGER; + } + if ("android.useAndroidX".equals(name)) { + return BuildHintType.BOOLEAN; + } + if ("build.cn1Version".equals(name) || "ios.bundleVersion".equals(name)) { + return BuildHintType.VERSION; + } + if (n.contains("password") || n.contains("secret") || n.contains("token")) { + return BuildHintType.SECRET; + } + if (n.contains("certificate") || n.contains("provision") || n.contains("sdkroot") || d.contains("path to")) { + return BuildHintType.PATH; + } + if (n.contains("url") || d.contains("https://") || d.contains("http://")) { + return BuildHintType.URL; + } + if (d.contains("true/false") || d.contains("boolean true/false") || d.contains("`true`") || d.contains("`false`")) { + return BuildHintType.BOOLEAN; + } + if (d.contains("comma") || d.contains("comma-delimited") || d.contains("comma delimited")) { + return BuildHintType.CSV; + } + if (d.contains("<") && d.contains(">") || n.contains("xml") || n.contains("plistinject") || n.contains("xpermissions")) { + return BuildHintType.XML; + } + if (n.contains("version") || d.contains("version")) { + return BuildHintType.VERSION; + } + if (d.contains("can be ") || d.contains("supported values") || d.contains("accepts ")) { + return BuildHintType.ENUM; + } + if (d.contains("integer") || d.contains("size in bytes") || n.endsWith("port")) { + return BuildHintType.INTEGER; + } + return BuildHintType.TEXT; + } + + private static String inferPlatform(String name) { + if (name.startsWith("android.") || name.startsWith("and.")) { + return "android"; + } + if (name.startsWith("ios.")) { + return "ios"; + } + if (name.startsWith("macNative.") || name.startsWith("codename1.mac.") || name.startsWith("desktop.mac.")) { + return "mac"; + } + if (name.startsWith("windows.") || name.startsWith("win.")) { + return "windows"; + } + if (name.startsWith("linux.")) { + return "linux"; + } + if (name.startsWith("javascript.")) { + return "javascript"; + } + if (name.startsWith("desktop.")) { + return "desktop"; + } + return "general"; + } + + public static BuildHintCatalog fallback() { + BuildHintCatalog catalog = new BuildHintCatalog(); + catalog.add(new BuildHintMetadata("build.cn1Version", "Pins the cloud build to a released Codename One version such as 7.0.250, or master.", BuildHintType.VERSION, "general")); + catalog.add(new BuildHintMetadata("java.version", "Build server Java version.", BuildHintType.INTEGER, "general")); + catalog.add(new BuildHintMetadata("android.debug", "Whether to include an Android debug build.", BuildHintType.BOOLEAN, "android")); + catalog.add(new BuildHintMetadata("android.release", "Whether to include an Android release build.", BuildHintType.BOOLEAN, "android")); + catalog.add(new BuildHintMetadata("android.xpermissions", "Additional Android manifest permissions XML.", BuildHintType.XML, "android")); + catalog.add(new BuildHintMetadata("ios.bundleVersion", "Version number of the generated iOS bundle.", BuildHintType.VERSION, "ios")); + catalog.add(new BuildHintMetadata("ios.deployment_target", "Minimum iOS version.", BuildHintType.VERSION, "ios")); + catalog.add(new BuildHintMetadata("ios.plistInject", "Raw XML injected into the iOS Info.plist.", BuildHintType.XML, "ios")); + catalog.add(new BuildHintMetadata("macNative.distribution", "Mac native distribution: appStore, developerID, or both.", BuildHintType.ENUM, "mac")); + catalog.add(new BuildHintMetadata("windows.signing.timestampUrl", "RFC 3161 timestamp server URL for Windows signing.", BuildHintType.URL, "windows")); + catalog.add(new BuildHintMetadata("desktop.width", "Desktop window width.", BuildHintType.INTEGER, "desktop")); + catalog.add(new BuildHintMetadata("desktop.height", "Desktop window height.", BuildHintType.INTEGER, "desktop")); + return catalog; + } +} diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/hints/BuildHintMetadata.java b/scripts/settings/common/src/main/java/com/codename1/settings/hints/BuildHintMetadata.java new file mode 100644 index 00000000000..e5e27a1d789 --- /dev/null +++ b/scripts/settings/common/src/main/java/com/codename1/settings/hints/BuildHintMetadata.java @@ -0,0 +1,42 @@ +package com.codename1.settings.hints; + +public final class BuildHintMetadata { + private final String name; + private final String description; + private final BuildHintType type; + private final String platform; + + public BuildHintMetadata(String name, String description, BuildHintType type, String platform) { + this.name = name; + this.description = description == null ? "" : description.trim(); + this.type = type == null ? BuildHintType.TEXT : type; + this.platform = platform == null ? "general" : platform; + } + + public String name() { + return name; + } + + public String description() { + return description; + } + + public BuildHintType type() { + return type; + } + + public String platform() { + return platform; + } + + public boolean matches(String query) { + if (query == null || query.trim().length() == 0) { + return true; + } + String q = query.toLowerCase(); + return name.toLowerCase().contains(q) + || description.toLowerCase().contains(q) + || platform.toLowerCase().contains(q) + || type.name().toLowerCase().contains(q); + } +} diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/hints/BuildHintType.java b/scripts/settings/common/src/main/java/com/codename1/settings/hints/BuildHintType.java new file mode 100644 index 00000000000..c2f8b6aa08d --- /dev/null +++ b/scripts/settings/common/src/main/java/com/codename1/settings/hints/BuildHintType.java @@ -0,0 +1,14 @@ +package com.codename1.settings.hints; + +public enum BuildHintType { + BOOLEAN, + INTEGER, + VERSION, + ENUM, + XML, + PATH, + URL, + CSV, + SECRET, + TEXT +} diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectBinding.java b/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectBinding.java new file mode 100644 index 00000000000..ff66d7e231d --- /dev/null +++ b/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectBinding.java @@ -0,0 +1,63 @@ +package com.codename1.settings.project; + +public final class ProjectBinding { + private String projectDir; + private String settings; + private String pom; + private String multimoduleRoot; + private String buildHintsDoc; + + public String projectDir() { + return projectDir; + } + + public String settings() { + return settings; + } + + public String pom() { + return pom; + } + + public String multimoduleRoot() { + return multimoduleRoot; + } + + public String buildHintsDoc() { + return buildHintsDoc; + } + + public boolean isValid() { + return settings != null && settings.length() > 0; + } + + public static ProjectBinding parse(String content) { + ProjectBinding b = new ProjectBinding(); + if (content == null) { + return b; + } + String[] lines = content.replace("\r\n", "\n").split("\n"); + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.length() == 0 || trimmed.startsWith("#")) { + continue; + } + int eq = trimmed.indexOf('='); + if (eq <= 0) { + continue; + } + String key = trimmed.substring(0, eq).trim(); + String val = trimmed.substring(eq + 1).trim(); + switch (key) { + case "projectDir" -> b.projectDir = val; + case "settings" -> b.settings = val; + case "pom" -> b.pom = val; + case "multimoduleRoot" -> b.multimoduleRoot = val; + case "buildHintsDoc" -> b.buildHintsDoc = val; + default -> { + } + } + } + return b; + } +} diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectIO.java b/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectIO.java new file mode 100644 index 00000000000..d90cd7340ea --- /dev/null +++ b/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectIO.java @@ -0,0 +1,46 @@ +package com.codename1.settings.project; + +import com.codename1.io.FileSystemStorage; +import com.codename1.io.Util; + +import java.io.IOException; +import java.io.InputStream; + +public final class ProjectIO { + public static final String INPUT_PROPERTY = "settings.input"; + + private ProjectIO() { + } + + public static ProjectBinding loadBinding() { + String path = System.getProperty(INPUT_PROPERTY); + if (path == null || path.trim().length() == 0) { + return null; + } + InputStream in = null; + try { + String url = fsUrl(path); + FileSystemStorage fs = FileSystemStorage.getInstance(); + if (!fs.exists(url)) { + return null; + } + in = fs.openInputStream(url); + ProjectBinding b = ProjectBinding.parse(Util.readToString(in, "UTF-8")); + return b.isValid() ? b : null; + } catch (IOException ex) { + return null; + } finally { + Util.cleanup(in); + } + } + + public static String fsUrl(String path) { + if (path == null) { + return null; + } + if (path.startsWith("file://") || path.indexOf("://") > 0) { + return path; + } + return "file://" + path; + } +} diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/project/SettingsProperties.java b/scripts/settings/common/src/main/java/com/codename1/settings/project/SettingsProperties.java new file mode 100644 index 00000000000..3e6edf927ec --- /dev/null +++ b/scripts/settings/common/src/main/java/com/codename1/settings/project/SettingsProperties.java @@ -0,0 +1,258 @@ +package com.codename1.settings.project; + +import com.codename1.io.FileSystemStorage; +import com.codename1.io.Util; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class SettingsProperties { + public static final String BUILD_HINT_PREFIX = "codename1.arg."; + + private final String path; + private final Map properties = new LinkedHashMap(); + private String originalText = ""; + private boolean modified; + + public SettingsProperties(String path) { + this.path = path; + } + + public String path() { + return path; + } + + public boolean isModified() { + return modified; + } + + public void load() throws IOException { + originalText = read(path); + properties.clear(); + if (originalText.length() > 0) { + parseProperties(originalText, properties); + } + modified = false; + } + + public String get(String key) { + return get(key, ""); + } + + public String get(String key, String def) { + String v = properties.get(key); + return v == null ? def : v; + } + + public void set(String key, String value) { + properties.put(key, value == null ? "" : value); + modified = true; + } + + public void remove(String key) { + properties.remove(key); + modified = true; + } + + public void setBuildHint(String hint, String value) { + set(fullBuildHintKey(hint), value); + } + + public String getBuildHint(String hint) { + return get(fullBuildHintKey(hint)); + } + + public void removeBuildHint(String hint) { + remove(fullBuildHintKey(hint)); + } + + public Set keys() { + return properties.keySet(); + } + + public List buildHintKeys() { + ArrayList out = new ArrayList(); + for (String key : properties.keySet()) { + if (key.startsWith(BUILD_HINT_PREFIX)) { + out.add(key.substring(BUILD_HINT_PREFIX.length())); + } + } + Collections.sort(out, String.CASE_INSENSITIVE_ORDER); + return out; + } + + public int buildHintCount() { + return buildHintKeys().size(); + } + + public void save() throws IOException { + write(path, merge(originalText, properties)); + originalText = read(path); + modified = false; + } + + public static String fullBuildHintKey(String hint) { + if (hint == null) { + return BUILD_HINT_PREFIX; + } + return hint.startsWith(BUILD_HINT_PREFIX) ? hint : BUILD_HINT_PREFIX + hint; + } + + static String merge(String original, Map values) { + String[] lines = original == null ? new String[0] : original.replace("\r\n", "\n").split("\n"); + StringBuilder out = new StringBuilder(original == null ? 256 : original.length() + 256); + Map remaining = new LinkedHashMap(); + remaining.putAll(values); + for (String line : lines) { + String trimmed = line.trim(); + int eq = trimmed.indexOf('='); + if (eq > 0 && !trimmed.startsWith("#")) { + String key = trimmed.substring(0, eq).trim(); + if (remaining.containsKey(key)) { + out.append(key).append('=').append(escape(remaining.get(key))).append('\n'); + remaining.remove(key); + continue; + } + } + if (line.length() > 0) { + out.append(line).append('\n'); + } + } + ArrayList keys = new ArrayList(); + for (String key : remaining.keySet()) { + keys.add(key); + } + Collections.sort(keys, String.CASE_INSENSITIVE_ORDER); + for (String key : keys) { + out.append(key).append('=').append(escape(remaining.get(key))).append('\n'); + } + return out.toString(); + } + + private static void parseProperties(String text, Map out) { + String[] lines = text.replace("\r\n", "\n").split("\n"); + String pendingKey = null; + StringBuilder pendingValue = new StringBuilder(); + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.length() == 0 || trimmed.startsWith("#") || trimmed.startsWith("!")) { + continue; + } + if (pendingKey != null) { + boolean continued = trimmed.endsWith("\\"); + pendingValue.append(continued ? trimmed.substring(0, trimmed.length() - 1) : trimmed); + if (!continued) { + out.put(pendingKey, unescape(pendingValue.toString())); + pendingKey = null; + pendingValue.setLength(0); + } + continue; + } + int split = separatorIndex(line); + if (split <= 0) { + continue; + } + String key = line.substring(0, split).trim(); + String value = line.substring(split + 1).trim(); + boolean continued = value.endsWith("\\"); + if (continued) { + pendingKey = key; + pendingValue.append(value.substring(0, value.length() - 1)); + } else { + out.put(key, unescape(value)); + } + } + if (pendingKey != null) { + out.put(pendingKey, unescape(pendingValue.toString())); + } + } + + private static int separatorIndex(String line) { + boolean escaped = false; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (escaped) { + escaped = false; + continue; + } + if (c == '\\') { + escaped = true; + continue; + } + if (c == '=' || c == ':') { + return i; + } + } + return -1; + } + + private static String read(String settingsPath) throws IOException { + if (settingsPath == null || settingsPath.length() == 0) { + return ""; + } + FileSystemStorage fs = FileSystemStorage.getInstance(); + String url = ProjectIO.fsUrl(settingsPath); + if (!fs.exists(url)) { + return ""; + } + InputStream in = null; + try { + in = fs.openInputStream(url); + return Util.readToString(in, "UTF-8"); + } finally { + Util.cleanup(in); + } + } + + private static void write(String settingsPath, String text) throws IOException { + OutputStream out = null; + try { + out = FileSystemStorage.getInstance().openOutputStream(ProjectIO.fsUrl(settingsPath)); + out.write(text.getBytes("UTF-8")); + out.flush(); + } finally { + Util.cleanup(out); + } + } + + private static String escape(String value) { + if (value == null) { + return ""; + } + return value.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", ""); + } + + private static String unescape(String value) { + StringBuilder out = new StringBuilder(value.length()); + boolean escaped = false; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (!escaped) { + if (c == '\\') { + escaped = true; + } else { + out.append(c); + } + continue; + } + switch (c) { + case 'n' -> out.append('\n'); + case 'r' -> out.append('\r'); + case 't' -> out.append('\t'); + default -> out.append(c); + } + escaped = false; + } + if (escaped) { + out.append('\\'); + } + return out.toString(); + } +} diff --git a/scripts/settings/common/src/main/resources/com/codename1/settings/extensions/CN1Libs.xml b/scripts/settings/common/src/main/resources/com/codename1/settings/extensions/CN1Libs.xml new file mode 100644 index 00000000000..0d42d947b97 --- /dev/null +++ b/scripts/settings/common/src/main/resources/com/codename1/settings/extensions/CN1Libs.xml @@ -0,0 +1,933 @@ + + + + CN1-Stream + Stream API from Java 8 rewritten on iterators for Codename One. + https://github.com/diamonddevgroup/CN1-Stream + 2 + MIT + utilities + All + + Diamond Dev Group + + + Linguist + Use this library to internationalize/localize your application quickly. It must be use with the CodenameOne Linguist desktop software. + https://github.com/ericlight/CN1Linguist + 1 + MIT + internationalization, localization, translation, translate, languages, utilities + All + + Eric Dodji Gbofu + + + Multipart Upload text file request and track upload progress + This lib will work as is but it's more of an example of how this can be achieved so that you can implement your own code. For example you might want to upload non-text (binary) files instead of text files. Or you might wat to upload files + parameters (there is a commented example in the code) + https://github.com/javieranton-zz/ProgressUploader + 1 + MIT + progress,upload + iOS, Android + + Javier Anton + + + + Form to crop and resize your images + Crop and resize your images whatsapp style. Ensures final cropped images have equal width and heights. Resizes to your desired dimensions. Allows for a minimum image size + https://github.com/javieranton-zz/PhotoCropper + 2 + MIT + images,crop,resize,photo + iOS, Android + + Javier Anton + + + + Crisp Codename One SDK + Integrate Crisp chat and CRM into your app + https://github.com/codenameone/CrispCodenameOneSDK + 6 + MIT + utilities,analytics + All + + Codename One + + + + Sign-in with Apple Support + Add sign-in with apple support + https://github.com/shannah/cn1-applesignin + 4 + GPL+CE + login,oauth + All + + Codename One + + + + CN1JailbreakDetect + Detect if device is rooted/jailbroken + https://github.com/shannah/CN1JailbreakDetect + 1 + Apache 2.0 + security + iOS, Android + + Codename One + + + + Codename One Asciidoctor + Asciidoc to HTML5 converter for Codename One + https://github.com/shannah/CN1Asciidoctor + 1 + MIT + utilities + All + + Codename One + + + + CodeRAD + MVC, Rapid Application Development library + https://github.com/shannah/CodeRAD + 13 + Apache 2.0 + utilities,ui,mvc + All + + Codename One + + + Shared Files Library + API to access shared files. (Currently Android only) + https://github.com/shannah/CodeRAD + 1 + Apache 2.0 + utilities + Android + + + + com.codenameone + sharedfiles-lib + LATEST + pom + + + Codename One + + + RAD Chat Room + A full featured Chat Room UI Component + https://github.com/shannah/RADChatRoom + 9 + Apache 2.0 + ui,chat + All + CodeRAD.cn1lib + Codename One + + + + Native Logs Reader + A small library to get easily the native logs of Android and iOS. + https://github.com/jsfan3/CN1Libs-NativeLogsReader + 3 + CC0 - Public Domain + utilities + iOS, Android + + Francesco Galgani + + + + Video Optimizer + It allows to get info about a video (duration, bitrate, size), get a preview image of a video and optimize a video for fast upload. It can also be used to check if a given file is a supported video. + https://github.com/jsfan3/CN1Libs-VideoOptimizer/ + 2 + CC0 + LGPL v.3 + utilities + iOS, Android + + Francesco Galgani + + + + Wowza Live Streaming Events + The purpose of this CN1Lib is to add live streaming capabilities to iOS and Android Codename One apps, hiding all the complexities and reducing the effort. + https://github.com/jsfan3/CN1Libs-WowzaLiveStreaming/ + 1 + Mixed + utilities + iOS, Android + + Francesco Galgani + + + + Device + A small library to get the market (consumer-friendly) name, model, and manufacturer of devices. + https://github.com/diamonddevgroup/CN1-Device + 7 + MIT License + utilities + iOS, Android + + Diamond Dev Group + + + + Camera Kit + Low level camera access API + https://github.com/codenameone/CameraKitCodenameOne/tree/master + 9 + MIT License + utilities + iOS, Android, Simulator, Javascript + + Codename One + + + + comScore Analytics + comScore mobile metrix / analytics support for Codename One + https://github.com/diamonddevgroup/comScore-CodenameOne + 1 + MIT License + analytics + iOS, Android + + Diamond Dev Group + + + + SMSInterceptor + Intercept incoming SMS on supported platforms in Codename One apps + https://github.com/diamonddevgroup/SMSInterceptor + 2 + MIT License + utilities + Android + + Diamond Dev Group + + + + SMSActivation + Implements the full activation via SMS user signup process + https://github.com/codenameone/SMSActivation/ + 9 + MIT License + utilities + All + + Codename One + + + + AnimatedGifSupport + Support for Animated GIFs + https://github.com/codenameone/AnimatedGifSupport + 3 + MIT License + design,ui + All + + Codename One + + + Server data caching library + Easily enable and control device cache for remote data + https://github.com/jegesh/cn1-object-cacher + 1 + MIT License + caching,data + All + CN1JSON.cn1lib + Yaakov Gesher + + + + BraintreeCodenameOne + Provides Braintree (PayPal) API support for Codename One apps + https://github.com/codenameone/BraintreeCodenameOne + 5 + MIT License + payment,utilities + iOS, Android + + Codename One + + + + FingerprintScanner + Fingerprint/TouchID support + https://github.com/codenameone/FingerprintScanner + 18 + MIT License + utilities + iOS, Android + + Codename One + + + com.codenameone + fingerprint-scanner-lib + LATEST + pom + + + + + + CN1-Helper + A Helper library for basic styling with code in Codename One. This eliminates the need to have endless UIID in your GUI builder + https://github.com/diamondobama/cn1-Helper + 4 + MIT License + styling,uiid,design,ui + iOS, Android, Javascript, UWP, Simulator, RIM, J2ME + + Diamond Dev Group + + + Intercom Support + Adds support for integrating intercom.io for customer support and cross screen insight + https://github.com/codenameone/IntercomSupport/ + 1 + GPL+Classpath Exception + utilities,analytics + iOS, Android + + Codename One + + + SSL Certificate Fingerprint + Checks that the certificate of a server matches a specific value + https://github.com/codenameone/SSLCertificateFingerprint/tree/master + 1 + GPL+Classpath Exception + utilities,networking + Desktop, iOS, Android + + Codename One + + + Drag and Drop from the native desktop OS + Allows the desktop/JavaScript versions to receive files dropped into them + https://github.com/shannah/cn1-native-data-transfer + 3 + Apache 2.0 + utilities,ui + Desktop, JavaScript + + Steve Hannah + + + Apple AppTrackingTransparency library + Thin wrapper around the iOS AppTrackingTransparency API. + https://github.com/shannah/cn1-app-tracking + 2 + MIT + utilities + iOS + + Steve Hannah + + + com.codenameone + app-tracking-lib + 1.0 + pom + + + + + Kotlin support for Codename One + Adds support for Kotlin in Codename One apps. (Requires IntelliJ IDEA) + https://github.com/shannah/codenameone-kotlin + 4 + Apache 2.0 + utilities,languages + iOS, Android, Javascript, UWP, Simulator, RIM, J2ME + + Codename One + + + Admob Fullscreen Ads + Admob Fullscreen Ads library, supports iOS and Android + https://github.com/codenameone/admobfullscreen-codenameone + 6 + Apache 2.0 + ads + iOS, Android + + Ram (creator of yhomework) + outdated + This extension is based on an older cn1lib and may no longer be supported by current Codename One versions. + + + com.codenameone + admob-fullscreen-lib + LATEST + pom + + + + + StartApp Fullscreen Ads + StartApp Fullscreen Ads library, supports iOS and Android + https://github.com/chen-fishbein/startapp-codenameone + 1 + Apache 2.0 + ads + iOS, Android + + CodenameOne + + + Circle Progress + Circle Progress Components + https://github.com/chen-fishbein/CN1CircleProgress + 2 + MIT License + ui + iOS, Android + + CodenameOne + + + Codename One Data Access Library + Provides a Data access layer for SQLite databases in Codename One. + https://github.com/shannah/cn1-data-access-lib + 4 + Apache 2.0 + storage,database + iOS, Android, Javascript, Simulator + + Steve Hannah + + + com.codenameone + data-access-lib + LATEST + pom + + + + + SpatiaLite Database + Provides SpatiaLite database support for Codename One. + https://github.com/shannah/cn1-spatialite + 5 + MPL Tri-License + storage,database + iOS, Android, Simulator + + Steve Hannah + + + Generic Webservice Client + Web service client for connecting to a RESTful web service. + https://github.com/shannah/cn1-generic-webservice-client + 1 + Apache 2.0 + database,webservice,networking + iOS, Android, Javascript, UWP, Simulator, RIM, J2ME + + Steve Hannah + + + CN1Xataface + Web service client for connecting to a MySQL back-end. + https://github.com/shannah/cn1-xataface + 2 + Apache 2.0 + database,webservice,networking + iOS, Android, Javascript, UWP, Simulator, RIM, J2ME + + Steve Hannah + + + Flurry Library + This plugin supports flurry analytics and Interstitial Ads (Full Screen Ads) \nThe library is implemented for Android and iOS. + https://github.com/chen-fishbein/flurry-codenameone + 3 + Apache 2.0 + ads, analytics + iOS, Android, + + Steve Hannah, Chen Fishbein, Sam Lotti + + + Codename One CodeScanner Library + This library provides code scanning (QR code and Bar code) support for Codename One + https://github.com/codenameone/cn1-codescan + 10 + Apache 2.0 + utilities + iOS, Android, Blackberry + + CodenameOne + + + com.codenameone + cn1-codescan-lib + LATEST + pom + + + + + Scandit Codescanner + This library provides code scanning (QR code and Bar code) using the Scandit SDK. NOTE: This library requires you to install the iOS and Android native SDKs into your app separately. See installation instructions on website. + https://github.com/shannah/cn1-codescan-scandit + 5 + Apache 2.0 + utilities + iOS, Android + CN1ObjCBridge.cn1lib + CodenameOne + + + QRScanner + CN1Lib scanning barcodes and QR codes in Codename One apps on Android and iOS. + https://github.com/codenameone/QRScanner + 12 + MIT License + utilities + Android, iOS + CodenameOne + + + + com.codenameone + qrscanner-lib + LATEST + pom + + + + + Zip Support + This is a Codename One library for Zip support based on the code from the zipme project + https://github.com/codenameone/ZipSupport + 2 + Modified GPL + utilities + All + + CodenameOne + + + Codename One Freshdesk SDK + Codename One support for Freshdesk.\nThis library wraps the native iOS and Android SDKs to provide a single cross-platform Java API\n that can be used in a Codename One project. + http://shannah.github.io/cn1-freshdesk/ + 1 + GPL+Classpath Exception + utilities,analytics + iOS, Android + + Steve Hannah + + + Telephony + Simcard information support for codename one applications\nThis library can retrieve MCC, MNC, ISO country code and carrier name information from the simcard\nCurrently supported in Android and iOS. + https://github.com/Pmovil/Telephony + 1 + MIT License + utilities,networking + iOS, Android + + Fabricio Cabeca (from Pmovil) + + + Parse4CN1 + Parse4CN1 - Codename One Library for Parse. Currrent version matches release v3.1.1 + https://github.com/sidiabale/parse4cn1 + 3110 + Apache License + storage, utilities, cloud, push + iOS, Android, UWP + CN1JSON.cn1lib + Chidiebere Okwudire (from SMash ICT Solutions) + + + Core2d + 2d gaming library + https://github.com/chen-fishbein/core2d-cn1lib + 2 + MIT License + gaming + All + + Antonio Mannucci + + + codenameone-connectivity + Simple library for getting basic connection information on codename one + https://github.com/littlemonkeyltd/codenameone-connectivity + 2 + MIT License + utilities,networking + iOS, Android + + Nick K (from littlemonkey) + + + Google Analytics + Native Google Analytics Library + https://github.com/Pmovil/cn1libs-native-ga + 1 + MIT License + analytics + iOS, Android + + Ivan (from Pmovil) + + + Calendar Library + Allows Codename One applications to use the device calendar. + https://github.com/chen-fishbein/codenameone-calendar + 4 + GPL+Classpath Exception + utilities + iOS, Android + + Andreas Heydler, Kapila de Lanerolle + + + Codename Sockets Library + This library was started as an attempt to add sockets support to Codename One. \nSupports iOS, Android, Blackberry, JavaSE and J2ME. + https://github.com/shannah/CN1Sockets + 5 + Unclear + utilities,networking + All + + Steve Hannah + + + CN1 Native Controls + Includes a native select widget that is useful in the Javascript port. May be expanded to include other native widgets. + https://github.com/shannah/cn1-native-controls + 4 + MIT license + ui + All + + Steve Hannah + + + Codename One WebSockets Library + This library adds WebSocket client support for Codename One apps. + https://github.com/shannah/cn1-websockets + 23 + MIT License + utilities,networking + iOS, Android, Javascript, Simulator, UWP + + Steve Hannah + + + IRBlaster + Provides API to use the IR (Infrared) Emitter on Selected Devices + https://github.com/shannah/IRBlaster + 1 + Apache License + utilities + Android + + Steve Hannah + + + Codename One MP3 Encoder + Adds support for recording MP3 audio in simulator + https://github.com/shannah/CN1MP3Encoder + 16 + MIT License + utilities,media + Simulator + + Steve Hannah + + + Codename One Media Utilies + Utility classes for working with media + https://github.com/shannah/CN1MediaTools + 3 + Apache + utilities,media + All + + Steve Hannah + + + Embedded Webserver Library + Add an embedded web server to Codename One apps. + https://github.com/shannah/CN1Webserver + 1 + MIT License + utilities,networking + iOS, Android, Simulator + + Steve Hannah + + + Codename One Pubnub SDK + Pubnub SDK for Codename One Apps + https://www.pubnub.com/docs/codenameone-java/data-streams-publish-and-subscribe + 3 + PubNub license + utilities,networking + iOS, Android + BouncyCastleCN1Lib.cn1lib + Pubnub + + + Dropbox SDK + Dropbox SDK for Codename One Apps + https://github.com/chen-fishbein/dropbox-codenameone-sdk + 1 + GPL+Classpath Exception + utilities,storage + iOS, Android, Windows Phone, Javascript + + CodenameOne + + + BouncyCastle SDK + Bouncy Castle Crypto API for Codename One + https://github.com/chen-fishbein/bouncy-castle-codenameone-lib + 2 + GPL+Classpath Exception + security + All + + CodenameOne + + + Objective-C Bridge + Access Objective-C SDKs directly from Java + https://github.com/shannah/CN1ObjCBridge + 5 + BSD + utilities,ios + iOS + + CodenameOne + + + SalesForce SDK + SalesForce SDK for Codename One + https://www.codenameone.com/blog/combining-salesforce-and-codename-one.html + 1 + LGPL + utilities + iOS, Android, Windows Phone, Javascript + + Bertrand Cirot + + + Codename One JSON Library + A library to read and write JSON in Codename One + https://github.com/shannah/CN1JSON + 5 + Apache 2.0 + utilities + All + + Steve Hannah + + + Codename One HTML Parser + A library for parsing HTML to standard XML DOM + https://github.com/shannah/CN1HTMLParser + 1 + Apache 2.0 + utilities + All + + Steve Hannah + + + CN1Image Map component + An image map component for rendering an image with clickable areas. + https://github.com/shannah/CN1ImageMap + 1 + Apache 2.0 + ui + All + + Steve Hannah + + + Codename One Tar Library + A library for reading and writing TAR files in Codename One + https://github.com/shannah/CN1JTar + 2 + Apache 2.0 + utilities + All + + Steve Hannah + + + Codename One Google Native Maps Support + Allows Codename One developers to embed native Google Maps on iOS, Android, and Javascript. Uses Google maps in BrowserComponent on simulator and falls back to Codename One\n MapComponent on UWP. + https://github.com/codenameone/codenameone-google-maps + 49 + Apache 2.0 + utilities,maps + iOS, Android, Javascript + + CodenameOne + + + com.codenameone + googlemaps-lib + LATEST + pom + + + + + sensors-codenameone + Sensors library for Codename One, implemented on Android and iOS + https://github.com/chen-fishbein/sensors-codenameone + 3 + Apache 2.0 + utilities + iOS, Android + + CodenameOne + + + bluetoothle-codenameone + Bluetooth LE Library for Codename One apps + https://github.com/codenameone/bluetoothle-codenameone + 11 + MIT License + utilities,networking + iOS, Android + CN1JSON.cn1lib + CodenameOne + + + CN1FontBox + Codename One Port of FontBox. Provides TTF font support with added features like stroking, filling, scaling, transforming, etc... + https://github.com/shannah/CN1FontBox + 5 + MIT License + fonts,ui + iOS, Android, Javascript, UWP, Simulator + + Steve Hannah + + + cn1-filechooser + Adds support for a native file chooser for opening files of any specified set of extensions and/or mimetypes. + https://github.com/shannah/cn1-filechooser + 9 + MIT License + utilities,storage + iOS, Android, Javascript, UWP, Simulator + + Codename One + + + com.codenameone + filechooser-lib + LATEST + pom + + + + + Codename One GeoViz Library + This library provides support for loading GeoJSON data in a Codename One application. It also provides a component to render the GeoJSON data. + https://github.com/shannah/CN1GeoViz + 1 + GPL+Classpath Exception + utilities,maps + iOS, Android, Javascript, Simulator + + Steve Hannah + + + Codename One XMLView Library + A codename one UI component for rendering XML. + https://github.com/shannah/cn1-xmlview + 2 + Apache 2.0 + utilities + All + + Steve Hannah + + + Codename One Offline Maps Library + MapProvider that works with the Codename One MapComponent class to provide support for offline maps + https://github.com/shannah/CN1OfflineMaps + 1 + LGPL + utilities,maps + All + CN1JTar.cn1lib + Steve Hannah + + + Codename One Cloudinary Support + This library adds support for the Cloudinary image management service to Codename One apps. The library itself is a direct port of the Cloudinary Java SDK. + https://github.com/shannah/cloudinary-codenameone + 3 + MIT + utilities + All + BouncyCastleCN1Lib.cn1lib + Steve Hannah + + + QR Code generator + A partial port of Zxing for j2me to CN1 for generating QR code + https://github.com/rwanghk/QRMaker + 1 + Apache 2.0 + utilities + All + + Roy Wang + + + ZXing Library + A port of ZXing Barcode scanner / generator to CN1 + https://github.com/rwanghk/ZXing-CN1 + 1 + Apache 2.0 + utilities + All + + Roy Wang + + + + Japanese text-to-speech (TTS) + Offline Japanese text-to-speech (TTS) for Codename One on Android using Open JTalk with a fine-tuned Mei voice + https://github.com/jsfan3/nihonjindes-cn1lib-tts + 1 + CC0 + third-party licenses + utilities,tts + Android + + Francesco Galgani + + + diff --git a/scripts/settings/common/src/test/java/com/codename1/settings/BuildHintCatalogTest.java b/scripts/settings/common/src/test/java/com/codename1/settings/BuildHintCatalogTest.java new file mode 100644 index 00000000000..dbd47edf61f --- /dev/null +++ b/scripts/settings/common/src/test/java/com/codename1/settings/BuildHintCatalogTest.java @@ -0,0 +1,54 @@ +package com.codename1.settings; + +import com.codename1.settings.hints.BuildHintCatalog; +import com.codename1.settings.hints.BuildHintType; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class BuildHintCatalogTest { + @Test + public void parsesDeveloperGuideBuildHintTable() { + String doc = """ + Before + |=== + |Name\t|Description + + |android.debug + |true/false defaults to true - indicates whether to include debug. + + |ios.plistInject + |Injects raw XML into the plist. + + |windows.signing.timestampUrl + |RFC 3161 timestamp server URL. + + |=== + After + """; + BuildHintCatalog catalog = BuildHintCatalog.fromAsciiDoc(doc); + assertNotNull(catalog.get("android.debug")); + assertEquals(BuildHintType.BOOLEAN, catalog.get("android.debug").type()); + assertEquals(BuildHintType.XML, catalog.get("ios.plistInject").type()); + assertEquals(BuildHintType.URL, catalog.get("windows.signing.timestampUrl").type()); + } + + @Test + public void packagedDeveloperGuideCatalogProvidesKnownHintTypes() throws Exception { + try (InputStream in = CodenameOneSettings.class.getResourceAsStream( + "/com/codename1/settings/hints/Advanced-Topics-Under-The-Hood.asciidoc")) { + assertNotNull(in, "The Settings jar should carry the developer-guide build hint table."); + String doc = new String(in.readAllBytes(), StandardCharsets.UTF_8); + BuildHintCatalog catalog = BuildHintCatalog.fromAsciiDoc(doc); + assertEquals(BuildHintType.INTEGER, catalog.get("java.version").type()); + assertEquals(BuildHintType.VERSION, catalog.get("build.cn1Version").type()); + assertEquals(BuildHintType.VERSION, catalog.get("ios.bundleVersion").type()); + assertEquals(BuildHintType.INTEGER, catalog.get("android.targetSDKVersion").type()); + assertEquals(BuildHintType.BOOLEAN, catalog.get("android.useAndroidX").type()); + } + } +} diff --git a/scripts/settings/common/src/test/java/com/codename1/settings/ExtensionCatalogMergerTest.java b/scripts/settings/common/src/test/java/com/codename1/settings/ExtensionCatalogMergerTest.java new file mode 100644 index 00000000000..1bc0a35141b --- /dev/null +++ b/scripts/settings/common/src/test/java/com/codename1/settings/ExtensionCatalogMergerTest.java @@ -0,0 +1,49 @@ +package com.codename1.settings; + +import com.codename1.settings.extensions.ExtensionCatalogMerger; +import com.codename1.settings.extensions.ExtensionDescriptor; +import com.codename1.settings.extensions.MavenDependency; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ExtensionCatalogMergerTest { + @Test + public void bundledWarningSurvivesRemoteCatalogRefresh() { + ExtensionDescriptor bundled = descriptor("outdated", "Older cn1lib warning"); + ExtensionDescriptor refreshed = descriptor("", ""); + + List merged = ExtensionCatalogMerger.preserveCompatibilityMetadata( + Arrays.asList(refreshed), Arrays.asList(bundled)); + + assertEquals(1, merged.size()); + assertEquals("outdated", merged.get(0).status()); + assertEquals("Older cn1lib warning", merged.get(0).warning()); + assertTrue(merged.get(0).hasCompatibilityWarning()); + assertSame(refreshed.dependency(), merged.get(0).dependency()); + } + + @Test + public void explicitRemoteWarningTakesPrecedence() { + ExtensionDescriptor bundled = descriptor("outdated", "Bundled warning"); + ExtensionDescriptor refreshed = descriptor("unsupported", "Remote warning"); + + ExtensionDescriptor merged = ExtensionCatalogMerger.preserveCompatibilityMetadata( + Arrays.asList(refreshed), Arrays.asList(bundled)).get(0); + + assertSame(refreshed, merged); + assertEquals("Remote warning", merged.warning()); + } + + private ExtensionDescriptor descriptor(String status, String warning) { + return new ExtensionDescriptor("Admob Fullscreen Ads", "Remote description", + new MavenDependency("com.codenameone", "admob-fullscreen-lib", "LATEST", "pom"), true, + "AdmobFullScreen.cn1lib", "https://example.com", "Apache 2.0", "iOS, Android", + "Author", "ads", "", "6", status, warning); + } +} diff --git a/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java b/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java new file mode 100644 index 00000000000..e82b16e0ed2 --- /dev/null +++ b/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java @@ -0,0 +1,63 @@ +package com.codename1.settings; + +import com.codename1.settings.extensions.MavenDependency; +import com.codename1.settings.extensions.PomEditor; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class PomEditorTest { + @Test + public void addsDependencyToExistingDependenciesBlock() { + String pom = "\n \n \n\n"; + MavenDependency dep = new MavenDependency("com.codenameone", "cn1-demo", "1.0"); + String updated = PomEditor.addDependency(pom, dep); + assertTrue(updated.contains("com.codenameone")); + assertTrue(updated.contains("cn1-demo")); + assertEquals(updated, PomEditor.addDependency(updated, dep)); + } + + @Test + public void removesOnlyTheSelectedDependency() { + String pom = "\n \n" + + " \n com.example\n keep\n \n" + + " \n com.codenameone\n remove\n \n" + + " \n\n"; + MavenDependency dep = new MavenDependency("com.codenameone", "remove", "1.0"); + String updated = PomEditor.removeDependency(pom, dep); + assertTrue(updated.contains("keep")); + assertTrue(!updated.contains("remove")); + assertEquals(updated, PomEditor.removeDependency(updated, dep)); + } + + @Test + public void dependencyCoordinatesMustAppearInTheSameBlock() { + String pom = "" + + "com.exampleone" + + "com.othertarget" + + ""; + + assertFalse(PomEditor.containsDependency(pom, + new MavenDependency("com.example", "target", "1.0"))); + } + + @Test + public void addsToProjectDependenciesInsteadOfDependencyManagementOrProfiles() { + String pom = "\n" + + " " + + "com.managedmanaged" + + "\n" + + " \n \n" + + " \n \n" + + "\n"; + MavenDependency dep = new MavenDependency("com.codenameone", "cn1-demo", "1.0"); + + String updated = PomEditor.addDependency(pom, dep); + + int inserted = updated.indexOf("cn1-demo"); + assertTrue(inserted > updated.indexOf("")); + assertTrue(inserted < updated.indexOf("")); + } +} diff --git a/scripts/settings/common/src/test/java/com/codename1/settings/SettingsThemeTest.java b/scripts/settings/common/src/test/java/com/codename1/settings/SettingsThemeTest.java new file mode 100644 index 00000000000..44e7bd45a73 --- /dev/null +++ b/scripts/settings/common/src/test/java/com/codename1/settings/SettingsThemeTest.java @@ -0,0 +1,180 @@ +package com.codename1.settings; + +import com.codename1.ui.css.CSSThemeCompiler; +import com.codename1.ui.util.MutableResource; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SettingsThemeTest { + private static final Path COMMON_DIR = Paths.get("../common").normalize(); + private static final Path SETTINGS = COMMON_DIR.resolve("codenameone_settings.properties"); + private static final Path THEME_CSS = COMMON_DIR.resolve("src/main/css/theme.css"); + private static final Path APP_SOURCE = COMMON_DIR.resolve("src/main/java/com/codename1/settings/CodenameOneSettings.java"); + private static final Path COMPILED_THEME = COMMON_DIR.resolve("target/classes/theme.res"); + + @Test + public void settingsAppEnablesCssThemeCompilation() throws Exception { + String settings = Files.readString(SETTINGS, StandardCharsets.UTF_8); + assertTrue(settings.contains("codename1.cssTheme=true"), + "Codename One CSS is only baked into theme.res when codename1.cssTheme=true is present. " + + "This follows the Initializr skill convention for common/src/main/css/theme.css."); + } + + @Test + public void themeCssCompilesToThemeResource() throws Exception { + String css = Files.readString(THEME_CSS, StandardCharsets.UTF_8); + assertTrue(css.contains("includeNativeBool: true"), + "theme.css should include the modern native theme base before Settings overrides. " + + "This keeps modern native components such as Switch styled correctly."); + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(css, resource, "SettingsThemeCompileCheck"); + assertNotNull(resource.getTheme("SettingsThemeCompileCheck"), + "theme.css should compile with the Codename One CSS compiler used by Initializr projects."); + assertFalse(Pattern.compile("(?m)^\\s*border\\s*:\\s*0\\s*;").matcher(css).find(), + "Codename One CSS rejects unitless 'border: 0'; use border-width: 0 instead."); + } + + @Test + public void packagedThemeResExistsAndContainsThemeData() throws Exception { + assertTrue(Files.isRegularFile(COMPILED_THEME), + "common/target/classes/theme.res should exist after Maven package/test. " + + "If this is missing, the JavaSE app starts with '/theme.res not found'."); + byte[] data = Files.readAllBytes(COMPILED_THEME); + String resourceText = new String(data, StandardCharsets.ISO_8859_1); + assertTrue(data.length > 1024, "theme.res should contain compiled theme data, not an empty placeholder."); + assertTrue(resourceText.contains("Theme"), "theme.res should include the default compiled theme entry."); + assertTrue(resourceText.contains("SettingsForm"), + "theme.res should include Settings UIID data from common/src/main/css/theme.css."); + } + + @Test + public void everyProgrammaticUiidHasLightAndDarkCssRules() throws Exception { + String source = Files.readString(APP_SOURCE, StandardCharsets.UTF_8); + String css = Files.readString(THEME_CSS, StandardCharsets.UTF_8); + Set uiids = referencedUiids(source); + assertFalse(uiids.isEmpty(), "Expected to find uiid(\"...\") references in CodenameOneSettings.java"); + Set selectors = cssSelectors(css); + for (String uiid : uiids) { + assertTrue(selectors.contains(uiid), + "Missing light CSS selector for UIID '" + uiid + "'. " + + "CN1 CSS selectors target component UIIDs, per Initializr skill guidance."); + assertTrue(selectors.contains(uiid + "Dark"), + "Missing dark CSS selector for UIID '" + uiid + "Dark'. " + + "The Settings app toggles by appending 'Dark' at runtime, so every styled UIID needs both."); + } + } + + @Test + public void darkThemeMatchesReferencePalette() throws Exception { + String css = Files.readString(THEME_CSS, StandardCharsets.UTF_8); + String[] colors = { + "#071B4D", + "#102B66", + "#163575", + "#0E2A61", + "#4C6EA8", + "#7390C0", + "#F5F8FF", + "#A8B8DA", + "#7E93BC", + "#4D86FF", + "#B8D532" + }; + for (String color : colors) { + assertTrue(css.contains(color), "Dark theme should include reference color " + color); + } + } + + @Test + public void focusedInputsRemainVisibleInDarkMode() throws Exception { + String css = Files.readString(THEME_CSS, StandardCharsets.UTF_8); + assertTrue(css.contains("SettingsSearchBoxFocusedDark")); + assertTrue(css.contains("SettingsSearchFieldDark, SettingsSearchFieldDark.selected")); + assertTrue(css.contains("border-color: #4D86FF")); + assertTrue(css.contains("color: #F5F8FF")); + } + + @Test + public void activeBuildHintControlsStayAlignedToTheRight() throws Exception { + String source = Files.readString(APP_SOURCE, StandardCharsets.UTF_8); + assertTrue(source.contains("widthPercentage(72), new Container()")); + assertTrue(source.contains("widthPercentage(28), controls")); + assertTrue(source.contains("controls.add(BorderLayout.EAST, remove)")); + } + + @Test + public void uiUsesDensityAwareThemeSizingInsteadOfFixedComponentDimensions() throws Exception { + String source = Files.readString(APP_SOURCE, StandardCharsets.UTF_8); + String css = Files.readString(THEME_CSS, StandardCharsets.UTF_8); + assertFalse(Pattern.compile("\\.setPreferred(?:W|H|Size)\\s*\\(").matcher(source).find(), + "CN1 component sizes must come from density-aware fonts, padding, margins, and layouts, " + + "not setPreferredWidth/Height/Size calls that break on Retina displays."); + assertFalse(Pattern.compile("font-size\\s*:\\s*[0-9.]+px", Pattern.CASE_INSENSITIVE).matcher(css).find(), + "Theme font sizes must use physical mm units so Retina density does not create miniature text."); + assertTrue(source.contains("new TableLayout(1, 2)"), + "The main content width should be responsive through TableLayout percentages."); + assertTrue(source.contains("new GridLayout(3, 2)"), + "The Basic form should use a responsive two-column GridLayout."); + assertTrue(source.contains("private Container configureToolbar()"), + "Native desktop chrome should use a stable top-bar container, not a second Toolbar instance."); + assertTrue(source.contains("section == Section.EXTENSIONS ? 100 : 72"), + "Extensions should use the full content width while forms retain a readable measure."); + } + + @Test + public void retinaTypographyHasReadablePhysicalMinimums() throws Exception { + String css = Files.readString(THEME_CSS, StandardCharsets.UTF_8); + assertMmFontAtLeast(css, "SettingsForm", 3.2); + assertMmFontAtLeast(css, "SettingsToolbarBrand", 3.4); + assertMmFontAtLeast(css, "SettingsPageTitle", 5.5); + assertMmFontAtLeast(css, "SettingsField", 3.0); + assertMmFontAtLeast(css, "SettingsPopupLabel", 3.0); + assertMmFontAtLeast(css, "SettingsExtensionTitle", 3.6); + assertMmFontAtLeast(css, "SettingsExtensionText", 2.8); + } + + private static void assertMmFontAtLeast(String css, String selector, double minimum) { + Matcher rule = Pattern.compile("(?m)^\\s*" + Pattern.quote(selector) + + "\\s*(?:,[^\\{]+)?\\{([^}]*)}", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL).matcher(css); + assertTrue(rule.find(), "Missing CSS rule for " + selector); + Matcher size = Pattern.compile("font-size\\s*:\\s*([0-9.]+)mm", + Pattern.CASE_INSENSITIVE).matcher(rule.group(1)); + assertTrue(size.find(), selector + " should declare a physical mm font size."); + assertTrue(Double.parseDouble(size.group(1)) >= minimum, + selector + " font size must remain at least " + minimum + "mm for Retina readability."); + } + + private static Set referencedUiids(String source) { + LinkedHashSet uiids = new LinkedHashSet(); + Matcher matcher = Pattern.compile("uiid\\(\"([A-Za-z0-9_]+)\"\\)").matcher(source); + while (matcher.find()) { + uiids.add(matcher.group(1)); + } + return uiids; + } + + private static Set cssSelectors(String css) { + LinkedHashSet selectors = new LinkedHashSet(); + Matcher matcher = Pattern.compile("(?m)^\\s*([A-Za-z][A-Za-z0-9_]*(?:\\s*,\\s*[A-Za-z][A-Za-z0-9_]*)*)\\s*\\{").matcher(css); + while (matcher.find()) { + String[] parts = matcher.group(1).split(","); + for (String part : parts) { + selectors.add(part.trim()); + } + } + return selectors; + } +} diff --git a/scripts/settings/javase/pom.xml b/scripts/settings/javase/pom.xml new file mode 100644 index 00000000000..a27a775dbad --- /dev/null +++ b/scripts/settings/javase/pom.xml @@ -0,0 +1,194 @@ + + + 4.0.0 + + com.codenameone.settings + cn1-settings + 8.0-SNAPSHOT + + com.codenameone + codenameone-settings + codenameone-settings + Standalone Codename One settings editor. + + + UTF-8 + 17 + 17 + javase + javase + + + + ${project.basedir}/../common/src/test/java + + + ${project.basedir}/../common + + icon.png + + + + ${project.basedir}/src/desktop/resources + + NativeTheme.res + + + + + + codenameone-maven-plugin + com.codenameone + ${cn1.plugin.version} + + + add-se-sources + generate-javase-sources + generate-sources + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.4.0 + + + add-javase-test-sources + generate-test-sources + add-test-source + + + ${project.basedir}/src/test/java + + + + + + + + + + + com.codenameone.settings + ${cn1app.name}-common + ${project.version} + + + com.codenameone.settings + ${cn1app.name}-common + ${project.version} + tests + test + + + com.codenameone + codenameone-core + test + + + com.codenameone + codenameone-javase + test + + + org.junit.jupiter + junit-jupiter-api + 5.10.2 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.10.2 + test + + + + + + executable-jar + + javase + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + compile + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + read-project-properties + + + ${basedir}/../common/codenameone_settings.properties + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + generate-desktop-app-wrapper + generate-sources + generate-desktop-app-wrapper + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-dependencies + prepare-package + copy-dependencies + + ${project.build.directory}/libs + runtime + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + true + libs/ + com.codename1.settings.CodenameOneSettingsLauncher + + + Codename One Settings + Codename One Settings + ${project.version} + java.desktop/com.apple.eawt.event java.desktop/com.apple.eawt + + + + + + + + + diff --git a/scripts/settings/javase/src/desktop/java/com/codename1/settings/CodenameOneSettingsLauncher.java b/scripts/settings/javase/src/desktop/java/com/codename1/settings/CodenameOneSettingsLauncher.java new file mode 100644 index 00000000000..ae1f6e87ab6 --- /dev/null +++ b/scripts/settings/javase/src/desktop/java/com/codename1/settings/CodenameOneSettingsLauncher.java @@ -0,0 +1,10 @@ +package com.codename1.settings; + +public final class CodenameOneSettingsLauncher { + private CodenameOneSettingsLauncher() { + } + + public static void main(String[] args) { + CodenameOneSettingsStub.main(args); + } +} diff --git a/scripts/settings/javase/src/desktop/java/com/codename1/settings/CodenameOneSettingsStub.java b/scripts/settings/javase/src/desktop/java/com/codename1/settings/CodenameOneSettingsStub.java new file mode 100644 index 00000000000..d290e379173 --- /dev/null +++ b/scripts/settings/javase/src/desktop/java/com/codename1/settings/CodenameOneSettingsStub.java @@ -0,0 +1,318 @@ +package com.codename1.settings; + +import com.codename1.impl.javase.JavaSEPort; +import com.codename1.ui.Display; + +import java.awt.Desktop; +import java.awt.GraphicsDevice; +import java.awt.GraphicsEnvironment; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.KeyboardFocusManager; +import java.awt.Taskbar; +import java.awt.Toolkit; +import java.awt.event.KeyEvent; +import java.awt.event.WindowEvent; +import java.awt.event.WindowListener; +import java.awt.image.BufferedImage; +import java.io.File; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +import javax.imageio.ImageIO; +import javax.swing.ImageIcon; +import javax.swing.JFrame; +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import javax.swing.KeyStroke; + +public class CodenameOneSettingsStub implements Runnable, WindowListener { + static final String APP_DISPLAY_NAME = "Codename One Settings"; + private static final String APP_TITLE = "Codename One Settings"; + private static final String APP_STORAGE_NAME = "CodenameOneSettings"; + static final String APP_VERSION = resolveApplicationVersion(); + private static final int APP_WIDTH = 1470; + private static final int APP_HEIGHT = 612; + private static final double APP_UI_SCALE = 1.0; + private static final boolean APP_RESIZEABLE = true; + private static final boolean APP_FULLSCREEN = false; + private static final String APP_DESKTOP_TITLEBAR = "native"; + private static final boolean APP_DESKTOP_INTERACTIVE_SCROLLBARS = true; + private static final boolean isWindows = File.separatorChar == '\\'; + + private static JFrame frm; + private CodenameOneSettings mainApp; + + public static void main(String[] args) { + configureDesktopAppIdentity(); + if (System.getProperty("settings.version") == null) { + System.setProperty("settings.version", APP_VERSION); + } + try { + Class.forName("org.cef.CefApp"); + System.setProperty("cn1.javase.implementation", "cef"); + } catch (Throwable ex) { + } + + JavaSEPort.setNativeTheme("/NativeTheme.res"); + JavaSEPort.blockMonitors(); + JavaSEPort.setAppHomeDir("." + APP_STORAGE_NAME); + JavaSEPort.setExposeFilesystem(true); + JavaSEPort.setTablet(true); + JavaSEPort.setUseNativeInput(true); + JavaSEPort.setShowEDTViolationStacks(false); + JavaSEPort.setShowEDTWarnings(false); + JavaSEPort.setFullScreen(APP_FULLSCREEN); + JavaSEPort.setDesktopTitleBarMode(APP_DESKTOP_TITLEBAR); + JavaSEPort.setDesktopInteractiveScrollbars(APP_DESKTOP_INTERACTIVE_SCROLLBARS); + if (isWindows) { + JavaSEPort.setFontFaces("ArialUnicodeMS", "SansSerif", "Monospaced"); + } else { + JavaSEPort.setFontFaces("Arial", "SansSerif", "Monospaced"); + } + + frm = new JFrame(APP_TITLE); + Toolkit tk = Toolkit.getDefaultToolkit(); + JavaSEPort.setDefaultPixelMilliRatio(tk.getScreenResolution() / 25.4 + * JavaSEPort.getRetinaScale() * APP_UI_SCALE); + Display.init(frm.getContentPane()); + Display.getInstance().setProperty("AppName", APP_DISPLAY_NAME); + Display.getInstance().setProperty("AppVersion", APP_VERSION); + Display.getInstance().setProperty("Platform", System.getProperty("os.name")); + Display.getInstance().setProperty("OSVer", System.getProperty("os.version")); + installFontShortcutDispatcher(); + SwingUtilities.invokeLater(new CodenameOneSettingsStub()); + } + + static void configureDesktopAppIdentity() { + System.setProperty("apple.awt.application.name", APP_DISPLAY_NAME); + System.setProperty("com.apple.mrj.application.apple.menu.about.name", APP_DISPLAY_NAME); + System.setProperty("sun.awt.application.name", APP_DISPLAY_NAME); + System.setProperty("sun.awt.X11.XWMClass", APP_STORAGE_NAME); + } + + static String resolveApplicationVersion() { + Package appPackage = CodenameOneSettingsStub.class.getPackage(); + String version = appPackage == null ? null : appPackage.getImplementationVersion(); + return version == null || version.length() == 0 ? "development" : version; + } + + private static void installFontShortcutDispatcher() { + KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(e -> { + if (e.getID() != KeyEvent.KEY_PRESSED || (!e.isMetaDown() && !e.isControlDown())) { + return false; + } + switch (e.getKeyCode()) { + case KeyEvent.VK_PLUS: + case KeyEvent.VK_EQUALS: + case KeyEvent.VK_ADD: + CodenameOneSettings.adjustActiveFontSizeForDesktopShortcut(2); + return true; + case KeyEvent.VK_MINUS: + case KeyEvent.VK_SUBTRACT: + CodenameOneSettings.adjustActiveFontSizeForDesktopShortcut(-2); + return true; + case KeyEvent.VK_0: + case KeyEvent.VK_NUMPAD0: + CodenameOneSettings.resetActiveFontSizeForDesktopShortcut(); + return true; + default: + return false; + } + }); + } + + @Override + public void run() { + frm.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + frm.setName(APP_DISPLAY_NAME); + frm.addWindowListener(this); + applyApplicationIcon(frm); + installFileMenu(frm); + installAboutHandler(); + GraphicsDevice gd = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice(); + if (APP_FULLSCREEN && gd.isFullScreenSupported()) { + frm.setResizable(false); + frm.setUndecorated(true); + gd.setFullScreenWindow(frm); + } else { + frm.setLocationByPlatform(true); + frm.setResizable(APP_RESIZEABLE); + frm.getContentPane().setPreferredSize(new java.awt.Dimension(APP_WIDTH, APP_HEIGHT)); + frm.getContentPane().setMinimumSize(new java.awt.Dimension(900, 560)); + frm.pack(); + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (Display.getInstance().isEdt()) { + mainApp = new CodenameOneSettings(); + mainApp.init(this); + mainApp.start(); + SwingUtilities.invokeLater(this); + } else { + frm.setVisible(true); + scheduleScreenshotIfRequested(); + } + } + }); + } + + private static void scheduleScreenshotIfRequested() { + String screenshot = System.getProperty("settings.screenshot"); + if (screenshot == null || screenshot.length() == 0) { + return; + } + Timer timer = new Timer(1200, e -> { + try { + BufferedImage image = new BufferedImage(frm.getContentPane().getWidth(), + frm.getContentPane().getHeight(), BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = image.createGraphics(); + frm.getContentPane().paint(graphics); + graphics.dispose(); + ImageIO.write(image, "png", new File(screenshot)); + } catch (Exception ex) { + ex.printStackTrace(); + } finally { + if (!"false".equals(System.getProperty("settings.screenshot.exit"))) { + Display.getInstance().exitApplication(); + } + } + }); + timer.setRepeats(false); + timer.start(); + } + + private static void applyApplicationIcon(JFrame frame) { + List icons = loadApplicationIcons(); + if (!icons.isEmpty()) { + frame.setIconImages(icons); + Image largestIcon = icons.get(icons.size() - 1); + applyTaskbarIcon(largestIcon); + applyAppleApplicationIcon(largestIcon); + requestDesktopForeground(); + } + } + + private static List loadApplicationIcons() { + ArrayList icons = new ArrayList(); + addIcon(icons, "/applicationIconImage_16x16.png"); + addIcon(icons, "/applicationIconImage_20x20.png"); + addIcon(icons, "/applicationIconImage_32x32.png"); + addIcon(icons, "/applicationIconImage_40x40.png"); + addIcon(icons, "/applicationIconImage_64x64.png"); + addIcon(icons, "/icon.png"); + return icons; + } + + private static void addIcon(List icons, String resource) { + URL url = CodenameOneSettingsStub.class.getResource(resource); + if (url != null) { + icons.add(new ImageIcon(url).getImage()); + } + } + + private static void applyTaskbarIcon(Image icon) { + try { + if (Taskbar.isTaskbarSupported()) { + Taskbar taskbar = Taskbar.getTaskbar(); + if (taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) { + taskbar.setIconImage(icon); + } + } + } catch (Throwable ignored) { + } + } + + private static void applyAppleApplicationIcon(Image icon) { + try { + Class applicationClass = Class.forName("com.apple.eawt.Application"); + Object application = applicationClass.getMethod("getApplication").invoke(null); + applicationClass.getMethod("setDockIconImage", Image.class).invoke(application, icon); + } catch (Throwable ignored) { + } + } + + private static void requestDesktopForeground() { + try { + if (Desktop.isDesktopSupported()) { + Desktop desktop = Desktop.getDesktop(); + if (desktop.isSupported(Desktop.Action.APP_REQUEST_FOREGROUND)) { + desktop.requestForeground(true); + } + } + } catch (Throwable ignored) { + } + } + + private static void installFileMenu(JFrame frame) { + JMenuBar bar = new JMenuBar(); + JMenu file = new JMenu("File"); + file.add(menuItem("Save", KeyEvent.VK_S, () -> CodenameOneSettings.saveActiveSettingsForDesktopMenu())); + file.add(menuItem("Open Project Folder", KeyEvent.VK_O, () -> CodenameOneSettings.openActiveProjectFolderForDesktopMenu())); + file.addSeparator(); + file.add(menuItem("Basic", KeyEvent.VK_1, () -> CodenameOneSettings.goActiveSectionForDesktopMenu(CodenameOneSettings.Section.BASIC))); + file.add(menuItem("Build Hints", KeyEvent.VK_2, () -> CodenameOneSettings.goActiveSectionForDesktopMenu(CodenameOneSettings.Section.BUILD_HINTS))); + file.add(menuItem("Extensions", KeyEvent.VK_3, () -> CodenameOneSettings.goActiveSectionForDesktopMenu(CodenameOneSettings.Section.EXTENSIONS))); + file.addSeparator(); + file.add(menuItem("Toggle Dark Mode", KeyEvent.VK_D, () -> CodenameOneSettings.toggleActiveDarkModeForDesktopMenu())); + file.add(menuItem("Increase Font Size", KeyEvent.VK_EQUALS, () -> CodenameOneSettings.adjustActiveFontSizeForDesktopShortcut(2))); + file.add(menuItem("Decrease Font Size", KeyEvent.VK_MINUS, () -> CodenameOneSettings.adjustActiveFontSizeForDesktopShortcut(-2))); + file.add(menuItem("Reset Font Size", KeyEvent.VK_0, () -> CodenameOneSettings.resetActiveFontSizeForDesktopShortcut())); + bar.add(file); + frame.setJMenuBar(bar); + } + + private static void installAboutHandler() { + try { + if (Desktop.isDesktopSupported() + && Desktop.getDesktop().isSupported(Desktop.Action.APP_ABOUT)) { + Desktop.getDesktop().setAboutHandler(e -> CodenameOneSettings.showActiveAboutForDesktopMenu()); + } + } catch (Throwable ignored) { + } + } + + private static JMenuItem menuItem(String text, int key, Runnable action) { + JMenuItem item = new JMenuItem(text); + if (key > 0) { + int mask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx(); + item.setAccelerator(KeyStroke.getKeyStroke(key, mask)); + } + item.addActionListener(e -> action.run()); + return item; + } + + @Override + public void windowOpened(WindowEvent e) { + } + + @Override + public void windowClosing(WindowEvent e) { + Display.getInstance().exitApplication(); + } + + @Override + public void windowClosed(WindowEvent e) { + } + + @Override + public void windowIconified(WindowEvent e) { + } + + @Override + public void windowDeiconified(WindowEvent e) { + } + + @Override + public void windowActivated(WindowEvent e) { + } + + @Override + public void windowDeactivated(WindowEvent e) { + } +} diff --git a/scripts/settings/javase/src/desktop/resources/NativeTheme.res b/scripts/settings/javase/src/desktop/resources/NativeTheme.res new file mode 100644 index 00000000000..83e067b6969 Binary files /dev/null and b/scripts/settings/javase/src/desktop/resources/NativeTheme.res differ diff --git a/scripts/settings/javase/src/test/java/com/codename1/settings/SettingsDesktopIdentityTest.java b/scripts/settings/javase/src/test/java/com/codename1/settings/SettingsDesktopIdentityTest.java new file mode 100644 index 00000000000..bb15c7e35aa --- /dev/null +++ b/scripts/settings/javase/src/test/java/com/codename1/settings/SettingsDesktopIdentityTest.java @@ -0,0 +1,85 @@ +package com.codename1.settings; + +import org.junit.jupiter.api.Test; + +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SettingsDesktopIdentityTest { + @Test + public void desktopIdentityUsesSettingsName() { + assertEquals("Codename One Settings", CodenameOneSettingsStub.APP_DISPLAY_NAME); + assertNotNull(CodenameOneSettingsStub.APP_VERSION); + assertFalse(CodenameOneSettingsStub.APP_VERSION.isEmpty()); + } + + @Test + public void desktopTextEditingUsesNativeCaretAndPersistentFocus() throws Exception { + String desktop = Files.readString(Path.of("src/desktop/java/com/codename1/settings/CodenameOneSettingsStub.java")); + assertTrue(desktop.contains("JavaSEPort.setUseNativeInput(true)")); + assertFalse(desktop.contains("JavaSEPort.setUseNativeInput(false)")); + } + + @Test + public void nativeThemeResourceIsPackagedForModernControls() { + URL nativeTheme = CodenameOneSettingsStub.class.getResource("/NativeTheme.res"); + assertNotNull(nativeTheme, + "JavaSE Settings must package /NativeTheme.res so includeNativeBool can style native components."); + } + + @Test + public void bundledExtensionCatalogIsPackaged() { + URL catalog = CodenameOneSettingsStub.class.getResource("/com/codename1/settings/extensions/CN1Libs.xml"); + assertNotNull(catalog, "Settings should ship the legacy cn1lib XML catalog for offline extension browsing."); + } + + @Test + public void outdatedExtensionsCarryExplicitCompatibilityMetadata() throws Exception { + URL catalog = CodenameOneSettingsStub.class.getResource("/com/codename1/settings/extensions/CN1Libs.xml"); + assertNotNull(catalog); + String xml = new String(catalog.openStream().readAllBytes(), StandardCharsets.UTF_8); + int admob = xml.indexOf("Admob Fullscreen Ads"); + int end = xml.indexOf("", admob); + assertTrue(admob >= 0 && end > admob); + String entry = xml.substring(admob, end); + assertTrue(entry.contains("outdated")); + assertTrue(entry.contains("")); + } + + @Test + public void macOsOwnsTheOnlyAboutCommand() throws Exception { + String common = Files.readString(Path.of("../common/src/main/java/com/codename1/settings/CodenameOneSettings.java")); + String desktop = Files.readString(Path.of("src/desktop/java/com/codename1/settings/CodenameOneSettingsStub.java")); + assertFalse(common.contains("popupAction(menu, \"About\"")); + assertFalse(common.contains("menuCommand(\"About\"")); + assertTrue(desktop.contains("setAboutHandler")); + } + + @Test + public void packagedVersionIsPropagatedToAboutDialog() throws Exception { + String pom = Files.readString(Path.of("pom.xml")); + String common = Files.readString(Path.of("../common/src/main/java/com/codename1/settings/CodenameOneSettings.java")); + String desktop = Files.readString(Path.of("src/desktop/java/com/codename1/settings/CodenameOneSettingsStub.java")); + assertTrue(pom.contains("${project.version}")); + assertTrue(desktop.contains("getImplementationVersion()")); + assertTrue(desktop.contains("settings.version")); + assertTrue(common.contains("prop(\"settings.version\", \"development\")")); + } + + @Test + public void centralPublicationMetadataIsInheritedByAllModules() throws Exception { + String parent = Files.readString(Path.of("../pom.xml")); + assertTrue(parent.contains("")); + assertTrue(parent.contains("")); + assertTrue(parent.contains("")); + assertTrue(parent.contains("central-publishing-maven-plugin")); + assertTrue(parent.contains("maven-gpg-plugin")); + } +} diff --git a/scripts/settings/pom.xml b/scripts/settings/pom.xml new file mode 100644 index 00000000000..ac7954b0357 --- /dev/null +++ b/scripts/settings/pom.xml @@ -0,0 +1,192 @@ + + + 4.0.0 + com.codenameone.settings + cn1-settings + 8.0-SNAPSHOT + pom + Codename One Settings + Standalone Maven-first settings editor for Codename One projects. + https://www.codenameone.com + + + + GPL v2 With Classpath Exception + https://openjdk.java.net/legal/gplv2+ce.html + repo + + + + + shai + Shai Almog + shai.almog@codenameone.com + +4 + + + chen + Chen Fishbein + chen.fishbein@codenameone.com + +4 + + + shannah + Steve Hannah + steve.hannah@codenameone.com + -8 + + + + https://github.com/codenameone/CodenameOne + scm:git:git@github.com:codenameone/CodenameOne.git + + + + common + + + + 8.0-SNAPSHOT + 8.0-SNAPSHOT + UTF-8 + 17 + 17 + 17 + 3.11.0 + cn1-settings + + + + + + com.codenameone + codenameone-core + ${cn1.version} + + + com.codenameone + codenameone-javase + ${cn1.version} + + + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.2 + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + org.apache.maven.plugins + maven-dependency-plugin + 3.6.1 + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + javase + + + codename1.platform + javase + + true + + + javase + + + + settings-central + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + attach-sources + jar-no-fork + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.6.3 + + none + false + + + + attach-javadocs + jar + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.5 + + + sign-artifacts + verify + sign + + ${gpg.passphrase} + + + + + + --pinentry-mode + loopback + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.8.0 + true + + central + true + + + + + + + diff --git a/scripts/settings/scripts/capture-html-reference.mjs b/scripts/settings/scripts/capture-html-reference.mjs new file mode 100644 index 00000000000..14f4a4297eb --- /dev/null +++ b/scripts/settings/scripts/capture-html-reference.mjs @@ -0,0 +1,222 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const args = parseArgs(process.argv.slice(2)); +const html = resolve(args.html || `${process.env.HOME}/Downloads/Codename One Settings.html`); +const output = resolve(args.output || "scripts/settings/target/reference.png"); +const metricsOutput = resolve(args.metrics || output.replace(/\.png$/i, ".json")); +const width = positiveInt(args.width, 1470); +const height = positiveInt(args.height, 612); +const scale = positiveNumber(args.scale, 2); +const section = (args.section || "basic").toLowerCase(); +const dark = args.theme !== "light"; +const menuOpen = args.menu === "true" || args.menu === true; +const chrome = args.chrome || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + +const profile = await mkdtemp(`${tmpdir()}/cn1-settings-reference-`); +let child; +let cdp; + +try { + const browserWs = await launchChrome(); + cdp = await connect(browserWs); + const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" }); + const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true }); + + await cdp.send("Page.enable", {}, sessionId); + await cdp.send("Runtime.enable", {}, sessionId); + await cdp.send("Emulation.setDeviceMetricsOverride", { + width, + height, + deviceScaleFactor: scale, + mobile: false, + screenWidth: width, + screenHeight: height + }, sessionId); + + await cdp.send("Page.navigate", { url: pathToFileURL(html).href }, sessionId); + await waitForApp(sessionId); + await evaluate(sessionId, `localStorage.setItem('cn1settings_theme', ${JSON.stringify(dark ? "dark" : "light")}); location.reload()`); + await waitForApp(sessionId); + + if (section !== "basic") { + const label = section === "extensions" ? "Ext" : section === "hints" ? "Hints" : section; + await evaluate(sessionId, `(() => { + const item = [...document.querySelectorAll('.railitem,.navitem')] + .find(node => node.textContent.trim() === ${JSON.stringify(label)}); + if (!item) throw new Error('Section control not found: ${label}'); + item.click(); + })()`); + await waitForSelector(sessionId, section === "extensions" ? ".extgrid" : ".hintgroup"); + } + + if (menuOpen) { + await evaluate(sessionId, `(() => { + const buttons = [...document.querySelectorAll('.topbar .iconbtn')]; + if (!buttons.length) throw new Error('Toolbar menu control not found'); + buttons[buttons.length - 1].click(); + })()`); + await waitForSelector(sessionId, ".menu"); + } + + const metrics = await evaluate(sessionId, `(() => { + const selectors = ['.app','.topbar','.brand','.mark','.appname','.pathchip','.topbar .btn','.topbar .iconbtn','.menu','.rail','.railitem','.content','.cwrap','.h1','.sub','.grid2','.field','.input','.icondrop','.searchbox','.extgrid','.extcard','.hintcard']; + const result = { viewport: { width: innerWidth, height: innerHeight, scale: devicePixelRatio }, theme: document.querySelector('.app')?.dataset.theme || document.querySelector('[data-theme]')?.dataset.theme }; + for (const selector of selectors) { + result[selector] = [...document.querySelectorAll(selector)].map(node => { + const r = node.getBoundingClientRect(); + const style = getComputedStyle(node); + return { + text: node.textContent.trim().replace(/\\s+/g, ' ').slice(0, 120), + x: round(r.x), y: round(r.y), width: round(r.width), height: round(r.height), + color: style.color, background: style.backgroundColor, border: style.borderColor, + fontFamily: style.fontFamily, fontSize: style.fontSize, fontWeight: style.fontWeight, + padding: style.padding, gap: style.gap + }; + }); + } + return result; + function round(value) { return Math.round(value * 100) / 100; } + })()`); + + const shot = await cdp.send("Page.captureScreenshot", { format: "png", fromSurface: true }, sessionId); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, Buffer.from(shot.data, "base64")); + await writeFile(metricsOutput, `${JSON.stringify(metrics, null, 2)}\n`); + console.log(`Captured ${output} (${width}x${height} @${scale}x)`); + console.log(`Measured ${metricsOutput}`); +} finally { + cdp?.close(); + child?.kill("SIGTERM"); + await rm(profile, { recursive: true, force: true }); +} + +async function launchChrome() { + child = spawn(chrome, [ + "--headless=new", + "--disable-gpu", + "--disable-background-networking", + "--disable-component-update", + "--disable-default-apps", + "--disable-features=Translate,MediaRouter", + "--hide-scrollbars", + "--no-first-run", + "--no-default-browser-check", + "--remote-debugging-port=0", + `--user-data-dir=${profile}`, + "about:blank" + ], { stdio: ["ignore", "ignore", "pipe"] }); + + return new Promise((resolveWs, rejectWs) => { + let stderr = ""; + const timeout = setTimeout(() => rejectWs(new Error(`Chrome did not expose DevTools. ${stderr}`)), 15000); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", chunk => { + stderr += chunk; + const match = stderr.match(/DevTools listening on (ws:\/\/[^\s]+)/); + if (match) { + clearTimeout(timeout); + resolveWs(match[1]); + } + }); + child.once("exit", code => { + clearTimeout(timeout); + rejectWs(new Error(`Chrome exited before capture (code ${code}). ${stderr}`)); + }); + }); +} + +async function connect(url) { + const ws = new WebSocket(url); + const pending = new Map(); + let nextId = 1; + await new Promise((resolveOpen, rejectOpen) => { + ws.addEventListener("open", resolveOpen, { once: true }); + ws.addEventListener("error", rejectOpen, { once: true }); + }); + ws.addEventListener("message", event => { + const message = JSON.parse(String(event.data)); + if (!message.id) return; + const waiter = pending.get(message.id); + if (!waiter) return; + pending.delete(message.id); + if (message.error) waiter.reject(new Error(`${waiter.method}: ${message.error.message}`)); + else waiter.resolve(message.result || {}); + }); + return { + send(method, params = {}, sessionId) { + return new Promise((resolveSend, rejectSend) => { + const id = nextId++; + pending.set(id, { resolve: resolveSend, reject: rejectSend, method }); + ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); + }); + }, + close() { ws.close(); } + }; +} + +async function waitForApp(sessionId) { + await waitForSelector(sessionId, ".app"); + await waitFor(sessionId, `document.fonts.status === 'loaded'`, 10000); + await delay(150); +} + +async function waitForSelector(sessionId, selector) { + await waitFor(sessionId, `document.querySelector(${JSON.stringify(selector)}) !== null`, 15000); +} + +async function waitFor(sessionId, expression, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await evaluate(sessionId, expression)) return; + await delay(50); + } + throw new Error(`Timed out waiting for: ${expression}`); +} + +async function evaluate(sessionId, expression) { + const response = await cdp.send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true + }, sessionId); + if (response.exceptionDetails) { + throw new Error(response.exceptionDetails.exception?.description || response.exceptionDetails.text); + } + return response.result?.value; +} + +function delay(ms) { + return new Promise(resolveDelay => setTimeout(resolveDelay, ms)); +} + +function positiveInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function positiveNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseArgs(values) { + const parsed = {}; + for (let i = 0; i < values.length; i++) { + const key = values[i].replace(/^--/, ""); + const next = values[i + 1]; + if (!values[i].startsWith("--")) continue; + if (next && !next.startsWith("--")) { + parsed[key] = next; + i++; + } else { + parsed[key] = true; + } + } + return parsed; +}