diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8c692e4af08..6466dce7298 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -155,6 +155,11 @@ jobs: BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} run: | set -euo pipefail + # This job runs inside the pr-ci-container, where GitHub's + # checkout safe.directory entry can be written under a different + # HOME than this shell step uses. Mark the workspace safe before + # diffing the PR so unrelated cn1libs are not rebuilt. + git config --global --add safe.directory "$GITHUB_WORKSPACE" all_libs=( cn1-ai-mlkit-text @@ -283,6 +288,7 @@ jobs: CN1LIB_MODULES: ${{ steps.cn1libs.outputs.modules }} run: | set -euo pipefail + git config --global --add safe.directory "$GITHUB_WORKSPACE" python3 ../scripts/gen-ai-cn1libs.py > /tmp/gen-ai.log # Capture a list of changed files instead of using --exit-code/--quiet, # which interacted poorly with the surrounding `set -e -o pipefail`. diff --git a/.github/workflows/release-on-maven-central.yml b/.github/workflows/release-on-maven-central.yml index 8c29fcf1905..9020193ab0e 100644 --- a/.github/workflows/release-on-maven-central.yml +++ b/.github/workflows/release-on-maven-central.yml @@ -92,8 +92,8 @@ jobs: # --- Game Builder editor ------------------------------------------------- # The cn1:gamebuilder goal resolves - # com.codenameone:codenameone-gamebuilder::jar-with-dependencies - # from Maven Central. That editor lives outside the maven/ reactor + # com.codenameone:codenameone-gamebuilder: and its runtime + # dependencies from Maven Central. That editor lives outside the maven/ reactor # (scripts/gamebuilder), compiles at Java 17, and depends on the # core/javase/plugin published above, so it is built and deployed in a # second pass here. These steps run only when the core release succeeded; @@ -125,7 +125,8 @@ jobs: # -Dmaven.test.skip=true also skips test *compilation* (the editor's # JUnit test deps are irrelevant to the published jar). -Pgamebuilder- # central adds the sources/javadoc jars, GPG signatures and the - # central-publishing bundle; -Pexecutable-jar builds the fat jar. + # central-publishing bundle; -Pexecutable-jar builds the desktop + # launcher jar and copies runtime dependencies for local runs. # -Dcodename1.platform=javase is REQUIRED: it activates the parent's # `javase` profile (which contributes the editor module) by property. # That profile is activeByDefault, but any explicit -P activation @@ -151,14 +152,52 @@ jobs: # Propagation to repo1 can take 30+ minutes, so this is a best-effort # sanity poll, not a gate. set +e - url="https://repo1.maven.org/maven2/com/codenameone/codenameone-gamebuilder/${GITHUB_REF_NAME}/codenameone-gamebuilder-${GITHUB_REF_NAME}-jar-with-dependencies.jar" + url="https://repo1.maven.org/maven2/com/codenameone/codenameone-gamebuilder/${GITHUB_REF_NAME}/codenameone-gamebuilder-${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-gamebuilder ${GITHUB_REF_NAME} (jar-with-dependencies) on Maven Central" + echo "Confirmed codenameone-gamebuilder ${GITHUB_REF_NAME} on Maven Central" exit 0 fi echo "[$i/15] Waiting on Maven Central for codenameone-gamebuilder (code=$code)" sleep 20 done echo "codenameone-gamebuilder ${GITHUB_REF_NAME} not visible on repo1 yet; it may still be propagating from Sonatype Central." + + # --- Signing Wizard editor --------------------------------------------- + # The cn1:certificatewizard goal resolves + # com.codenameone:codenameone-certificatewizard: and its runtime + # dependencies from Maven Central. The editor is a Java-17 Codename One desktop app in + # scripts/certificatewizard and uses the generated OpenAPI client for the + # Apple signing service. + - name: Deploy Signing Wizard editor to Maven Central + if: steps.deploy.outcome == 'success' || steps.confirm.outcome == 'success' + run: | + export GPG_TTY=$(tty) + cd scripts/certificatewizard + xvfb-run -a mvn -Pexecutable-jar -Pcertificatewizard-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 Signing Wizard 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-certificatewizard/${GITHUB_REF_NAME}/codenameone-certificatewizard-${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-certificatewizard ${GITHUB_REF_NAME} on Maven Central" + exit 0 + fi + echo "[$i/15] Waiting on Maven Central for codenameone-certificatewizard (code=$code)" + sleep 20 + done + echo "codenameone-certificatewizard ${GITHUB_REF_NAME} not visible on repo1 yet; it may still be propagating from Sonatype Central." diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index a2f36ab2f1d..9e82f36bbc3 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -7394,6 +7394,41 @@ public void openGallery(final ActionListener response, int type) { } + /// Opens a native file chooser when the platform provides one. The callback + /// source is a `String` path readable by `FileSystemStorage`, or `null` when + /// the user cancels. + /// + /// #### Parameters + /// + /// - `response`: callback receiving the selected file path + /// - `accept`: comma-separated list of accepted file extensions or MIME types + public void openFileChooser(final ActionListener response, String accept) { + final Dialog d = new Dialog("Select a file"); + d.setLayout(new BorderLayout()); + FileTreeModel model = new FileTreeModel(true); + if (accept != null) { + Vector tokens = StringUtil.tokenizeString(accept, ','); + for (int iter = 0; iter < tokens.size(); iter++) { + String token = ((String) tokens.elementAt(iter)).trim(); + int slash = token.indexOf('/'); + if (token.length() > 0 && slash < 0 && !"*".equals(token)) { + if (token.startsWith(".")) { + token = token.substring(1); + } + model.addExtensionFilter(token); + } + } + } + + FileTree t = new OpenGalleryFileTree(model, response, d); + d.addComponent(BorderLayout.CENTER, t); + d.placeButtonCommands(new Command[]{new Command("Cancel")}); + Command c = d.showAtPosition(2, 2, 2, 2, true); + if (c != null) { + response.actionPerformed(null); + } + } + /// Opens the device image gallery /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java b/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java index 44391f03c10..9ef7c721c69 100644 --- a/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java +++ b/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java @@ -1043,7 +1043,7 @@ private Connection createRequest(boolean parseJson) { if (contentType != null) { req.setContentType(contentType); } - req.setFailSilently(false); + req.setFailSilently(hasErrorCodeHandler()); if (cache != null) { req.setCacheMode(cache); } @@ -1093,6 +1093,13 @@ private Connection createRequest(boolean parseJson) { return req; } + private boolean hasErrorCodeHandler() { + return byteArrayErrorCallback != null + || stringErrorCallback != null + || jsonErrorCallback != null + || propertyErrorCallback != null; + } + private static class FetchAsPropertyListActionListener implements ActionListener { private final Connection request; private final String root; diff --git a/CodenameOne/src/com/codename1/ui/CN.java b/CodenameOne/src/com/codename1/ui/CN.java index f36db84cc6f..ecabef3e823 100644 --- a/CodenameOne/src/com/codename1/ui/CN.java +++ b/CodenameOne/src/com/codename1/ui/CN.java @@ -1129,6 +1129,20 @@ public static void openGallery(ActionListener response, int type) { Display.impl.openGallery(response, type); } + /// Opens a file chooser for arbitrary user-selected files. + /// + /// The callback source is a `String` path readable by + /// `FileSystemStorage.openInputStream()`, or `null` if cancelled. `accept` + /// is a comma-separated list of extensions or MIME types. + /// + /// #### Parameters + /// + /// - `response`: callback receiving the selected file path + /// - `accept`: comma-separated extensions or MIME types, or `null` for all files + public static void openFileChooser(ActionListener response, String accept) { + Display.impl.openFileChooser(response, accept); + } + /// Returns a 2-3 letter code representing the platform name for the platform override /// /// #### Returns diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index e0d93d6cf8a..25632b25f96 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -2058,6 +2058,9 @@ public void fireMagnifyGesture(int x, int y, float scale) { } catch (Throwable t) { cmp = null; } + if (cmp == null) { + cmp = f; + } while (cmp != null) { if (cmp.pinch(scale)) { return; @@ -4895,6 +4898,26 @@ public void openGallery(ActionListener response, int type) { impl.openGallery(response, type); } + /// Opens a file chooser for arbitrary user-selected files. + /// + /// The callback source is a `String` path that can be read with + /// `FileSystemStorage.openInputStream()`, or `null` if the user cancelled. + /// The `accept` argument is a comma-separated list of file extensions + /// (`"pdf,txt"`, `"p8"`) or MIME types (`"application/pdf"`). Platforms with + /// native document pickers use them; other ports fall back to a Codename One + /// file tree. + /// + /// Unlike `openGallery()`, this API is not for media-library access and does + /// not add photo/music build hints. + /// + /// #### Parameters + /// + /// - `response`: callback receiving the selected file path + /// - `accept`: comma-separated extensions or MIME types, or `null` for all files + public void openFileChooser(ActionListener response, String accept) { + impl.openFileChooser(response, accept); + } + /// Checks to see if the given gallery type is supported on the current platform. /// /// #### Parameters diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index c4694793ea9..cc448b00f84 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -9618,11 +9618,11 @@ public boolean isGalleryTypeSupported(int type) { - public void openGallery(final ActionListener response, int type){ - if (!isGalleryTypeSupported(type)) { - throw new IllegalArgumentException("Gallery type "+type+" not supported on this platform."); - } - if (getActivity() == null) { + public void openGallery(final ActionListener response, int type){ + if (!isGalleryTypeSupported(type)) { + throw new IllegalArgumentException("Gallery type "+type+" not supported on this platform."); + } + if (getActivity() == null) { throw new RuntimeException("Cannot open galery in background mode"); } if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { @@ -9686,14 +9686,64 @@ public void openGallery(final ActionListener response, int type){ galleryIntent.putExtra(Intent.EXTRA_MIME_TYPES, Display.getInstance().getProperty("android.openGallery.accept", "*/*").split(",")); }else{ galleryIntent.setType("*/*"); - } - this.getActivity().startActivityForResult(galleryIntent, multi ? OPEN_GALLERY_MULTI: OPEN_GALLERY); - } - - class NativeImage extends Image { - - public NativeImage(Bitmap nativeImage) { - super(nativeImage); + } + this.getActivity().startActivityForResult(galleryIntent, multi ? OPEN_GALLERY_MULTI: OPEN_GALLERY); + } + + @Override + public void openFileChooser(final ActionListener response, String accept) { + if (getActivity() == null) { + throw new RuntimeException("Cannot open file chooser in background mode"); + } + if(editInProgress()) { + stopEditing(true); + } + callback = new EventDispatcher(); + callback.addListener(response); + Intent pickerIntent = new Intent(); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + pickerIntent.setAction(Intent.ACTION_OPEN_DOCUMENT); + } else { + pickerIntent.setAction(Intent.ACTION_GET_CONTENT); + } + pickerIntent.addCategory(Intent.CATEGORY_OPENABLE); + pickerIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + pickerIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + } + String[] mimeTypes = getFileChooserMimeTypes(accept); + pickerIntent.setType("*/*"); + if (mimeTypes.length > 0) { + pickerIntent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); + } + this.getActivity().startActivityForResult(pickerIntent, OPEN_GALLERY); + } + + private String[] getFileChooserMimeTypes(String accept) { + if (accept == null || accept.trim().length() == 0 || "*/*".equals(accept.trim())) { + return new String[0]; + } + ArrayList out = new ArrayList(); + String[] tokens = accept.split(","); + for (int iter = 0; iter < tokens.length; iter++) { + String token = tokens[iter].trim(); + if (token.length() == 0 || "*".equals(token)) { + continue; + } + if (token.indexOf('/') > 0) { + out.add(token); + } + } + if (out.isEmpty()) { + out.add("*/*"); + } + return out.toArray(new String[out.size()]); + } + + class NativeImage extends Image { + + public NativeImage(Bitmap nativeImage) { + super(nativeImage); } } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 5b7033dd62b..cc8f8cb4364 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -79,7 +79,9 @@ import java.io.FilenameFilter; import java.lang.ref.SoftReference; import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.net.URI; import java.sql.SQLException; import java.util.HashMap; @@ -2458,6 +2460,11 @@ protected class C extends JPanel implements KeyListener, MouseListener, MouseMot private Graphics2D g2dInstance; private java.awt.Dimension forcedSize; private boolean releaseLock; + private double magnificationAccumulator; + private Set nativeMagnificationTargets = Collections.newSetFromMap(new IdentityHashMap()); + private java.util.List nativeMagnificationListeners = new ArrayList(); + private AWTEventListener magnificationWheelFallbackListener; + private boolean gestureDebug = Boolean.getBoolean("cn1.javase.gestureDebug"); public int x, y; C() { @@ -2470,8 +2477,156 @@ protected class C extends JPanel implements KeyListener, MouseListener, MouseMot addHierarchyBoundsListener(this); setFocusable(true); setOpaque(false); + installNativeMagnificationListeners(); + addHierarchyListener(new HierarchyListener() { + public void hierarchyChanged(HierarchyEvent e) { + long flags = e.getChangeFlags(); + if ((flags & HierarchyEvent.DISPLAYABILITY_CHANGED) != 0 || (flags & HierarchyEvent.PARENT_CHANGED) != 0) { + installNativeMagnificationListeners(); + } + } + }); requestFocus(); } + + private void installNativeMagnificationListeners() { + installMagnificationWheelFallbackListener(); + installNativeMagnificationListener(this); + java.awt.Container parent = getParent(); + while (parent != null) { + if (parent instanceof JComponent) { + installNativeMagnificationListener((JComponent)parent); + } + parent = parent.getParent(); + } + Window window = SwingUtilities.getWindowAncestor(this); + if (window instanceof JFrame) { + JFrame frame = (JFrame)window; + installNativeMagnificationListener(frame.getRootPane()); + installNativeMagnificationListener(frame.getLayeredPane()); + if (frame.getGlassPane() instanceof JComponent) { + installNativeMagnificationListener((JComponent)frame.getGlassPane()); + } + if (frame.getContentPane() instanceof JComponent) { + installNativeMagnificationListener((JComponent)frame.getContentPane()); + } + } + } + + private void installNativeMagnificationListener(JComponent target) { + if (target == null || nativeMagnificationTargets.contains(target)) { + return; + } + try { + Class utilities = Class.forName("com.apple.eawt.event.GestureUtilities"); + Class listenerType = Class.forName("com.apple.eawt.event.MagnificationListener"); + Class gestureListenerType = Class.forName("com.apple.eawt.event.GestureListener"); + Object listener = Proxy.newProxyInstance( + listenerType.getClassLoader() == null ? ClassLoader.getSystemClassLoader() : listenerType.getClassLoader(), + new Class[]{listenerType}, + new InvocationHandler() { + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if ("magnify".equals(method.getName()) && args != null && args.length == 1) { + Method getMagnification = args[0].getClass().getMethod("getMagnification"); + Object value = getMagnification.invoke(args[0]); + if (value instanceof Number) { + debugGesture("native magnification " + value); + handleMagnification(scaleCoordinateX(getWidth() / 2), + scaleCoordinateY(getHeight() / 2), + ((Number)value).doubleValue()); + } + } + return null; + } + }); + Method add = utilities.getMethod("addGestureListenerTo", JComponent.class, gestureListenerType); + add.invoke(null, target, listener); + nativeMagnificationTargets.add(target); + nativeMagnificationListeners.add(listener); + debugGesture("installed native magnification listener on " + target.getClass().getName()); + } catch (Throwable err) { + debugGesture("native magnification listener failed on " + target.getClass().getName() + ": " + err); + } + } + + private void installMagnificationWheelFallbackListener() { + if (magnificationWheelFallbackListener != null) { + return; + } + magnificationWheelFallbackListener = new AWTEventListener() { + public void eventDispatched(AWTEvent event) { + if (!(event instanceof MouseWheelEvent)) { + return; + } + MouseWheelEvent e = (MouseWheelEvent)event; + if (!e.isControlDown() && !e.isMetaDown()) { + return; + } + java.awt.Component source = e.getComponent(); + if (source == null) { + return; + } + Window canvasWindow = SwingUtilities.getWindowAncestor(C.this); + Window sourceWindow = SwingUtilities.getWindowAncestor(source); + if (canvasWindow == null || sourceWindow != canvasWindow) { + return; + } + java.awt.Point p = SwingUtilities.convertPoint(source, e.getPoint(), C.this); + int gestureX = scaleCoordinateX(p.x); + int gestureY = scaleCoordinateY(p.y); + if (e.getPreciseWheelRotation() < 0 || e.getWheelRotation() < 0) { + debugGesture("wheel magnification in " + e.getPreciseWheelRotation()); + fireMagnify(gestureX, gestureY, 1.14f); + e.consume(); + } else if (e.getPreciseWheelRotation() > 0 || e.getWheelRotation() > 0) { + debugGesture("wheel magnification out " + e.getPreciseWheelRotation()); + fireMagnify(gestureX, gestureY, 0.88f); + e.consume(); + } + } + }; + try { + Toolkit.getDefaultToolkit().addAWTEventListener(magnificationWheelFallbackListener, AWTEvent.MOUSE_WHEEL_EVENT_MASK); + } catch (Throwable ignored) { + magnificationWheelFallbackListener = null; + } + } + + private void debugGesture(String message) { + if (gestureDebug) { + System.out.println("[JavaSEPort gesture] " + message); + } + } + + private void disposeGestureListeners() { + if (magnificationWheelFallbackListener != null) { + try { + Toolkit.getDefaultToolkit().removeAWTEventListener(magnificationWheelFallbackListener); + } catch (Throwable ignored) { + } + magnificationWheelFallbackListener = null; + } + } + + private void handleMagnification(final int x, final int y, double magnification) { + magnificationAccumulator += magnification; + while (magnificationAccumulator >= 0.08d) { + fireMagnify(x, y, 1.14f); + magnificationAccumulator -= 0.08d; + } + while (magnificationAccumulator <= -0.08d) { + fireMagnify(x, y, 0.88f); + magnificationAccumulator += 0.08d; + } + } + + private void fireMagnify(final int x, final int y, final float scale) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + Display.getInstance().fireMagnifyGesture(x, y, scale); + } + }); + } public void setForcedSize(Dimension d) { forcedSize = d; @@ -3535,6 +3690,14 @@ public void mouseWheelMoved(final MouseWheelEvent e) { lastInputEvent = e; final int x = isScrollWheeling() ? lastX : scaleCoordinateX(e.getX()); final int y = isScrollWheeling() ? lastY : scaleCoordinateY(e.getY()); + if (e.isControlDown() || e.isMetaDown()) { + if (e.getPreciseWheelRotation() < 0 || e.getWheelRotation() < 0) { + fireMagnify(x, y, 1.14f); + } else if (e.getPreciseWheelRotation() > 0 || e.getWheelRotation() > 0) { + fireMagnify(x, y, 0.88f); + } + return; + } if (e.getScrollType() == MouseWheelEvent.WHEEL_UNIT_SCROLL) { Form f = getCurrentForm(); if(f != null){ @@ -3607,6 +3770,9 @@ public void paintDirty() { * @inheritDoc */ public void deinitialize() { + if (canvas != null) { + canvas.disposeGestureListeners(); + } if (canvas.getParent() != null) { canvas.getParent().remove(canvas); } @@ -14642,6 +14808,75 @@ public void run() { }); } + @Override + public void openFileChooser(final com.codename1.ui.events.ActionListener response, final String accept) { + EventQueue.invokeLater(new Runnable() { + public void run() { + java.awt.Frame parent = null; + java.awt.Frame[] frames = java.awt.Frame.getFrames(); + for (int iter = 0; iter < frames.length; iter++) { + if (frames[iter].isShowing()) { + parent = frames[iter]; + break; + } + } + FileDialog picker = new FileDialog(parent, "Select File", FileDialog.LOAD); + final String[] filters = parseFileChooserAccept(accept); + if (filters.length > 0) { + picker.setFilenameFilter(new FilenameFilter() { + public boolean accept(File dir, String name) { + if (name == null) { + return false; + } + String lower = name.toLowerCase(); + for (int iter = 0; iter < filters.length; iter++) { + if (lower.endsWith("." + filters[iter])) { + return true; + } + } + return false; + } + }); + } + picker.setVisible(true); + final File selected; + if (picker.getFile() == null) { + selected = null; + } else { + selected = new File(picker.getDirectory(), picker.getFile()); + } + Display.getInstance().callSerially(new Runnable() { + public void run() { + if (selected == null) { + response.actionPerformed(null); + } else { + response.actionPerformed(new com.codename1.ui.events.ActionEvent("file://" + selected.getAbsolutePath().replace('\\', '/'))); + } + } + }); + } + }); + } + + private String[] parseFileChooserAccept(String accept) { + if (accept == null || accept.trim().length() == 0 || "*/*".equals(accept.trim())) { + return new String[0]; + } + java.util.ArrayList out = new java.util.ArrayList(); + String[] tokens = accept.split(","); + for (int iter = 0; iter < tokens.length; iter++) { + String token = tokens[iter].trim().toLowerCase(); + if (token.length() == 0 || token.indexOf('/') >= 0 || "*".equals(token)) { + continue; + } + if (token.startsWith(".")) { + token = token.substring(1); + } + out.add(token); + } + return out.toArray(new String[out.size()]); + } + @Override public void captureAudio(com.codename1.ui.events.ActionListener response) { if(!checkForPermission("android.permission.RECORD_AUDIO", "This is required to record the audio")){ diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index 5d9d13dd1c9..f371b452081 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -291,6 +291,9 @@ CLLocationManagerDelegate, AVAudioRecorderDelegate #if !TARGET_OS_TV , UIPickerViewDelegate, UIDocumentInteractionControllerDelegate #endif +#if !TARGET_OS_WATCH && !TARGET_OS_TV +, UIDocumentPickerDelegate +#endif #ifdef INCLUDE_ZOOZ ,ZooZPaymentCallbackDelegate #endif diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 2d3af9a85ac..baa703c2769 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -5094,6 +5094,68 @@ - (void)picker:(PHPickerViewController *)picker didFinishPicking:(NSArray *)urls { + NSString *path = nil; + if ([urls count] > 0) { + path = cn1CopyPickedDocumentToTemp([urls objectAtIndex:0]); + } + com_codename1_impl_ios_IOSImplementation_fileChooserResult___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG path == nil ? nil : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG path)); + if (controller.presentingViewController != nil) { + [controller dismissViewControllerAnimated:YES completion:nil]; + } +} + +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url { + NSString *path = cn1CopyPickedDocumentToTemp(url); + com_codename1_impl_ios_IOSImplementation_fileChooserResult___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG path == nil ? nil : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG path)); + if (controller.presentingViewController != nil) { + [controller dismissViewControllerAnimated:YES completion:nil]; + } +} + +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller { + com_codename1_impl_ios_IOSImplementation_fileChooserResult___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG nil); + if (controller.presentingViewController != nil) { + [controller dismissViewControllerAnimated:YES completion:nil]; + } +} +#endif + #if !TARGET_OS_TV -(void)imagePickerControllerDidCancel:(UIImagePickerController *)picker { //[self dismissModalViewControllerAnimated:YES]; diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 3cb92f233fb..d8a9f8dcc49 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -5925,6 +5925,76 @@ void com_codename1_impl_ios_IOSNative_captureCamera___boolean_int_int(CN1_THREAD #endif } +#if !TARGET_OS_WATCH && !TARGET_OS_TV +static void cn1AddFileChooserDocumentType(NSMutableArray *types, CFStringRef tagClass, NSString *tag) { + if (tag == nil || [tag length] == 0) { + return; + } + CFStringRef uti = UTTypeCreatePreferredIdentifierForTag(tagClass, (CFStringRef)tag, NULL); + if (uti != NULL) { +#if __has_feature(objc_arc) + NSString *type = CFBridgingRelease(uti); +#else + NSString *type = [(NSString *)uti autorelease]; +#endif + if (![types containsObject:type]) { + [types addObject:type]; + } + } +} + +static NSArray *cn1FileChooserDocumentTypes(NSString *accept) { + NSMutableArray *types = [NSMutableArray array]; + if (accept != nil) { + NSArray *tokens = [accept componentsSeparatedByString:@","]; + for (NSString *rawToken in tokens) { + NSString *token = [rawToken stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if ([token length] == 0 || [token isEqualToString:@"*"] || [token isEqualToString:@"*/*"]) { + continue; + } + if ([token rangeOfString:@"/"].location != NSNotFound) { + cn1AddFileChooserDocumentType(types, kUTTagClassMIMEType, token); + } else { + if ([token hasPrefix:@"."]) { + token = [token substringFromIndex:1]; + } + cn1AddFileChooserDocumentType(types, kUTTagClassFilenameExtension, [token lowercaseString]); + } + } + } + if ([types count] == 0) { + [types addObject:(NSString *)kUTTypeItem]; + } + return types; +} +#endif + +void com_codename1_impl_ios_IOSNative_openFileChooser___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT accept) { +#if !TARGET_OS_WATCH && !TARGET_OS_TV + NSString *nsAccept = accept == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG accept); + NSArray *documentTypes = cn1FileChooserDocumentTypes(nsAccept); + dispatch_async(dispatch_get_main_queue(), ^{ + POOL_BEGIN(); +#ifndef CN1_USE_ARC + UIDocumentPickerViewController *pickerController = [[[UIDocumentPickerViewController alloc] initWithDocumentTypes:documentTypes inMode:UIDocumentPickerModeImport] autorelease]; +#else + UIDocumentPickerViewController *pickerController = [[UIDocumentPickerViewController alloc] initWithDocumentTypes:documentTypes inMode:UIDocumentPickerModeImport]; +#endif + pickerController.delegate = [CodenameOne_GLViewController instance]; + if (@available(iOS 11.0, *)) { + pickerController.allowsMultipleSelection = NO; + } + if (popoverSupported()) { + pickerController.modalPresentationStyle = UIModalPresentationFormSheet; + } + [[CodenameOne_GLViewController instance] presentViewController:pickerController animated:YES completion:nil]; + POOL_END(); + }); +#else + com_codename1_impl_ios_IOSImplementation_fileChooserResult___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG nil); +#endif +} + #ifdef INCLUDE_PHOTOLIBRARY_USAGE #ifdef ENABLE_GALLERY_MULTISELECT diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 0b903b45139..c03844442fc 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -3929,6 +3929,25 @@ public static void capturePictureResult(String r) { captureCallback = null; } } + + /** + * Callback for the native document picker. + */ + public static void fileChooserResult(String r) { + dropEvents = false; + if(fileChooserCallback != null) { + if(r != null) { + if(r.startsWith("file:")) { + fileChooserCallback.fireActionEvent(new ActionEvent(r)); + } else { + fileChooserCallback.fireActionEvent(new ActionEvent("file:" + r)); + } + } else { + fileChooserCallback.fireActionEvent(new ActionEvent(null)); + } + fileChooserCallback = null; + } + } public void captureAudio(ActionListener response) { @@ -3971,6 +3990,7 @@ public static void captureMovieResult(String r) { } private static EventDispatcher captureCallback; + private static EventDispatcher fileChooserCallback; /** * Captures a photo and notifies with the image data when available @@ -4358,6 +4378,14 @@ public void openGallery(ActionListener response, int type) { captureCallback.addListener(response); nativeInstance.openGallery(type); } + + @Override + public void openFileChooser(ActionListener response, String accept) { + fileChooserCallback = new EventDispatcher(); + fileChooserCallback.addListener(response); + nativeInstance.openFileChooser(accept); + dropEvents = true; + } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index a4d0683d927..b3b6722110f 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -477,6 +477,7 @@ native void fillGradient(int kind, int stopCount, float[] positions, float[] pre // capture native void captureCamera(boolean movie, int quality, int duration); native void openGallery(int type); + native void openFileChooser(String accept); // Low-level camera API (com.codename1.camera). Backed by CN1Camera.m // which wraps AVCaptureSession. The IOSCameraImpl class on the Java side diff --git a/docs/developer-guide/Index.asciidoc b/docs/developer-guide/Index.asciidoc index 352c60ee5fc..d25912367d4 100644 --- a/docs/developer-guide/Index.asciidoc +++ b/docs/developer-guide/Index.asciidoc @@ -520,20 +520,26 @@ You need 4 files for signing. Two certificates and two provisioning profiles: . *Development:* The development certificate/provisioning is used to install on your development devices -The certificate wizard automatically creates these 4 files and configures them for you. +The standalone Certificate Wizard can automatically create these files and configure the current Maven project. -.Using the iOS Certificate Wizard Steps 1 and 2 -image::img/ios-certificate-wizard-1.png[Using the iOS Certificate Wizard Steps 1 and 2] +Run the wizard from the project directory: -.Using the iOS Certificate Wizard Steps 3 and 4 -image::img/ios-certificate-wizard-2.png[Using the iOS Certificate Wizard Steps 3 and 4] +[source,bash] +---- +mvn cn1:certificatewizard +---- + +After storing an App Store Connect API key, click *Auto Setup* in the toolbar. The wizard reads the project package and display name, creates or reuses the Bundle ID with push enabled, creates/reuses development and App Store signing assets, and writes the debug/release signing settings back into `codenameone_settings.properties`. + +.Certificate wizard overview with mocked signing assets +image::img/certificate-wizard-overview-mock.svg[Certificate wizard overview with mocked signing assets,scaledwidth=70%] -.Using the iOS Certificate Wizard Steps 5 and 6 -image::img/ios-certificate-wizard-3.png[Using the iOS Certificate Wizard Steps 5 and 6] +.Certificate wizard profiles screen with mocked values +image::img/certificate-wizard-profiles-mock.svg[Certificate wizard profiles screen with mocked values,scaledwidth=70%] [TIP] ==== -If you have more than one project you should use the same iOS P12 certificate files in all the projects and regenerate the provisioning. In this situation the certificate wizard asks you if you want to revoke the existing certificate which you shouldn't revoke in such a case. You can update the provisioning profile in Apple's iOS developer website. +If you have more than one project you should use the same iOS P12 certificate files in all the projects and regenerate the provisioning. In this situation the Certificate Wizard asks you if you want to revoke the existing certificate which you shouldn't revoke in such a case. You can update the provisioning profile in Apple's iOS developer website. ==== One important aspect of provisioning on iOS is the device list in the provisioning step. Apple allows you to install the app on 100 devices during development. This blocks developers from skipping the App Store altogether. It's important you list the correct UDID for the device in the list otherwise install will fail. diff --git a/docs/developer-guide/img/certificate-wizard-api-key-mock.svg b/docs/developer-guide/img/certificate-wizard-api-key-mock.svg new file mode 100644 index 00000000000..efb1d6abf18 --- /dev/null +++ b/docs/developer-guide/img/certificate-wizard-api-key-mock.svg @@ -0,0 +1,36 @@ + + + + Codename One + Signing + + ASC API Key missing + + D + + + + + ASC API Key + ASC API Key + The .p8 key is stored by the cloud service and is never returned by the API. + + Create an App Store Connect API key + 1. Open Users and Access > Integrations > App Store Connect API. + 2. If prompted, request API access and accept Apple's terms. + 3. On Team Keys, generate a key with Admin access. + 4. Copy the Issuer ID and Key ID, then download the .p8 file. + + Create .p8 key in App Store Connect + Key ID + + ABCD1234EF + Issuer ID + + 11111111-2222-3333-4444-555555555555 + Private key .p8 + + Import .p8 file + + Store key + diff --git a/docs/developer-guide/img/certificate-wizard-overview-mock.svg b/docs/developer-guide/img/certificate-wizard-overview-mock.svg new file mode 100644 index 00000000000..85f9a85b734 --- /dev/null +++ b/docs/developer-guide/img/certificate-wizard-overview-mock.svg @@ -0,0 +1,63 @@ + + + + Codename One + Signing + + ASC API Key configured + + + Auto Setup + + WORKSPACE + + Overview + ASC API Key + SIGNING ASSETS + Certificates 2 + Bundle IDs 1 + Devices 2 + Profiles 2 + Overview + Everything you need to sign and ship an iOS build, managed through the App Store Connect API. + SETUP STATUS + + + ASC API Key + Connected + ABCD1234EF + + + + Distribution certificate + Ready + App Store Distribution + + + + App Store profile + Ready + My App App Store + + YOUR ASSETS + + + 2 + Certificates + + + + 1 + Bundle IDs + + + + 2 + Devices + + + + 2 + Profiles + + diff --git a/docs/developer-guide/img/certificate-wizard-profiles-mock.svg b/docs/developer-guide/img/certificate-wizard-profiles-mock.svg new file mode 100644 index 00000000000..df3f9be9b3c --- /dev/null +++ b/docs/developer-guide/img/certificate-wizard-profiles-mock.svg @@ -0,0 +1,49 @@ + + + + Codename One + Signing + + ASC API Key configured + + + Auto Setup + + SIGNING ASSETS + Certificates 2 + Bundle IDs 1 + Devices 2 + + Profiles 2 + Provisioning Profiles + Create and install fresh .mobileprovision files. + + + Filter + Profile + Type + Bundle ID + Expires + + My App App Store + PRF_STORE + + STORE + com.example.myapp + 312 days + + Install + + Delete + + My App Development + PRF_DEV + + DEVELOPMENT + com.example.myapp + 27 days + + Install + + Delete + diff --git a/docs/developer-guide/img/ios-cert-wizard-1-signing.png b/docs/developer-guide/img/ios-cert-wizard-1-signing.png deleted file mode 100644 index 154d5e4e4d3..00000000000 Binary files a/docs/developer-guide/img/ios-cert-wizard-1-signing.png and /dev/null differ diff --git a/docs/developer-guide/img/ios-cert-wizard-2-login.png b/docs/developer-guide/img/ios-cert-wizard-2-login.png deleted file mode 100644 index 55536c22bf0..00000000000 Binary files a/docs/developer-guide/img/ios-cert-wizard-2-login.png and /dev/null differ diff --git a/docs/developer-guide/img/ios-certificate-wizard-1.png b/docs/developer-guide/img/ios-certificate-wizard-1.png deleted file mode 100644 index 5d9bb6afd27..00000000000 Binary files a/docs/developer-guide/img/ios-certificate-wizard-1.png and /dev/null differ diff --git a/docs/developer-guide/img/ios-certificate-wizard-2.png b/docs/developer-guide/img/ios-certificate-wizard-2.png deleted file mode 100644 index 3ea8e13e6f5..00000000000 Binary files a/docs/developer-guide/img/ios-certificate-wizard-2.png and /dev/null differ diff --git a/docs/developer-guide/img/ios-certificate-wizard-3.png b/docs/developer-guide/img/ios-certificate-wizard-3.png deleted file mode 100644 index 9648866593a..00000000000 Binary files a/docs/developer-guide/img/ios-certificate-wizard-3.png and /dev/null differ diff --git a/docs/developer-guide/io.asciidoc b/docs/developer-guide/io.asciidoc index 492f33de1ab..f2b2a0709e4 100644 --- a/docs/developer-guide/io.asciidoc +++ b/docs/developer-guide/io.asciidoc @@ -165,6 +165,32 @@ You can use this directory to place an image to share as you did in the < { + if(evt == null || evt.getSource() == null) { + return; + } + String path = (String)evt.getSource(); + try(InputStream in = FileSystemStorage.getInstance().openInputStream(path)) { + String text = Util.readToString(in, "UTF-8"); + // import the selected file + } catch(IOException err) { + Log.e(err); + } +}, "pdf,txt"); +---- + +The second argument is a comma-separated accept list. It can contain filename extensions such as `pdf,txt,p8`, MIME types such as `application/pdf`, or `null`/`*/*` for any file. JavaSE uses the native AWT file dialog. Android uses the system document picker and copies content URIs into an app-readable file path when needed. Ports without a native document picker fall back to a Codename One file tree. + +No photo-library or media permissions are added by this API. On Android the system document picker grants read access to the selected document. On iOS this API should be implemented with a document picker, not the photo library; `ios.NSPhotoLibraryUsageDescription` is only needed for `openGallery()`. + A more advanced usage of the `FileSystemStorage` API can be a `FileSystemStorage` `Tree`: [source,java] diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index f060459bb4d..db1a76b6727 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -120,6 +120,7 @@ appcompat Apache apache ATS +Authenticode BLE CommonCrypto CodeMirror @@ -197,6 +198,7 @@ SQLDemo SQLMap Smalltalk snapchat +Sectigo Storyboard storyboards Temurin diff --git a/docs/developer-guide/signing.asciidoc b/docs/developer-guide/signing.asciidoc index 1b8646182bb..e07da2fb879 100644 --- a/docs/developer-guide/signing.asciidoc +++ b/docs/developer-guide/signing.asciidoc @@ -44,28 +44,114 @@ You would recommend it for all platforms for simplicity but some developers pref [[certificate-wizard]] -=== iOS signing wizard +=== Certificate Wizard -Codename One features a wizard to generate certificates/provisioning for iOS without requiring a Mac or deep understanding of the signing process for iOS. Support still exists for manually generating the P12/provisioning files when necessary but for most intents and purposes using the wizard will simplify this error prone process. +Codename One features a standalone Certificate Wizard to manage signing assets for Apple, Android and desktop targets. It replaces the old certificate wizard inside `CodenameOneSettings` and is launched as a Maven tool bound to the current project. -To generate your certificates and profiles, open project's properties and click on "iOS" in the left menu. This will show the "iOS Signing" panel that includes fields to select your certificates and mobile provisioning profiles. +Run the wizard from a Codename One Maven project: -.Netbeans iOS Signing properties panel -image::img/ios-cert-wizard-1-signing.png[Netbeans iOS Signing properties panel,scaledwidth=50%] +[source,bash] +---- +mvn cn1:certificatewizard +---- + +The Maven goal resolves the wizard artifact and its runtime dependencies from Maven, then launches it with the current project's settings path. The wizard uses the Codename One cloud signing API for Apple assets and local `keytool` for Android keystores. On a first run the overview shows that the App Store Connect API key isn't configured and links to the API key page. This is an Apple credential that allows the cloud service to create and manage certificates, bundle IDs, devices, provisioning profiles, APNs keys and Mac signing assets for your Apple Developer Program team. + +==== Creating the App Store Connect API key + +Open https://appstoreconnect.apple.com/access/integrations/api[App Store Connect > Users and Access > Integrations > App Store Connect API]. If this is the first time your team uses the API, App Store Connect asks you to request API access first. Click *Request Access*, accept the terms, and submit the request. + +Once access is enabled, go to *Team Keys* and click *Generate API Key*. Give the key a recognizable name, such as `Codename One Signing`, and choose *Admin* access. + +IMPORTANT: Use *Admin* access for the Certificate Wizard. The wizard needs to create and list signing certificates, bundle IDs, devices, and provisioning profiles. App Manager or Developer access may not be sufficient for these operations. + +After creating the key, App Store Connect shows the *Issuer ID* above the table and the *Key ID* in the key row. Copy both values into the wizard. Download the API key file. Apple only allows the `.p8` file to be downloaded once, so store a backup in a secure location. + +In the Codename One Certificate Wizard, fill these fields: + +* *Key ID*: The value shown in the generated key row in App Store Connect. This isn't your Apple ID. +* *Issuer ID*: The UUID shown above the Team Keys table on the App Store Connect API page. +* *Private key .p8*: Click *Import .p8 file* to select the downloaded file with the native file chooser, or paste the full file contents, including the `BEGIN PRIVATE KEY` and `END PRIVATE KEY` lines. + +The `.p8` private key is sent to the Codename One cloud signing service and isn't returned by the API later. If you delete the key from App Store Connect or lose the local `.p8` backup, generate and store a new API key. + +When you download a generated `.p12` certificate from the wizard, the *P12 password* field is a new password that you choose for that downloaded file. It's not the App Store Connect Issuer ID, and it doesn't come from Apple. + +==== Recommended fast path: Auto Setup + +After the API key is stored, the top toolbar shows *Auto Setup* next to the API key status. This is the recommended path for a normal Codename One project. It creates the common Apple signing assets for the current project and installs the downloaded files into `codenameone_settings.properties`. + +.Certificate wizard overview with mocked signing assets +image::img/certificate-wizard-overview-mock.svg[Certificate wizard overview with mocked signing assets,scaledwidth=80%] + +Click *Auto Setup*. The wizard reads the current project's `codenameone_settings.properties`, uses `codename1.packageName` as the bundle ID, uses `codename1.displayName` or `codename1.mainName` as the app name, enables push notifications for the bundle ID, and then creates or reuses the signing assets it needs. + +The fast path creates the common Apple signing setup: + +* A Bundle ID for the current project, if it doesn't already exist. +* An iOS Development certificate, if no compatible active certificate exists. +* An iOS Development provisioning profile, if at least one development device is registered. +* An iOS Distribution certificate, if no compatible active certificate exists. +* An App Store provisioning profile. +* Debug signing settings for the development certificate/profile pair. +* Release signing settings for the App Store certificate/profile pair. +* Mac App Store or Developer ID assets when you use the Mac Signing page. -If you already have valid certificates and profiles, you can enter their locations here. If you don't, then you can use the wizard by clicking the #Generate# button in the lower part of the form. +Development provisioning requires at least one registered device. If no devices are registered, the wizard defers development profile creation and continues with the App Store signing assets. Register the device in the *Devices* section and run *Auto Setup* again to fill in the debug signing settings. -==== Logging into the wizard +.Certificate wizard API key screen with mocked values +image::img/certificate-wizard-api-key-mock.svg[Certificate wizard API key screen with mocked values,scaledwidth=80%] -After clicking #Generate# you'll be shown a login form. Log into this form using the *App Store Connect* Apple ID that's registered on your Apple Developer Program team. **NOT YOUR CODENAME ONE LOGIN**. +.Certificate wizard profiles screen with mocked values +image::img/certificate-wizard-profiles-mock.svg[Certificate wizard profiles screen with mocked values,scaledwidth=80%] -.Wizard login form -image::img/ios-cert-wizard-2-login.png[Wizard login form,scaledwidth=20%] +The wizard writes downloaded Apple `.p12`, `.mobileprovision` and Mac `.provisionprofile` files into the current project signing configuration. For a development iOS profile it updates the iOS debug settings. For an App Store iOS profile it updates the iOS release settings. For Mac profiles it updates `codename1.mac.certificate`, `codename1.mac.certificatePassword`, `codename1.mac.provision` and the relevant `macNative.*` hints. + +==== Mac signing and notarization + +The *Mac* section uses the same App Store Connect API key and the unified Apple signing API. It supports Mac App Store certificates/profiles and Developer ID certificates for direct distribution. Developer ID builds can use notarization credentials derived from the stored App Store Connect key when the build server performs notarization. + +Mac native signing uses the project settings documented in the Mac native chapter: + +* `codename1.mac.certificate`: The generated or imported Mac `.p12`. +* `codename1.mac.certificatePassword`: The password for that `.p12`. +* `codename1.mac.provision`: The Mac `.provisionprofile`. +* `codename1.arg.macNative.enabled=true`: Enables the Mac native signing path. +* `codename1.arg.macNative.distribution=appStore` or `developerID`: Chooses the signing channel. + +==== Android signing + +The *Android* section generates a local self-signed Android keystore using the JDK `keytool` executable. It stores the file under `androidCerts/KeyChain.ks` in the project by default and writes: + +* `codename1.android.keystore` +* `codename1.android.keystoreAlias` +* `codename1.android.keystorePassword` + +WARNING: Back up the generated keystore and password. Android apps already published with one certificate can't be updated with a different certificate. + +==== Windows signing + +The *Windows* section doesn't issue certificates. Windows Authenticode certificates must come from a code-signing certificate authority or a managed signing service such as Azure Trusted Signing, DigiCert KeyLocker, GlobalSign GCC, Sectigo, or another OV/EV provider. Once you have a `.pfx` or `.p12`, configure: + +* `codename1.windows.signing.certificate` +* `codename1.windows.signing.password` +* `codename1.windows.signing.timestamp` + +==== Clearing cached signing data + +The *Clear Signing Data* section is a privacy tool, not a recommended part of normal signing setup or troubleshooting. Use it only when you need to delete cached signing data owned by the current Codename One account from the cloud signing service. + +It clears stored Apple API credentials, generated certificate private keys, provisioning profile metadata, APNs keys and notarization data. It doesn't delete assets from Apple, but future cloud builds will need signing data to be regenerated or re-imported. + +==== Manual asset management + +Use the individual wizard sections when you need to inspect assets, register a device, sync certificates with Apple, create an ad-hoc or Mac profile, install a specific certificate/profile pair, generate an Android keystore, or delete/revoke an obsolete asset. + +The asset tables are live-filterable and sortable. Click a row to show the values returned by the signing API. Existing Apple assets can't currently be edited through this API; for changes, create a new asset or delete/revoke the old one where Apple permits it. Destructive actions such as deleting a profile, deleting an APNs key, deleting the stored API key, or revoking a certificate prompt for confirmation. ==== Selecting devices -Once you're logged in you will be shown a list of all the devices that you have registered on your -Apple developer account. +Development and ad-hoc provisioning profiles can only include devices that are registered on your Apple Developer account. In the standalone wizard, open the *Devices* section to register a device by name and UDID before creating a development or ad-hoc provisioning profile. .Devices form image::img/ios-cert-wizard-3-devices.png[Devices form,scaledwidth=20%] @@ -127,8 +213,8 @@ Wildcard ids such as com.mycompany.\* or even \* allow you to create one generic 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] +.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] **** ==== Installing files locally @@ -157,7 +243,7 @@ image::img/ios-cert-wizard-9-signing-panel.png[Filled in signing panel after wiz === Advanced iOS signing -WARNING: You should use the certificate wizard, if you don't have a Mac. This section is here for reference and edge cases that don't work with the certificate wizard +WARNING: You should use the Certificate Wizard, if you don't have a Mac. This section is here for reference and edge cases that don't work with the Certificate Wizard iOS signing has two distinct modes: App Store signing which is valid for distribution via App Store Connect (you won't be able to run the resulting application without submitting it to Apple) and development mode signing. @@ -253,7 +339,7 @@ image::img/ios-project-settings.png[IOS Project Settings,scaledwidth=30%] ==== Configuring `codenameone_settings.properties` for manual signing -The Codename One build servers read signing assets from `codenameone_settings.properties`. When you bypass the signing wizard, populate the following keys manually so the packaging process can locate your certificates, provisioning profiles, and keystores: +The Codename One build servers read signing assets from `codenameone_settings.properties`. When you bypass the Certificate Wizard, populate the following keys manually so the packaging process can locate your certificates, provisioning profiles, and keystores: *iOS* @@ -275,9 +361,9 @@ If the keystore fields are empty the Maven and Ant builders will create a defaul Below is a list of common issues when singing and a set of suggestions for things to check. Notice that some of these signing failures will sometimes manifest themselves during build and sometimes will manifest during the installation of the application. -TIP: Most of these issues aren't applicable when using the wizard for example, a Mac isn't required for the certificate wizard as it uses the Codename One cloud +TIP: Most of these issues aren't applicable when using the wizard for example, a Mac isn't required for the Certificate Wizard as it uses the Codename One cloud -- *You must use a Mac to generate P12 certificates manually*. The workaround you found is the certificate wizard! +- *You must use a Mac to generate P12 certificates manually*. The workaround you found is the Certificate Wizard! + Notice that this is something you need to do once a year (generate P12), you will also need a Mac to upload your final app to the store now. @@ -351,4 +437,3 @@ 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. For more details see http://developer.android.com/guide/publishing/app-signing.html - diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/runConfigurations/CN1_Certificate_Wizard.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/runConfigurations/CN1_Certificate_Wizard.xml new file mode 100644 index 00000000000..07a832f992d --- /dev/null +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/runConfigurations/CN1_Certificate_Wizard.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/.vscode/settings.json b/maven/cn1app-archetype/src/main/resources/archetype-resources/.vscode/settings.json index eb543a391d8..b34c7c3bf04 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/.vscode/settings.json +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/.vscode/settings.json @@ -7,6 +7,10 @@ "alias": "Tools > Codename One Settings", "command": "\"cn1:settings\" \"-U\" \"-e\"" }, + { + "alias": "Tools > Certificate Wizard", + "command": "\"cn1:certificatewizard\" \"-U\" \"-e\"" + }, { "alias": "Run in Simulator", "command": "\"verify\" \"-Psimulator\" \"-DskipTests\" \"-Dcodename1.platform=javase\" \"-e\"" @@ -63,4 +67,4 @@ "alias": "Local > Cross-platform (JavaSE) Desktop App", "command": "\"-Pexecutable-jar\" \"package\" \"-Dcodename1.platform=javase\" \"-DskipTests\" \"-U\" \"-e\"" } -]} \ No newline at end of file +]} diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/README.adoc b/maven/cn1app-archetype/src/main/resources/archetype-resources/README.adoc index 3c48268908f..65e597526a1 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/README.adoc +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/README.adoc @@ -38,6 +38,16 @@ This project is a multi-module Maven project that was generated from a Maven arc The project should work in IntelliJ out of the box. No need to copy any files. +== Signing + +Use the Certificate Wizard to configure Apple signing assets, Android keystores, and desktop signing settings: + +---- +mvn cn1:certificatewizard +---- + +Generated IDE projects include a Certificate Wizard action under their tools/favorites area. + == Help and Support -See the https://www.codenameone.com[Codename One Web Site]. \ No newline at end of file +See the https://www.codenameone.com[Codename One Web Site]. diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/mvnconfig.toml b/maven/cn1app-archetype/src/main/resources/archetype-resources/mvnconfig.toml index ead3459c34c..1b5dc66dc0b 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/mvnconfig.toml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/mvnconfig.toml @@ -4,7 +4,7 @@ label="Run in Simulator" run=true [desktop] -command=["$MVNW", "verify", "-Prun-desktop", "-DskipTests", "-Dcodename1.platform=javase" "-e"] +command=["$MVNW", "verify", "-Prun-desktop", "-DskipTests", "-Dcodename1.platform=javase", "-e"] label="Run as Desktop App" run=true @@ -76,6 +76,11 @@ command=["$MVNW", "cn1:settings", "-U", "-e"] label="Codename One Settings" group="Tools" +[certificate_wizard] +command=["$MVNW", "cn1:certificatewizard", "-U", "-e"] +label="Certificate Wizard" +group="Tools" + [update] command=["$MVNW", "cn1:update", "-U", "-e"] label="Update Codename One" diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/run.bat b/maven/cn1app-archetype/src/main/resources/archetype-resources/run.bat index f8b979c32a7..5c3957af4e2 100755 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/run.bat +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/run.bat @@ -22,6 +22,10 @@ goto :EOF :settings !MVNW! cn1:settings -e +goto :EOF +:certificatewizard +!MVNW! cn1:certificatewizard -e + goto :EOF :update !MVNW! cn1:update -U -e @@ -36,5 +40,7 @@ echo desktop echo Runs app as a desktop app. echo settings echo Opens Codename One settings +echo certificatewizard +echo Opens the Certificate Wizard echo update echo Update Codename One libraries diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/run.sh b/maven/cn1app-archetype/src/main/resources/archetype-resources/run.sh index ef0a12cbe5f..c707349e115 100755 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/run.sh +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/run.sh @@ -13,6 +13,10 @@ function settings { "$MVNW" "cn1:settings" "-e" } +function certificatewizard { + + "$MVNW" "cn1:certificatewizard" "-e" +} function update { "$MVNW" "cn1:update" "-U" "-e" @@ -26,6 +30,8 @@ function help { "echo" "-e" " Runs app as a desktop app." "echo" "-e" " settings" "echo" "-e" " Opens Codename One settings" + "echo" "-e" " certificatewizard" + "echo" "-e" " Opens the Certificate Wizard" "echo" "-e" " update" "echo" "-e" " Update Codename One libraries" } @@ -34,4 +40,4 @@ CMD=$1 if [ "$CMD" == "" ]; then CMD="simulator" fi -"$CMD" \ No newline at end of file +"$CMD" diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/tools/eclipse/__mainName__ - Certificate Wizard.launch b/maven/cn1app-archetype/src/main/resources/archetype-resources/tools/eclipse/__mainName__ - Certificate Wizard.launch new file mode 100644 index 00000000000..5140bc8fcf2 --- /dev/null +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/tools/eclipse/__mainName__ - Certificate Wizard.launch @@ -0,0 +1,27 @@ +#set ( $d = "$") + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/tools/netbeans/nbactions.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/tools/netbeans/nbactions.xml index eab8d61a956..8019bc58856 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/tools/netbeans/nbactions.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/tools/netbeans/nbactions.xml @@ -42,6 +42,13 @@ cn1:settings + + CUSTOM-Open Certificate Wizard + Open Certificate Wizard + + cn1:certificatewizard + + build diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java index 22b9a1b5620..152cb5d97fa 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java @@ -528,9 +528,15 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) .append(" bs['SWIFT_OBJC_BRIDGING_HEADER'] = '") .append(IPhoneBuilder.escapeRubyStr(mainClass + "-src/" + mainClass + "-Watch-Bridging-Header.h")).append("'\n") // Resolve and to the watchOS stub - // headers (writeStubHeaders) since neither framework exists on - // watchOS; the stubs supply the GL types the shared decls use. - .append(" bs['HEADER_SEARCH_PATHS'] = '$(inherited) $(SRCROOT)/") + // headers (writeStubHeaders) only when Xcode is actually + // compiling the watch target for a watch SDK. If an old or + // implicit app dependency makes Xcode visit this target during + // an iOS Simulator build, these stubs must not shadow Apple's + // real OpenGLES headers. + .append(" bs.delete('HEADER_SEARCH_PATHS')\n") + .append(" bs['HEADER_SEARCH_PATHS[sdk=watchos*]'] = '$(inherited) $(SRCROOT)/") + .append(IPhoneBuilder.escapeRubyStr(mainClass)).append("-src/watchOSStubs'\n") + .append(" bs['HEADER_SEARCH_PATHS[sdk=watchsimulator*]'] = '$(inherited) $(SRCROOT)/") .append(IPhoneBuilder.escapeRubyStr(mainClass)).append("-src/watchOSStubs'\n") .append(" bs['SKIP_INSTALL'] = 'YES'\n"); if (resolvedTeamId != null && !resolvedTeamId.isEmpty()) { @@ -592,16 +598,25 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) // Companion embedding is opt-in (watchNative.embedCompanion=true) and OFF // by default. Embedding adds the watch target as a build dependency of the - // iOS app, which makes building the iOS app also build the watch target -- - // and Xcode builds that embedded dependency against the iOS app's - // iphonesimulator SDK, where the real OpenGLES framework collides with the - // watchOSStubs (duplicate EAGLContext) and breaks the iOS screenshot build. - // The watch slice is built + tested independently (build-ios-watch / - // run-watch-ui-tests.sh), so the test pipeline does not need the embed. - // Re-enabling companion packaging needs the embedded dependency pinned to - // the watchsimulator/watchos SDK; tracked as a follow-up. + // iOS app, which makes building the iOS app also build the watch target. + // Remove any dependency/copy phase that Xcode or an older generator run + // left behind unless the project explicitly asks for companion packaging. boolean embedCompanion = "true".equals(request.getArg("watchNative.embedCompanion", "false")); - if (embedCompanion && !isStandalone()) { + if (!embedCompanion || isStandalone()) { + s.append("app_target.dependencies.to_a.each do |dep|\n") + .append(" proxy = dep.respond_to?(:target_proxy) ? dep.target_proxy : nil\n") + .append(" remote = proxy && proxy.respond_to?(:remote_global_id) ? xcproj.objects_by_uuid[proxy.remote_global_id] : nil\n") + .append(" dep_target = dep.respond_to?(:target) ? dep.target : nil\n") + .append(" dep_target = remote if dep_target.nil?\n") + .append(" next unless dep_target && dep_target.respond_to?(:name) && dep_target.name == watch_name\n") + .append(" dep.remove_from_project\n") + .append(" proxy.remove_from_project if proxy && proxy.respond_to?(:remove_from_project) && xcproj.objects.include?(proxy)\n") + .append("end\n") + .append("app_target.build_phases.to_a.each do |phase|\n") + .append(" next unless phase.respond_to?(:display_name) && phase.display_name == 'Embed Watch Content'\n") + .append(" phase.remove_from_project\n") + .append("end\n"); + } else { // Companion: embed the watch .app into the iOS app under // $(CONTENTS_FOLDER_PATH)/Watch and add a build dependency so the // pair archives together. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenCertificateWizardMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenCertificateWizardMojo.java new file mode 100644 index 00000000000..7dce5809ca6 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenCertificateWizardMojo.java @@ -0,0 +1,564 @@ +/* + * 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 org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +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 java.awt.Desktop; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.UUID; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.prefs.Preferences; + +/** + * Launches the standalone Certificate Wizard for a Codename One project. + * + *
mvn cn1:certificatewizard
+ */ +@Mojo(name = "certificatewizard") +public class OpenCertificateWizardMojo extends AbstractCN1Mojo { + private static final String LAUNCHED_PROPERTY = + "com.codename1.maven.OpenCertificateWizardMojo.launched"; + + @Parameter(property = "token", required = false) + private String token; + + @Parameter(property = "user", required = false) + private String user; + + @Parameter(property = "baseUrl", required = false, defaultValue = "https://cloud.codenameone.com") + private String baseUrl; + + @Parameter(property = "outputDir", required = false) + private File outputDir; + + @Parameter(property = "certificatewizard.login", required = false, defaultValue = "true") + private boolean login; + + @Parameter(property = "certificatewizard.loginTimeoutSeconds", required = false, defaultValue = "180") + private int loginTimeoutSeconds; + + @Parameter(property = "certificatewizard.spawn", required = false, defaultValue = "true") + private boolean spawn; + + @Override + protected void executeImpl() throws MojoExecutionException, MojoFailureException { + if (Boolean.getBoolean(LAUNCHED_PROPERTY)) { + getLog().debug("Skipping certificatewizard: already launched in this Maven invocation"); + return; + } + if (!isCN1ProjectDir()) { + getLog().debug("Skipping certificatewizard: not a CN1 project dir"); + return; + } + System.setProperty(LAUNCHED_PROPERTY, "true"); + + File projectDir = getCN1ProjectDir(); + if (outputDir == null) { + outputDir = new File(projectDir, "iosCerts"); + } + outputDir.mkdirs(); + + Preferences prefs = Preferences.userRoot().node("/com/codename1/ui"); + String effectiveToken = firstNonEmpty(token, ""); + String effectiveUser = firstNonEmpty(user, prefs.get("user", null), ""); + String cachedToken = firstNonEmpty(prefs.get("token", null), ""); + if (effectiveToken.length() == 0 && isUsableJwt(cachedToken)) { + effectiveToken = cachedToken; + getLog().debug("Using cached Codename One signing API JWT"); + } + if (login && effectiveToken.length() == 0) { + LoginResult result = interactiveLogin(); + if (result != null) { + effectiveToken = result.token; + effectiveUser = firstNonEmpty(user, result.user, effectiveUser); + prefs.put("token", effectiveToken); + if (effectiveUser.length() > 0) { + prefs.put("user", effectiveUser); + } + } + } + + File runtimeDir = new File(System.getProperty("user.home"), ".certificateWizard"); + runtimeDir.mkdirs(); + File inputFile = new File(runtimeDir, "certificatewizard.input"); + File outputFile = new File(runtimeDir, UUID.randomUUID().toString() + ".output"); + writeBinding(inputFile, projectDir, outputDir, outputFile, effectiveUser, effectiveToken); + + ToolClasspath toolClasspath = getCertificateWizardClasspath(); + getLog().info("Launching certificate wizard bound to " + projectDir); + if (effectiveToken.length() == 0) { + getLog().warn("No Codename One bearer token was found. The wizard will open in offline mode unless " + + "you pass -Dtoken= or allow -Dcertificatewizard.login=true."); + } + + if (shouldSpawn()) { + launchDetached(toolClasspath, runtimeDir, inputFile, projectDir); + return; + } + + Java java = createJava(); + java.setFork(true); + java.setJvm(namedJavaLauncher(runtimeDir).getAbsolutePath()); + java.setClassname("com.codename1.certificatewizard.CertificateWizardLauncher"); + java.createClasspath().setPath(joinClasspath(toolClasspath.files)); + configureDesktopIdentity(java, toolClasspath.primaryJar, runtimeDir); + java.createJvmarg().setValue("-Dcertificatewizard.input=" + inputFile.getAbsolutePath()); + java.executeJava(); + } + + @Override + protected boolean isCN1ProjectDir() { + File cn1ProjectDir = getCN1ProjectDir(); + if (cn1ProjectDir == null || project == null || project.getBasedir() == null) { + getLog().debug("Skipping certificatewizard: not a CN1 project dir"); + 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(); + if (cn1.equals(rootCommon)) { + return true; + } + } catch (IOException ex) { + getLog().error("Failed to get canonical paths for project dir", ex); + } + getLog().debug("Skipping certificatewizard: not a CN1 project dir"); + return false; + } + + private void configureDesktopIdentity(Java java, File jar, File runtimeDir) { + for (String arg : desktopIdentityArgs(jar, runtimeDir)) { + java.createJvmarg().setValue(arg); + } + } + + 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, "certificatewizard.log"); + List command = new ArrayList(); + command.add(namedJavaLauncher(runtimeDir).getAbsolutePath()); + command.addAll(desktopIdentityArgs(toolClasspath.primaryJar, runtimeDir)); + command.add("-Dcertificatewizard.input=" + inputFile.getAbsolutePath()); + command.add("-cp"); + command.add(joinClasspath(toolClasspath.files)); + command.add("com.codename1.certificatewizard.CertificateWizardLauncher"); + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(projectDir); + pb.redirectErrorStream(true); + pb.redirectOutput(ProcessBuilder.Redirect.appendTo(log)); + configureLauncherEnvironment(pb); + try { + pb.start(); + getLog().info("Certificate Wizard launched in the background. Log: " + log.getAbsolutePath()); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to launch Certificate Wizard", ex); + } + } + + private String javaExecutable() { + String executable = isWindows() ? "javaw.exe" : "java"; + return new File(new File(System.getProperty("java.home"), "bin"), executable).getAbsolutePath(); + } + + File namedJavaLauncher(File runtimeDir) { + File java = new File(javaExecutable()); + if (isWindows()) { + File launcher = new File(runtimeDir, "CertificateWizard.exe"); + try { + FileUtils.copyFile(java, launcher); + return launcher; + } catch (IOException ex) { + getLog().debug("Unable to create Certificate Wizard launcher executable: " + ex.getMessage()); + return java; + } + } + File launcher = new File(runtimeDir, "Certificate Wizard"); + try { + Files.deleteIfExists(launcher.toPath()); + Files.createSymbolicLink(launcher.toPath(), java.toPath()); + return launcher; + } catch (IOException | UnsupportedOperationException | SecurityException ex) { + getLog().debug("Unable to create Certificate Wizard launcher symlink: " + ex.getMessage()); + return java; + } + } + + 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); + } + + List desktopIdentityArgs(File jar, File runtimeDir) { + List args = new ArrayList(); + args.add("-Dapple.awt.application.name=Certificate Wizard"); + args.add("-Dcom.apple.mrj.application.apple.menu.about.name=Certificate Wizard"); + args.add("-Dsun.awt.application.name=Certificate Wizard"); + args.add("-Dsun.awt.X11.XWMClass=CertificateWizard"); + 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()) { + return args; + } + args.add("-Xdock:name=Certificate Wizard"); + File icon = extractWizardIcon(jar, runtimeDir); + if (icon != null && icon.isFile()) { + args.add("-Xdock:icon=" + icon.getAbsolutePath()); + } + return args; + } + + 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."); + } + + File extractWizardIcon(File jar, File runtimeDir) { + File iconFile = new File(runtimeDir, "certificate-wizard-icon.png"); + try (JarFile jf = new JarFile(jar)) { + JarEntry entry = jf.getJarEntry("icon.png"); + if (entry == null) { + getLog().debug("Certificate wizard jar does not contain icon.png"); + return null; + } + try (InputStream in = jf.getInputStream(entry)) { + FileUtils.copyInputStreamToFile(in, iconFile); + } + return iconFile; + } catch (IOException ex) { + getLog().debug("Unable to extract certificate wizard dock icon: " + ex.getMessage()); + return null; + } + } + + private LoginResult interactiveLogin() { + if (loginTimeoutSeconds <= 0) { + return null; + } + String key = UUID.randomUUID().toString(); + long deadline = System.currentTimeMillis() + loginTimeoutSeconds * 1000L; + try { + String root = normalizeBaseUrl(baseUrl); + String redirect = root + "/loggedIn.html"; + String loginUrl = root + "/appsec/7.0/set-user?redirect=" + enc(redirect) + + "&loginKey=" + enc(key); + getLog().info("Opening Codename One sign-in for certificate wizard authentication"); + getLog().info(loginUrl); + openBrowser(loginUrl); + while (System.currentTimeMillis() < deadline) { + LoginResult result = pollLogin(root, key); + if (result != null) { + getLog().info("Received Codename One signing API token for " + result.user); + return result; + } + try { + Thread.sleep(2000L); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return null; + } + } + getLog().warn("Timed out waiting for Codename One browser login"); + } catch (Exception ex) { + getLog().warn("Unable to complete browser login: " + ex.getMessage()); + } + return null; + } + + private LoginResult pollLogin(String root, String key) throws IOException { + URL url = new URL(root + "/poll-user?ver=2&loginKey=" + enc(key)); + HttpURLConnection con = (HttpURLConnection) url.openConnection(); + try { + con.setConnectTimeout(10000); + con.setReadTimeout(10000); + con.setRequestMethod("GET"); + int code = con.getResponseCode(); + if (code == 404) { + return null; + } + if (code != 200) { + getLog().debug("Codename One login poll returned HTTP " + code); + return null; + } + String body = IOUtils.toString(con.getInputStream(), StandardCharsets.UTF_8).trim(); + if (body.length() == 0) { + return null; + } + String[] lines = body.split("\\r?\\n", 2); + String receivedToken = lines[0].trim(); + if (receivedToken.length() == 0) { + return null; + } + String receivedUser = lines.length > 1 ? lines[1].trim() : ""; + return new LoginResult(receivedToken, receivedUser); + } finally { + con.disconnect(); + } + } + + private void openBrowser(String url) throws Exception { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(new URI(url)); + } else { + getLog().warn("Desktop browsing is not available. Open the URL above in a browser to continue."); + } + } + + private void writeBinding(File inputFile, File projectDir, File outDir, File outputFile, + String effectiveUser, String effectiveToken) throws MojoExecutionException { + String content = "# Codename One certificate wizard project binding\n" + + "projectDir=" + projectDir.getAbsolutePath() + "\n" + + "settings=" + new File(projectDir, "codenameone_settings.properties").getAbsolutePath() + "\n" + + "outputDir=" + outDir.getAbsolutePath() + "\n" + + "output=" + outputFile.getAbsolutePath() + "\n" + + "user=" + effectiveUser + "\n" + + "token=" + effectiveToken + "\n" + + "baseUrl=" + baseUrl + "\n"; + try { + FileUtils.write(inputFile, content, StandardCharsets.UTF_8); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to write certificatewizard binding", ex); + } + } + + private ToolClasspath getCertificateWizardClasspath() throws MojoExecutionException, MojoFailureException { + Artifact artifact = getArtifact("com.codenameone", "codenameone-certificatewizard"); + if (artifact == null) { + artifact = repositorySystem.createArtifact( + "com.codenameone", "codenameone-certificatewizard", pluginVersion(), "jar"); + } + ToolClasspath classpath = resolveToolClasspath(artifact); + if (classpath.primaryJar == null || classpath.files.isEmpty()) { + throw new MojoFailureException( + "Could not resolve the certificate wizard " + + "(com.codenameone:codenameone-certificatewizard:" + pluginVersion() + + ").\n" + + "It is distributed through Maven Central alongside the Codename One plugin.\n" + + "To work on the wizard itself, run:\n" + + " cd scripts/certificatewizard && mvn -Pexecutable-jar -pl javase -am package -Dcodename1.platform=javase\n" + + " java -cp \"javase/target/codenameone-certificatewizard-*.jar:javase/target/libs/*\" " + + "com.codename1.certificatewizard.CertificateWizardLauncher"); + } + 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-certificatewizard".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 static final class ToolClasspath { + final File primaryJar; + final List files; + + ToolClasspath(File primaryJar, List files) { + this.primaryJar = primaryJar; + this.files = files; + } + } + + private String pluginVersion() { + if (pluginArtifacts != null) { + for (Artifact a : pluginArtifacts) { + if ("codenameone-maven-plugin".equals(a.getArtifactId()) + && "com.codenameone".equals(a.getGroupId())) { + return a.getVersion(); + } + } + } + return project.getProperties().getProperty("cn1.plugin.version", + project.getProperties().getProperty("cn1.version", "8.0-SNAPSHOT")); + } + + private static String firstNonEmpty(String a, String b, String c) { + if (a != null && a.trim().length() > 0) { + return a.trim(); + } + if (b != null && b.trim().length() > 0) { + return b.trim(); + } + return c == null ? "" : c; + } + + private static String firstNonEmpty(String a, String b) { + return firstNonEmpty(a, b, ""); + } + + private static String normalizeBaseUrl(String url) { + String out = url == null || url.trim().length() == 0 + ? "https://cloud.codenameone.com" : url.trim(); + while (out.endsWith("/")) { + out = out.substring(0, out.length() - 1); + } + return out; + } + + private static String enc(String s) throws IOException { + return URLEncoder.encode(s, "UTF-8"); + } + + static boolean isUsableJwt(String token) { + long expiresAt = jwtExpiresAt(token); + return expiresAt > System.currentTimeMillis() + 120000L; + } + + static long jwtExpiresAt(String token) { + if (token == null) { + return -1L; + } + String[] parts = token.split("\\.", -1); + if (parts.length < 2) { + return -1L; + } + try { + byte[] decoded = Base64.getUrlDecoder().decode(padBase64(parts[1])); + String payload = new String(decoded, StandardCharsets.UTF_8); + long exp = numericJsonClaim(payload, "exp"); + return exp <= 0L ? -1L : exp * 1000L; + } catch (RuntimeException ex) { + return -1L; + } + } + + private static long numericJsonClaim(String json, String name) { + String quoted = "\"" + name + "\""; + int idx = json.indexOf(quoted); + if (idx < 0) { + return -1L; + } + int colon = json.indexOf(':', idx + quoted.length()); + if (colon < 0) { + return -1L; + } + int start = colon + 1; + while (start < json.length() && Character.isWhitespace(json.charAt(start))) { + start++; + } + int end = start; + while (end < json.length() && Character.isDigit(json.charAt(end))) { + end++; + } + if (end == start) { + return -1L; + } + try { + return Long.parseLong(json.substring(start, end)); + } catch (NumberFormatException ex) { + return -1L; + } + } + + private static String padBase64(String value) { + int remainder = value.length() % 4; + if (remainder == 0) { + return value; + } + StringBuilder out = new StringBuilder(value); + for (int i = remainder; i < 4; i++) { + out.append('='); + } + return out.toString(); + } + + private static final class LoginResult { + final String token; + final String user; + + LoginResult(String token, String user) { + this.token = token; + this.user = user == null ? "" : user; + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGameBuilderMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGameBuilderMojo.java index e1b3beaa4b5..91d196998a4 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGameBuilderMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGameBuilderMojo.java @@ -24,6 +24,9 @@ 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; @@ -33,6 +36,8 @@ import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; /** @@ -42,9 +47,8 @@ *

It writes a binding descriptor to {@code ~/.gameBuilder/gamebuilder.input} (the * project's games directory, settings and an output channel) and forks the editor app. * The editor is delivered through Maven — the artifact - * {@code com.codenameone:codenameone-gamebuilder:jar-with-dependencies} is resolved from - * Maven Central (the same mechanism that ships the Designer/CSS-compiler jar), so there - * is no separate install step. The editor reads/writes + * {@code com.codenameone:codenameone-gamebuilder} and its runtime dependencies are + * resolved from Maven Central, so there is no separate install step. The editor reads/writes * {@code src/main/resources/games/*.game} in this project.

* *

The goal only applies to Java 17 Codename One projects (the editor uses Java 17 @@ -78,7 +82,7 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException File outputFile = new File(runtimeDir, uuid + ".output"); writeBinding(new File(runtimeDir, "gamebuilder.input"), projectDir, gamesDir, outputFile); - File jar = getGameBuilderJar(); + ToolClasspath toolClasspath = getGameBuilderClasspath(); getLog().info("Launching game builder bound to " + projectDir); Java java = createJava(); @@ -86,7 +90,8 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException if ("true".equals(System.getProperty("spawn", "true"))) { java.setSpawn(true); } - java.setJar(jar); + java.setClassname("com.codename1.gamebuilder.GameBuilderStub"); + java.createClasspath().setPath(joinClasspath(toolClasspath.files)); // The forked editor reads the binding path from this system property. java.createJvmarg().setValue("-Dgamebuilder.input=" + new File(runtimeDir, "gamebuilder.input").getAbsolutePath()); @@ -133,31 +138,73 @@ private void writeBinding(File inputFile, File projectDir, File gamesDir, File o } /** - * Resolves the game-builder editor through Maven — the self-contained artifact - * {@code com.codenameone:codenameone-gamebuilder:jar-with-dependencies}. This mirrors - * {@link AbstractCN1Mojo#getDesignerJar()}: in a released build the artifact is a - * declared dependency of the plugin (found via {@link #getArtifact}); otherwise it is - * resolved from the configured repositories at the plugin's own version. There is no - * install script — the editor ships with the plugin from Maven Central. + * Resolves the game-builder editor and runtime dependencies through Maven. */ - private File getGameBuilderJar() throws MojoExecutionException, MojoFailureException { - Artifact artifact = getArtifact("com.codenameone", "codenameone-gamebuilder", "jar-with-dependencies"); + private ToolClasspath getGameBuilderClasspath() throws MojoExecutionException, MojoFailureException { + Artifact artifact = getArtifact("com.codenameone", "codenameone-gamebuilder"); if (artifact == null) { - artifact = repositorySystem.createArtifactWithClassifier( - "com.codenameone", "codenameone-gamebuilder", pluginVersion(), "jar", "jar-with-dependencies"); + artifact = repositorySystem.createArtifact( + "com.codenameone", "codenameone-gamebuilder", pluginVersion(), "jar"); } - File jar = getJar(artifact); - if (jar == null || !jar.exists()) { + ToolClasspath classpath = resolveToolClasspath(artifact); + if (classpath.files.isEmpty()) { throw new MojoFailureException( "Could not resolve the game builder editor " - + "(com.codenameone:codenameone-gamebuilder:" + pluginVersion() + ":jar-with-dependencies).\n" + + "(com.codenameone:codenameone-gamebuilder:" + pluginVersion() + ").\n" + "It is distributed through Maven Central alongside the Codename One plugin; make sure " + "your build can reach Maven Central and is using a released plugin version.\n" + "To work on the editor itself, run it straight from its module:\n" + " cd scripts/gamebuilder && mvn -Pexecutable-jar -pl javase -am package -Dcodename1.platform=javase\n" - + " java -jar javase/target/codenameone-gamebuilder-*-jar-with-dependencies.jar"); + + " java -cp \"javase/target/codenameone-gamebuilder-*.jar:javase/target/libs/*\" " + + "com.codename1.gamebuilder.GameBuilderStub"); + } + 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)); + addArtifactFile(files, artifact); + if (result != null && result.getArtifacts() != null) { + for (Artifact resolved : result.getArtifacts()) { + addArtifactFile(files, resolved); + } + } + return new ToolClasspath(files); + } + + private static void addArtifactFile(List files, Artifact artifact) { + if (artifact == null || artifact.getFile() == null || !"jar".equals(artifact.getType())) { + return; + } + File file = artifact.getFile().getAbsoluteFile(); + if (!file.exists() || files.contains(file)) { + return; + } + files.add(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 static final class ToolClasspath { + final List files; + + ToolClasspath(List files) { + this.files = files; } - return jar; } /** The Codename One plugin's own version, used as the game-builder editor's version. */ diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java index 53ee9202385..44ecf1a114a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java @@ -346,6 +346,7 @@ private static void emitMethod(StringBuilder sb, RestMethod rm) { sb.append(" com.codename1.io.rest.RequestBuilder _rb = com.codename1.io.rest.Rest.") .append(rm.verb).append("(_url);\n"); + emitExceptionHandler(sb, rm); // Query params. for (RestParam p : rm.params) { @@ -386,6 +387,7 @@ private static void emitMethod(StringBuilder sb, RestMethod rm) { } // Fetch. + emitErrorCodeHandler(sb, rm); switch (rm.fetchKind) { case MAPPED: sb.append(" _rb.fetchAsMapped(").append(rm.payloadBinaryName).append(".class, callback);\n"); @@ -406,6 +408,35 @@ private static void emitMethod(StringBuilder sb, RestMethod rm) { sb.append(" }\n\n"); } + private static void emitErrorCodeHandler(StringBuilder sb, RestMethod rm) { + if (rm.callbackIndex >= 0) { + sb.append(" _rb.onErrorCodeString(new com.codename1.io.rest.ErrorCodeHandler() {\n"); + sb.append(" public void onError(com.codename1.io.rest.Response _r) {\n"); + sb.append(" callback.completed((com.codename1.io.rest.Response)_r);\n"); + sb.append(" }\n"); + sb.append(" });\n"); + return; + } + sb.append(" _rb.onErrorCodeString(new com.codename1.io.rest.ErrorCodeHandler() {\n"); + sb.append(" public void onError(com.codename1.io.rest.Response _r) { }\n"); + sb.append(" });\n"); + } + + private static void emitExceptionHandler(StringBuilder sb, RestMethod rm) { + if (rm.callbackIndex >= 0) { + sb.append(" _rb.onError(new com.codename1.ui.events.ActionListener() {\n"); + sb.append(" public void actionPerformed(com.codename1.io.NetworkEvent _evt) {\n"); + sb.append(" _evt.consume();\n"); + sb.append(" callback.completed(null);\n"); + sb.append(" }\n"); + sb.append(" }, false);\n"); + return; + } + sb.append(" _rb.onError(new com.codename1.ui.events.ActionListener() {\n"); + sb.append(" public void actionPerformed(com.codename1.io.NetworkEvent _evt) { _evt.consume(); }\n"); + sb.append(" }, false);\n"); + } + /// Builds the Java expression that resolves the URL path with `{name}` /// placeholders replaced by the matching `@Path` parameter values /// (URL-encoded). The result is concatenated onto `baseUrl`. diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java new file mode 100644 index 00000000000..365dea6a671 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java @@ -0,0 +1,38 @@ +package com.codename1.maven; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Cn1AppArchetypeCertificateWizardTest { + + @Test + void certificateWizardBindingsAreIncludedInPackagedProjectTemplates() throws Exception { + assertContains("tools/netbeans/nbactions.xml", "CUSTOM-Open Certificate Wizard"); + assertContains("tools/netbeans/nbactions.xml", "cn1:certificatewizard"); + assertContains("tools/eclipse/__mainName__ - Certificate Wizard.launch", "cn1:certificatewizard"); + assertContains(".idea/runConfigurations/CN1_Certificate_Wizard.xml", "cn1:certificatewizard"); + assertContains(".vscode/settings.json", "Tools > Certificate Wizard"); + assertContains(".vscode/settings.json", "cn1:certificatewizard"); + assertContains("mvnconfig.toml", "[certificate_wizard]"); + assertContains("mvnconfig.toml", "cn1:certificatewizard"); + assertContains("run.sh", "cn1:certificatewizard"); + assertContains("run.bat", "cn1:certificatewizard"); + assertContains("README.adoc", "mvn cn1:certificatewizard"); + } + + private static void assertContains(String path, String expected) throws Exception { + String content = archetypeResource("archetype-resources/" + path); + assertTrue(content.contains(expected), path + " should contain " + expected); + } + + private static String archetypeResource(String path) throws Exception { + File file = new File("../cn1app-archetype/src/main/resources", path); + assertTrue(file.isFile(), "Missing archetype resource " + file.getAbsolutePath()); + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenCertificateWizardMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenCertificateWizardMojoTest.java new file mode 100644 index 00000000000..be656503fbd --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenCertificateWizardMojoTest.java @@ -0,0 +1,146 @@ +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 OpenCertificateWizardMojoTest { + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void extractsPackagedIconForDesktopLaunchers() throws Exception { + File jar = tmp.newFile("wizard.jar"); + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(jar))) { + out.putNextEntry(new JarEntry("icon.png")); + out.write("fake-png".getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + + File runtimeDir = tmp.newFolder("runtime"); + File extracted = new OpenCertificateWizardMojo().extractWizardIcon(jar, runtimeDir); + + assertTrue(extracted.isFile()); + assertEquals("certificate-wizard-icon.png", extracted.getName()); + assertEquals("fake-png", new String(Files.readAllBytes(extracted.toPath()), StandardCharsets.UTF_8)); + } + + @Test + public void macOsDetectionIsBasedOnOsName() { + String old = System.getProperty("os.name"); + try { + System.setProperty("os.name", "Mac OS X"); + assertTrue(OpenCertificateWizardMojo.isMacOs()); + System.setProperty("os.name", "Linux"); + assertFalse(OpenCertificateWizardMojo.isMacOs()); + } finally { + if (old == null) { + System.clearProperty("os.name"); + } else { + System.setProperty("os.name", old); + } + } + } + + @Test + public void java9DetectionHandlesModernAndLegacyVersionStrings() { + String old = System.getProperty("java.specification.version"); + try { + System.setProperty("java.specification.version", "17"); + assertTrue(OpenCertificateWizardMojo.isJava9OrNewer()); + System.setProperty("java.specification.version", "1.8"); + assertFalse(OpenCertificateWizardMojo.isJava9OrNewer()); + } finally { + if (old == null) { + System.clearProperty("java.specification.version"); + } else { + System.setProperty("java.specification.version", old); + } + } + } + + @Test + public void desktopIdentityArgsIncludeCrossPlatformAppNameAndModuleExports() throws Exception { + File jar = tmp.newFile("wizard.jar"); + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(jar))) { + out.putNextEntry(new JarEntry("icon.png")); + out.write("fake-png".getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + File runtimeDir = tmp.newFolder("runtime"); + + List args = new OpenCertificateWizardMojo().desktopIdentityArgs(jar, runtimeDir); + + assertTrue(args.contains("-Dapple.awt.application.name=Certificate Wizard")); + assertTrue(args.contains("-Dcom.apple.mrj.application.apple.menu.about.name=Certificate Wizard")); + assertTrue(args.contains("-Dsun.awt.application.name=Certificate Wizard")); + assertTrue(args.contains("-Dsun.awt.X11.XWMClass=CertificateWizard")); + if (OpenCertificateWizardMojo.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 namedJavaLauncherUsesCertificateWizardProcessName() throws Exception { + File runtimeDir = tmp.newFolder("runtime"); + File launcher = new OpenCertificateWizardMojo().namedJavaLauncher(runtimeDir); + + if (OpenCertificateWizardMojo.isWindows()) { + assertEquals("CertificateWizard.exe", launcher.getName()); + } else { + assertEquals("Certificate Wizard", launcher.getName()); + assertTrue(Files.isSymbolicLink(launcher.toPath()) || launcher.exists()); + } + } + + @Test + public void certificateWizardCanLaunchFromProjectRootWithCommonModule() 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, "codenameone_settings.properties").toPath(), + "codename1.packageName=com.example.app\n".getBytes(StandardCharsets.UTF_8)); + + OpenCertificateWizardMojo mojo = new OpenCertificateWizardMojo(); + mojo.project = projectAt(root); + + assertTrue(mojo.isCN1ProjectDir()); + assertEquals(common.getCanonicalFile(), mojo.getCN1ProjectDir().getCanonicalFile()); + } + + @Test + public void certificateWizardCanStillLaunchFromCommonModule() 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)); + + OpenCertificateWizardMojo mojo = new OpenCertificateWizardMojo(); + mojo.project = projectAt(common); + + assertTrue(mojo.isCN1ProjectDir()); + assertEquals(common.getCanonicalFile(), mojo.getCN1ProjectDir().getCanonicalFile()); + } + + 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; + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java index ba34655c7eb..975f1314b03 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java @@ -95,6 +95,14 @@ public void emitsImplAndBootstrapForPetApi() throws Exception { implSrc.contains("_rb.header(\"Authorization\", AuthorizationHeader)")); assertTrue("getPetById should fetch as mapped Pet", implSrc.contains("_rb.fetchAsMapped(com.example.Pet.class, callback)")); + assertTrue("HTTP error statuses should be delivered to the callback", + implSrc.contains("_rb.onErrorCodeString(new com.codename1.io.rest.ErrorCodeHandler()")); + assertTrue("generated clients should consume transport exceptions through the existing error listener API", + implSrc.contains("_rb.onError(new com.codename1.ui.events.ActionListener()")); + assertTrue("transport exceptions should be delivered to the callback", + implSrc.contains("callback.completed(null)")); + assertTrue("error response should be forwarded without falling through to the default dialog", + implSrc.contains("callback.completed((com.codename1.io.rest.Response)_r)")); assertTrue("addPet should call Rest.post", implSrc.contains("com.codename1.io.rest.Rest.post(_url)")); diff --git a/maven/core-unittests/src/test/java/com/codename1/io/rest/RequestBuilderTest.java b/maven/core-unittests/src/test/java/com/codename1/io/rest/RequestBuilderTest.java index e96a7e06f71..7b5f35cdd1a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/io/rest/RequestBuilderTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/io/rest/RequestBuilderTest.java @@ -40,6 +40,7 @@ void createRequestPopulatesConnection() throws Exception { ConnectionRequest executed = findRequest(BASE_URL + "/items/42"); assertNotNull(executed); + assertFalse(executed.isFailSilently()); assertEquals(ConnectionRequest.CachingMode.MANUAL, executed.getCacheMode()); assertEquals(1500, executed.getTimeout()); assertEquals(3000, executed.getReadTimeout()); @@ -86,6 +87,9 @@ void stringErrorHandlerReceivesResponse() throws Exception { }); builder.getAsString(); + ConnectionRequest executed = findRequest(BASE_URL); + assertNotNull(executed); + assertTrue(executed.isFailSilently()); Response response = holder[0]; assertNotNull(response); assertEquals(500, response.getResponseCode()); @@ -111,6 +115,9 @@ void jsonErrorHandlerRespectsParserConfiguration() throws Exception { }); builder.getAsJsonMap(); + ConnectionRequest executed = findRequest(BASE_URL); + assertNotNull(executed); + assertTrue(executed.isFailSilently()); Response response = holder[0]; assertNotNull(response); Map data = response.getResponseData(); diff --git a/scripts/certificatewizard/.gitignore b/scripts/certificatewizard/.gitignore new file mode 100644 index 00000000000..cbd4bf3670d --- /dev/null +++ b/scripts/certificatewizard/.gitignore @@ -0,0 +1,2 @@ +/target/ +*/target/ diff --git a/scripts/certificatewizard/common/codenameone_settings.properties b/scripts/certificatewizard/common/codenameone_settings.properties new file mode 100644 index 00000000000..86cf88fa524 --- /dev/null +++ b/scripts/certificatewizard/common/codenameone_settings.properties @@ -0,0 +1,16 @@ +codename1.mainName=CertificateWizard +codename1.packageName=com.codename1.certificatewizard +codename1.displayName=Codename One Certificate Wizard +codename1.version=1.0 +codename1.vendor=CodenameOne +codename1.secondaryTitle=Certificate Wizard +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.kotlin=false diff --git a/scripts/certificatewizard/common/icon.png b/scripts/certificatewizard/common/icon.png new file mode 100644 index 00000000000..1f4fa5dd252 Binary files /dev/null and b/scripts/certificatewizard/common/icon.png differ diff --git a/scripts/certificatewizard/common/pom.xml b/scripts/certificatewizard/common/pom.xml new file mode 100644 index 00000000000..d30aeb1520b --- /dev/null +++ b/scripts/certificatewizard/common/pom.xml @@ -0,0 +1,160 @@ + + + 4.0.0 + + com.codenameone.certificatewizard + cn1-certificatewizard + 8.0-SNAPSHOT + + cn1-certificatewizard-common + jar + cn1-certificatewizard-common + Shared UI and service code for the Codename One Certificate Wizard. + + + + 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 + + java + true + + -Xmx1024M + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + simulator + + javase + + + + + + + + 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 + + + generate-ios-signing-client + generate-sources + generate-openapi + + ${project.basedir}/../specs/openapi.json + com.codename1.certificatewizard.cloud + ${project.build.directory}/generated-sources/cn1 + + + + cn1-process-classes + process-classes + + bytecode-compliance + css + process-annotations + + + + attach-test-artifact + test + attach-test-artifact + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.4.0 + + + add-generated-cn1-sources + generate-sources + add-source + + + ${project.build.directory}/generated-sources/cn1 + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + diff --git a/scripts/certificatewizard/common/src/main/css/theme.css b/scripts/certificatewizard/common/src/main/css/theme.css new file mode 100644 index 00000000000..083702e591a --- /dev/null +++ b/scripts/certificatewizard/common/src/main/css/theme.css @@ -0,0 +1,863 @@ +/* + * Certificate Wizard theme adapted from the iOS Signing mock design. + * CN1 CSS uses UIIDs, not web selectors; sizes are density-independent mm. + */ + +Form, CWForm { + background-color: #F3F4F7; + color: #112247; + font-family: "native:MainRegular"; + font-size: 2.75mm; +} +Label, Button, TextField, TextArea, CheckBox { + color: #112247; + font-family: "native:MainRegular"; + font-size: 2.75mm; +} +CWChrome { + background-color: #FFFFFF; + border-bottom: 0.25mm solid #D9DEE8; + padding: 1.5mm 2.8mm 1.5mm 2.8mm; +} +CWTitle { + background-color: transparent; + color: #112247; + font-family: "native:MainBold"; + font-size: 3.0mm; + padding: 0.6mm 1.5mm 0.6mm 1.5mm; +} +CWLogo { + background-color: transparent; + color: #112247; + font-family: "native:MainBold"; + font-size: 3.1mm; + padding: 0.8mm 1mm 0.8mm 0; +} +CWStatus { + background: cn1-pill-border; + background-color: #E7F4EC; + color: #2E8B57; + padding: 1.0mm 2.5mm 1.0mm 2.5mm; + margin: 0.3mm; + font-family: "native:MainBold"; + font-size: 2.45mm; +} +CWStatusOff { + background: cn1-pill-border; + background-color: #FDF1DC; + color: #B26A00; + padding: 1.0mm 2.5mm 1.0mm 2.5mm; + margin: 0.3mm; + font-family: "native:MainBold"; + font-size: 2.45mm; +} +CWToolbarButton { + background-color: #FFFFFF; + color: #7F8AA3; + border: 0.25mm solid #D9DEE8; + border-radius: 1.6mm; + padding: 1.2mm; + margin: 0.45mm; +} +CWToolbarActions { + background-color: transparent; + padding: 0; + margin: 0; +} +CWEmail { + background-color: transparent; + color: #7F8AA3; + font-size: 2.45mm; + padding: 1mm; +} +CWBody { + background-color: #F3F4F7; +} +CWSidebar { + background-color: #FFFFFF; + border-right: 0.25mm solid #D9DEE8; + padding: 2.0mm 1.5mm 2.0mm 1.5mm; +} +CWNavLabel { + background-color: transparent; + color: #A0A8BA; + font-family: "native:MainBold"; + font-size: 2.1mm; + padding: 2.2mm 1.4mm 0.8mm 1.4mm; +} +CWNav { + background-color: #FFFFFF; + color: #7F8AA3; + padding: 1.7mm 1.8mm 1.7mm 1.8mm; + margin: 0.25mm 0 0.25mm 0; + border-radius: 1.6mm; + font-size: 2.65mm; +} +CWNavSelected { + background-color: #E8F0FF; + color: #2F6BFF; + padding: 1.7mm 1.8mm 1.7mm 1.8mm; + margin: 0.25mm 0 0.25mm 0; + border-radius: 1.6mm; + font-family: "native:MainBold"; + font-size: 2.65mm; +} +CWPage { + background-color: #F3F4F7; + padding: 4.0mm 5.0mm 6.0mm 5.0mm; +} +CWPageTitle { + background-color: transparent; + color: #112247; + font-family: "native:MainBold"; + font-size: 4.5mm; + padding: 0 0 0.5mm 0; +} +CWSub { + background-color: transparent; + color: #7F8AA3; + font-size: 2.55mm; + padding: 0 0 2.2mm 0; +} +CWCard { + background-color: #FFFFFF; + border: 0.25mm solid #D9DEE8; + border-radius: 1.8mm; + padding: 2.4mm; + margin: 1.0mm 0 1.4mm 0; +} +CWCardTitle { + background-color: transparent; + color: #112247; + font-family: "native:MainBold"; + font-size: 2.8mm; + padding: 0.5mm 0 0.4mm 0; +} +CWCardMeta { + background-color: transparent; + color: #7F8AA3; + font-size: 2.35mm; + padding: 0.2mm 0 0.2mm 0; +} +CWCardRow { + background-color: #FFFFFF; + border-top: 0.25mm solid #D9DEE8; + padding: 1.0mm 0 1.0mm 0; + margin: 0; +} +CWMetric { + background-color: #FFFFFF; + border: 0.25mm solid #D9DEE8; + border-radius: 1.8mm; + padding: 2.5mm; + margin: 0.8mm; +} +CWMetricNumber { + background-color: transparent; + color: #112247; + font-family: "native:MainBold"; + font-size: 5.2mm; +} +CWMetricLabel { + background-color: transparent; + color: #7F8AA3; + font-size: 2.35mm; + padding: 0.4mm 0 0 0; +} +CWActionRow { + background-color: transparent; + padding: 0; + margin: 0.8mm 0 0.8mm 0; +} +CWActionGrid { + background-color: transparent; + padding: 0; + margin: 0; +} +CWPrimary { + background: cn1-pill-border; + background-color: #B8D532; + color: #FFFFFF; + padding: 1.35mm 3.1mm 1.35mm 3.1mm; + margin: 0.4mm; + font-family: "native:MainBold"; + font-size: 2.45mm; + border-radius: 3.2mm; + text-align: center; +} +CWAccent { + background: cn1-pill-border; + background-color: #2F6BFF; + color: #FFFFFF; + padding: 1.35mm 3.1mm 1.35mm 3.1mm; + margin: 0.4mm; + font-family: "native:MainBold"; + font-size: 2.45mm; + border-radius: 3.2mm; + text-align: center; +} +CWOutline { + background: cn1-pill-border; + background-color: #FFFFFF; + color: #112247; + border: 0.25mm solid #D9DEE8; + border-radius: 3.2mm; + padding: 1.25mm 2.8mm 1.25mm 2.8mm; + margin: 0.4mm; + font-family: "native:MainBold"; + font-size: 2.45mm; + text-align: center; +} +CWDanger { + background: cn1-pill-border; + background-color: #FFFFFF; + color: #D64545; + border: 0.25mm solid #D9DEE8; + border-radius: 3.2mm; + padding: 1.25mm 2.8mm 1.25mm 2.8mm; + margin: 0.4mm; + font-family: "native:MainBold"; + font-size: 2.45mm; + text-align: center; +} +CWDisabled { + background: cn1-pill-border; + background-color: #EEF1F6; + color: #A0A8BA; + border: 0.25mm solid #D9DEE8; + border-radius: 3.2mm; + padding: 1.35mm 3.1mm 1.35mm 3.1mm; + margin: 0.4mm; + font-family: "native:MainBold"; + font-size: 2.45mm; + text-align: center; +} +CWField { + background-color: #FFFFFF; + color: #112247; + border: 0.25mm solid #D9DEE8; + border-radius: 1.4mm; + padding: 1.4mm; + margin: 0.5mm 0 1.2mm 0; +} +CWFieldHint { + background-color: transparent; + color: #A0A8BA; + padding: 1.4mm; + font-size: 2.75mm; +} +CWFieldLabel { + background-color: transparent; + color: #7F8AA3; + font-size: 2.35mm; + padding: 0.7mm 0 0.4mm 0; +} +CWFilterRow { + background-color: transparent; + padding: 0 0 1.2mm 0; +} +CWFilterWrap { + background-color: #FFFFFF; + border: 0.25mm solid #D9DEE8; + border-radius: 1.4mm; + padding: 0; + margin: 0.4mm 0.8mm 0.4mm 0; +} +CWFilterField { + background-color: #FFFFFF; + color: #112247; + border: none; + padding: 1.1mm 1.4mm 1.1mm 1.4mm; + margin: 0; + font-size: 2.45mm; +} +CWFilterClear { + background-color: transparent; + color: #7F8AA3; + padding: 1.0mm; + margin: 0; +} +CWTableBody { + background-color: transparent; + padding: 0; + margin: 0; +} +CWTableHeader { + background-color: transparent; + color: #7F8AA3; + font-family: "native:MainBold"; + font-size: 2.1mm; + padding: 1.0mm 1.2mm 1.2mm 1.2mm; +} +CWRow { + background-color: #FFFFFF; + border-top: 0.25mm solid #D9DEE8; + padding: 1.5mm 1.2mm 1.5mm 1.2mm; +} +CWTableCell { + background-color: transparent; + padding: 0.2mm 0.4mm 0.2mm 0.4mm; +} +CWCellMain { + background-color: transparent; + color: #112247; + font-family: "native:MainBold"; + font-size: 2.65mm; +} +CWCellSub { + background-color: transparent; + color: #7F8AA3; + font-size: 2.25mm; +} +CWPillOk { + background-color: #E7F4EC; + color: #2E8B57; + padding: 0.8mm 1.9mm 0.8mm 1.9mm; + border-radius: 1.4mm; + font-family: "native:MainBold"; + font-size: 2.2mm; +} +CWPillWarn { + background-color: #FDF1DC; + color: #B26A00; + padding: 0.8mm 1.9mm 0.8mm 1.9mm; + border-radius: 1.4mm; + font-family: "native:MainBold"; + font-size: 2.2mm; +} +CWPillBad { + background-color: #FBE9E9; + color: #D64545; + padding: 0.8mm 1.9mm 0.8mm 1.9mm; + border-radius: 1.4mm; + font-family: "native:MainBold"; + font-size: 2.2mm; +} +CWPillMuted { + background-color: #F7F8FB; + color: #7F8AA3; + padding: 0.8mm 1.9mm 0.8mm 1.9mm; + border-radius: 1.4mm; + font-family: "native:MainBold"; + font-size: 2.2mm; +} +CWBanner { + background-color: #E8F0FF; + color: #112247; + border-radius: 1.8mm; + padding: 2.0mm; + margin: 0 0 2.2mm 0; +} +CWBannerWarn { + background-color: #FDF1DC; + color: #112247; + border: 0.25mm solid #F0D9A8; + border-radius: 1.8mm; + padding: 2.0mm; + margin: 0 0 2.2mm 0; +} +CWModal { + background-color: #FFFFFF; + border: 0.25mm solid #D9DEE8; + border-radius: 2.2mm; + padding: 2.6mm; +} +CWDialogContent { + background-color: transparent; + padding: 0; + margin: 0; +} +CWModalTitle { + background-color: transparent; + color: #112247; + font-family: "native:MainBold"; + font-size: 3.4mm; + padding: 0 0 1.4mm 0; +} +CWDialogActions { + background-color: transparent; + padding: 1.2mm 0 0 0; + margin: 1.0mm 0 0 0; +} +CWChoice { + background-color: #FFFFFF; + color: #112247; + border: 0.25mm solid #D9DEE8; + border-radius: 1.8mm; + padding: 1.8mm; + margin: 0.7mm 0 0.7mm 0; + font-size: 2.55mm; +} +CWChoiceSelected { + background-color: #E8F0FF; + color: #112247; + border: 0.25mm solid #2F6BFF; + border-radius: 1.8mm; + padding: 1.8mm; + margin: 0.7mm 0 0.7mm 0; + font-size: 2.55mm; +} +CWSegment { + background: cn1-pill-border; + background-color: #FFFFFF; + color: #112247; + border: 0.25mm solid #D9DEE8; + border-radius: 3.2mm; + padding: 1.15mm 2.4mm 1.15mm 2.4mm; + margin: 0.25mm; + font-family: "native:MainBold"; + font-size: 2.35mm; + text-align: center; +} +CWSegmentSelected { + background: cn1-pill-border; + background-color: #2F6BFF; + color: #FFFFFF; + border-radius: 3.2mm; + padding: 1.15mm 2.4mm 1.15mm 2.4mm; + margin: 0.25mm; + font-family: "native:MainBold"; + font-size: 2.35mm; + text-align: center; +} + +CWDarkToggle { + background-color: #FFFFFF; + color: #112247; + border: 0.25mm solid #D9DEE8; + border-radius: 1.6mm; + padding: 1.0mm 2.0mm 1.0mm 2.0mm; + margin: 0.3mm; + font-family: "native:MainBold"; + font-size: 2.45mm; + text-align: center; +} + +DarkForm, DarkCWForm { + background-color: #071B4D; + color: #F5F8FF; + font-family: "native:MainRegular"; + font-size: 2.75mm; +} +DarkLabel, DarkButton, DarkTextField, DarkTextArea, DarkCheckBox { + color: #F5F8FF; + font-family: "native:MainRegular"; + font-size: 2.75mm; +} +DarkCWChrome { + background-color: #102B66; + border-bottom: 0.25mm solid #33518C; + padding: 1.5mm 2.8mm 1.5mm 2.8mm; +} +DarkCWTitle { + background-color: transparent; + color: #F5F8FF; + font-family: "native:MainBold"; + font-size: 3.0mm; + padding: 0.6mm 1.5mm 0.6mm 1.5mm; +} +DarkCWLogo { + background-color: transparent; + color: #F5F8FF; + font-family: "native:MainBold"; + font-size: 3.1mm; + padding: 0.8mm 1mm 0.8mm 0; +} +DarkCWStatus { + background: cn1-pill-border; + background-color: #143B66; + color: #5FD08A; + padding: 1.0mm 2.5mm 1.0mm 2.5mm; + margin: 0.3mm; + font-family: "native:MainBold"; + font-size: 2.45mm; +} +DarkCWStatusOff { + background: cn1-pill-border; + background-color: #173A7A; + color: #A8B8DA; + padding: 1.0mm 2.5mm 1.0mm 2.5mm; + margin: 0.3mm; + font-family: "native:MainBold"; + font-size: 2.45mm; +} +DarkCWToolbarButton { + background-color: #102B66; + color: #F5F8FF; + border: 0.25mm solid #33518C; + border-radius: 1.6mm; + padding: 1.2mm; + margin: 0.45mm; +} +DarkCWToolbarActions { + background-color: transparent; + padding: 0; + margin: 0; +} +DarkCWEmail { + background-color: transparent; + color: #A8B8DA; + font-size: 2.45mm; + padding: 1mm; +} +DarkCWBody { + background-color: #071B4D; +} +DarkCWSidebar { + background-color: #102B66; + border-right: 0.25mm solid #33518C; + padding: 2.0mm 1.5mm 2.0mm 1.5mm; +} +DarkCWNavLabel { + background-color: transparent; + color: #7E93BC; + font-family: "native:MainBold"; + font-size: 2.1mm; + padding: 2.2mm 1.4mm 0.8mm 1.4mm; +} +DarkCWNav { + background-color: #102B66; + color: #A8B8DA; + padding: 1.7mm 1.8mm 1.7mm 1.8mm; + margin: 0.25mm 0 0.25mm 0; + border-radius: 1.6mm; + font-size: 2.65mm; +} +DarkCWNavSelected { + background-color: #173A7A; + color: #4D86FF; + padding: 1.7mm 1.8mm 1.7mm 1.8mm; + margin: 0.25mm 0 0.25mm 0; + border-radius: 1.6mm; + font-family: "native:MainBold"; + font-size: 2.65mm; +} +DarkCWPage { + background-color: #071B4D; + padding: 4.0mm 5.0mm 6.0mm 5.0mm; +} +DarkCWPageTitle { + background-color: transparent; + color: #F5F8FF; + font-family: "native:MainBold"; + font-size: 4.5mm; + padding: 0 0 0.5mm 0; +} +DarkCWSub { + background-color: transparent; + color: #A8B8DA; + font-size: 2.55mm; + padding: 0 0 2.2mm 0; +} +DarkCWCard { + background-color: #102B66; + border: 0.25mm solid #33518C; + border-radius: 1.8mm; + padding: 2.4mm; + margin: 1.0mm 0 1.4mm 0; +} +DarkCWCardTitle { + background-color: transparent; + color: #F5F8FF; + font-family: "native:MainBold"; + font-size: 2.8mm; + padding: 0.5mm 0 0.4mm 0; +} +DarkCWCardMeta { + background-color: transparent; + color: #A8B8DA; + font-size: 2.35mm; + padding: 0.2mm 0 0.2mm 0; +} +DarkCWCardRow { + background-color: #102B66; + border-top: 0.25mm solid #33518C; + padding: 1.0mm 0 1.0mm 0; + margin: 0; +} +DarkCWMetric { + background-color: #102B66; + border: 0.25mm solid #33518C; + border-radius: 1.8mm; + padding: 2.5mm; + margin: 0.8mm; +} +DarkCWMetricNumber { + background-color: transparent; + color: #F5F8FF; + font-family: "native:MainBold"; + font-size: 5.2mm; +} +DarkCWMetricLabel { + background-color: transparent; + color: #A8B8DA; + font-size: 2.35mm; + padding: 0.4mm 0 0 0; +} +DarkCWActionRow { + background-color: transparent; + padding: 0; + margin: 0.8mm 0 0.8mm 0; +} +DarkCWActionGrid { + background-color: transparent; + padding: 0; + margin: 0; +} +DarkCWPrimary { + background: cn1-pill-border; + background-color: #B8D532; + color: #FFFFFF; + padding: 1.35mm 3.1mm 1.35mm 3.1mm; + margin: 0.4mm; + font-family: "native:MainBold"; + font-size: 2.45mm; + border-radius: 3.2mm; + text-align: center; +} +DarkCWAccent { + background: cn1-pill-border; + background-color: #4D86FF; + color: #FFFFFF; + padding: 1.35mm 3.1mm 1.35mm 3.1mm; + margin: 0.4mm; + font-family: "native:MainBold"; + font-size: 2.45mm; + border-radius: 3.2mm; + text-align: center; +} +DarkCWOutline { + background: cn1-pill-border; + background-color: #102B66; + color: #F5F8FF; + border: 0.25mm solid #33518C; + border-radius: 3.2mm; + padding: 1.25mm 2.8mm 1.25mm 2.8mm; + margin: 0.4mm; + font-family: "native:MainBold"; + font-size: 2.45mm; + text-align: center; +} +DarkCWDanger { + background: cn1-pill-border; + background-color: #102B66; + color: #FF7A7A; + border: 0.25mm solid #33518C; + border-radius: 3.2mm; + padding: 1.25mm 2.8mm 1.25mm 2.8mm; + margin: 0.4mm; + font-family: "native:MainBold"; + font-size: 2.45mm; + text-align: center; +} +DarkCWDisabled { + background: cn1-pill-border; + background-color: #163575; + color: #7890C0; + border: 0.25mm solid #33518C; + border-radius: 3.2mm; + padding: 1.35mm 3.1mm 1.35mm 3.1mm; + margin: 0.4mm; + font-family: "native:MainBold"; + font-size: 2.45mm; + text-align: center; +} +DarkCWField { + background-color: #0E2A61; + color: #F5F8FF; + border: 0.25mm solid #33518C; + border-radius: 1.4mm; + padding: 1.4mm; + margin: 0.5mm 0 1.2mm 0; +} +DarkCWFieldHint { + background-color: transparent; + color: #A8B8DA; + padding: 1.4mm; + font-size: 2.75mm; +} +DarkCWFieldLabel { + background-color: transparent; + color: #A8B8DA; + font-size: 2.35mm; + padding: 0.7mm 0 0.4mm 0; +} +DarkCWFilterRow { + background-color: transparent; + padding: 0 0 1.2mm 0; +} +DarkCWFilterWrap { + background-color: #0E2A61; + border: 0.25mm solid #33518C; + border-radius: 1.4mm; + padding: 0; + margin: 0.4mm 0.8mm 0.4mm 0; +} +DarkCWFilterField { + background-color: #0E2A61; + color: #F5F8FF; + border: none; + padding: 1.1mm 1.4mm 1.1mm 1.4mm; + margin: 0; + font-size: 2.45mm; +} +DarkCWFilterClear { + background-color: transparent; + color: #A8B8DA; + padding: 1.0mm; + margin: 0; +} +DarkCWTableBody { + background-color: transparent; + padding: 0; + margin: 0; +} +DarkCWTableHeader { + background-color: transparent; + color: #A8B8DA; + font-family: "native:MainBold"; + font-size: 2.1mm; + padding: 1.0mm 1.2mm 1.2mm 1.2mm; +} +DarkCWRow { + background-color: #102B66; + border-top: 0.25mm solid #33518C; + padding: 1.5mm 1.2mm 1.5mm 1.2mm; +} +DarkCWTableCell { + background-color: transparent; + padding: 0.2mm 0.4mm 0.2mm 0.4mm; +} +DarkCWCellMain { + background-color: transparent; + color: #F5F8FF; + font-family: "native:MainBold"; + font-size: 2.65mm; +} +DarkCWCellSub { + background-color: transparent; + color: #A8B8DA; + font-size: 2.25mm; +} +DarkCWPillOk { + background-color: #143B66; + color: #5FD08A; + padding: 0.8mm 1.9mm 0.8mm 1.9mm; + border-radius: 1.4mm; + font-family: "native:MainBold"; + font-size: 2.2mm; +} +DarkCWPillWarn { + background-color: #173A7A; + color: #A8B8DA; + padding: 0.8mm 1.9mm 0.8mm 1.9mm; + border-radius: 1.4mm; + font-family: "native:MainBold"; + font-size: 2.2mm; +} +DarkCWPillBad { + background-color: #3A2442; + color: #FF7A7A; + padding: 0.8mm 1.9mm 0.8mm 1.9mm; + border-radius: 1.4mm; + font-family: "native:MainBold"; + font-size: 2.2mm; +} +DarkCWPillMuted { + background-color: #163575; + color: #A8B8DA; + padding: 0.8mm 1.9mm 0.8mm 1.9mm; + border-radius: 1.4mm; + font-family: "native:MainBold"; + font-size: 2.2mm; +} +DarkCWBanner { + background-color: #173A7A; + color: #F5F8FF; + border-radius: 1.8mm; + padding: 2.0mm; + margin: 0 0 2.2mm 0; +} +DarkCWBannerWarn { + background-color: #34355E; + color: #E0A94E; + border: 0.25mm solid #735F38; + border-radius: 1.8mm; + padding: 2.0mm; + margin: 0 0 2.2mm 0; +} +DarkCWModal { + background-color: #102B66; + border: 0.25mm solid #33518C; + border-radius: 2.2mm; + padding: 2.6mm; +} +DarkCWDialogContent { + background-color: transparent; + padding: 0; + margin: 0; +} +DarkCWModalTitle { + background-color: transparent; + color: #F5F8FF; + font-family: "native:MainBold"; + font-size: 3.4mm; + padding: 0 0 1.4mm 0; +} +DarkCWDialogActions { + background-color: transparent; + padding: 1.2mm 0 0 0; + margin: 1.0mm 0 0 0; +} +DarkCWChoice { + background-color: #102B66; + color: #F5F8FF; + border: 0.25mm solid #33518C; + border-radius: 1.8mm; + padding: 1.8mm; + margin: 0.7mm 0 0.7mm 0; + font-size: 2.55mm; +} +DarkCWChoiceSelected { + background-color: #173A7A; + color: #F5F8FF; + border: 0.25mm solid #33518C; + border-radius: 1.8mm; + padding: 1.8mm; + margin: 0.7mm 0 0.7mm 0; + font-size: 2.55mm; +} +DarkCWSegment { + background: cn1-pill-border; + background-color: #102B66; + color: #F5F8FF; + border: 0.25mm solid #33518C; + border-radius: 3.2mm; + padding: 1.15mm 2.4mm 1.15mm 2.4mm; + margin: 0.25mm; + font-family: "native:MainBold"; + font-size: 2.35mm; + text-align: center; +} +DarkCWSegmentSelected { + background: cn1-pill-border; + background-color: #4D86FF; + color: #FFFFFF; + border-radius: 3.2mm; + padding: 1.15mm 2.4mm 1.15mm 2.4mm; + margin: 0.25mm; + font-family: "native:MainBold"; + font-size: 2.35mm; + text-align: center; +} +DarkCWDarkToggle { + background-color: #102B66; + color: #F5F8FF; + border: 0.25mm solid #33518C; + border-radius: 1.6mm; + padding: 1.0mm 2.0mm 1.0mm 2.0mm; + margin: 0.3mm; + font-family: "native:MainBold"; + font-size: 2.45mm; + text-align: center; +} diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java new file mode 100644 index 00000000000..c873670bc52 --- /dev/null +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java @@ -0,0 +1,2606 @@ +package com.codename1.certificatewizard; + +import com.codename1.certificatewizard.api.CloudSigningService; +import com.codename1.certificatewizard.api.MockSigningService; +import com.codename1.certificatewizard.api.SigningService; +import com.codename1.certificatewizard.api.SigningState; +import com.codename1.certificatewizard.api.WizardDecisions; +import com.codename1.certificatewizard.project.ProjectBinding; +import com.codename1.certificatewizard.project.ProjectIO; +import com.codename1.certificatewizard.project.AndroidKeystoreProvider; +import com.codename1.certificatewizard.project.SigningAssetInstaller; +import com.codename1.components.InteractionDialog; +import com.codename1.components.ToastBar; +import com.codename1.io.FileSystemStorage; +import com.codename1.io.Log; +import com.codename1.io.NetworkEvent; +import com.codename1.io.Preferences; +import com.codename1.io.Util; +import com.codename1.system.Lifecycle; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.CheckBox; +import com.codename1.ui.Command; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Font; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.Toolbar; +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 java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +public class CertificateWizard extends Lifecycle { + public enum Section { OVERVIEW, CREDENTIAL, CERTIFICATES, BUNDLES, DEVICES, PROFILES, APNS, MAC, ANDROID, WINDOWS, MAINTENANCE } + + private static final String ASC_API_KEYS_URL = "https://appstoreconnect.apple.com/access/integrations/api"; + private static final String PREF_DARK_MODE = "certificatewizard.darkMode"; + private static final String PREF_FONT_DELTA = "certificatewizard.fontDeltaPx"; + private static final String PREF_CREDENTIAL_CONFIGURED = "certificatewizard.credentialConfigured"; + private static final String PREF_CREDENTIAL_KEY_ID = "certificatewizard.credentialKeyId"; + private static final String PREF_CREDENTIAL_ISSUER_ID = "certificatewizard.credentialIssuerId"; + private static final String PROFILE_DEVELOPMENT = "IOS_APP_DEVELOPMENT"; + private static final String PROFILE_APP_STORE = "IOS_APP_STORE"; + private static final String PROFILE_MAC_STORE = "MAC_APP_STORE"; + private static final String PROFILE_MAC_DIRECT = "MAC_APP_DIRECT"; + + private static SigningService injectedService; + private static AndroidKeystoreProvider androidKeystoreProvider; + private static CertificateWizard activeWizard; + private static boolean edtErrorHandlerInstalled; + private static boolean networkErrorHandlerInstalled; + + private SigningService service; + private SigningState state = SigningState.empty(); + private Section section = Section.OVERVIEW; + private Form form; + private Container page; + private ProjectBinding binding; + private String userEmail = ""; + private String token = ""; + private String latestCertificatePath = ""; + private String latestCertificatePassword = ""; + private String latestProfilePath = ""; + private boolean latestAssetsDebug; + private boolean darkMode; + private int fontDeltaPx; + private boolean assumedCredentialConfigured; + private float fontPinchAccumulator = 1f; + private final String[] tableFilters = new String[Section.values().length]; + private final int[] tableSortColumns = new int[Section.values().length]; + private final boolean[] tableSortAscending = new boolean[Section.values().length]; + private Container messageHost; + private String pageMessage = ""; + private boolean pageMessageWarn; + + public static void setServiceForTesting(SigningService s) { + injectedService = s; + } + + public static void setAndroidKeystoreProvider(AndroidKeystoreProvider provider) { + androidKeystoreProvider = provider; + } + + public static void adjustActiveFontSizeForDesktopShortcut(int deltaPx) { + CertificateWizard wizard = activeWizard; + if (wizard != null) { + CN.callSerially(() -> wizard.adjustFontSize(deltaPx)); + } + } + + @Override + public void runApp() { + installEdtErrorHandler(); + initTableState(); + Toolbar.setGlobalToolbar(true); + darkMode = Preferences.get(PREF_DARK_MODE, Boolean.TRUE.equals(CN.isDarkMode())); + fontDeltaPx = Preferences.get(PREF_FONT_DELTA, 0); + CN.setDarkMode(Boolean.valueOf(darkMode)); + binding = ProjectIO.loadBinding(); + userEmail = firstNonEmpty(System.getProperty("certificatewizard.user"), + binding == null ? null : binding.user(), "Not signed in"); + token = firstNonEmpty(System.getProperty("certificatewizard.token"), binding == null ? null : binding.token(), ""); + seedCredentialState(); + + if (injectedService != null) { + service = injectedService; + } else if ("true".equals(firstNonEmpty(System.getProperty("certificatewizard.mock"), null, "false"))) { + service = new MockSigningService(); + } else { + String baseUrl = firstNonEmpty(System.getProperty("certificatewizard.baseUrl"), + binding == null ? null : binding.baseUrl(), CloudSigningService.DEFAULT_BASE_URL); + String out = binding == null ? null : binding.outputDir(); + service = new CloudSigningService(baseUrl, token, out); + } + + form = new Form("Codename One Certificate Wizard", 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("CWForm")); + form.getToolbar().setUIID(uiid("CWChrome")); + form.getTextSelection().setEnabled(true); + installMenuCommands(); + buildShell(); + form.show(); + reload(); + } + + private void seedCredentialState() { + boolean configured = Preferences.get(PREF_CREDENTIAL_CONFIGURED, token != null && token.length() > 0); + if (!configured) { + return; + } + assumedCredentialConfigured = true; + String keyId = Preferences.get(PREF_CREDENTIAL_KEY_ID, "Stored"); + String issuerId = Preferences.get(PREF_CREDENTIAL_ISSUER_ID, ""); + state = new SigningState(new SigningState.Credential(true, keyId, issuerId), null, null, null, null, null); + } + + private void initTableState() { + for (int i = 0; i < tableSortAscending.length; i++) { + tableSortAscending[i] = true; + } + } + + private void installEdtErrorHandler() { + activeWizard = this; + if (edtErrorHandlerInstalled) { + return; + } + edtErrorHandlerInstalled = true; + networkErrorHandlerInstalled = true; + CN.addNetworkErrorListener(evt -> { + evt.consume(); + CertificateWizard wizard = activeWizard; + if (wizard != null) { + if (CN.isEdt()) { + wizard.handleNetworkError(evt); + } else { + CN.callSerially(() -> wizard.handleNetworkError(evt)); + } + } + }); + CN.addEdtErrorHandler(evt -> { + evt.consume(); + Throwable err = evt.getSource() instanceof Throwable ? (Throwable)evt.getSource() : null; + if (err != null) { + Log.e(err); + } + CertificateWizard wizard = activeWizard; + if (wizard != null) { + wizard.showUnhandledEdtError(err); + } + }); + } + + void showUnhandledEdtError(Throwable err) { + String message = "An internal application error occurred."; + if (err != null) { + message = friendlyMessage(err); + } + showPageMessage(message, true); + } + + public Form getForm() { + return form; + } + + public SigningState getState() { + return state; + } + + public Section getSection() { + return section; + } + + static boolean isEdtErrorHandlerInstalledForTesting() { + return edtErrorHandlerInstalled; + } + + static boolean isNetworkErrorHandlerInstalledForTesting() { + return networkErrorHandlerInstalled; + } + + @Override + protected void handleNetworkError(NetworkEvent err) { + err.consume(); + if (err.getError() != null) { + Log.e(err.getError()); + } + String message = err.getMessage(); + if (message == null || message.trim().isEmpty()) { + message = err.getError() == null ? null : err.getError().getMessage(); + } + if (message == null || message.trim().isEmpty()) { + message = "Network request failed"; + } + if (err.getResponseCode() > 0) { + message = "Codename One cloud request failed (HTTP " + err.getResponseCode() + "): " + message; + } + showPageMessage(message, true); + } + + public void refreshUI() { + buildShell(); + } + + private void buildShell() { + form.removeAll(); + form.setUIID(uiid("CWForm")); + form.getToolbar().setUIID(uiid("CWChrome")); + form.add(BorderLayout.NORTH, topBar()); + form.add(BorderLayout.WEST, sidebar()); + page = new Container(BoxLayout.y()); + page.setScrollableY(true); + page.setUIID(uiid("CWPage")); + form.add(BorderLayout.CENTER, page); + renderPage(); + applyFontScale(form); + form.revalidate(); + } + + private Container topBar() { + Container bar = new Container(new BorderLayout()); + bar.setUIID(uiid("CWChrome")); + Container left = new Container(new FlowLayout(Component.LEFT)); + Label logo = new Label("Codename One"); + logo.setUIID(uiid("CWLogo")); + logo.setTextSelectionEnabled(true); + Label title = new Label("Signing"); + title.setUIID(uiid("CWTitle")); + title.setTextSelectionEnabled(true); + left.add(logo).add(title); + Container right = new Container(new FlowLayout(Component.RIGHT)); + right.setUIID(uiid("CWToolbarActions")); + Button status = new Button(state.credential.configured() ? "ASC API Key configured" : "ASC API Key missing"); + status.setUIID(uiid(state.credential.configured() ? "CWStatus" : "CWStatusOff")); + status.setName("pill.credential"); + status.addActionListener(e -> go(Section.CREDENTIAL)); + Label email = new Label(userEmail); + email.setUIID(uiid("CWEmail")); + email.setTextSelectionEnabled(true); + Button refresh = iconButton(FontImage.MATERIAL_REFRESH, "Refresh", "btn.refresh"); + refresh.addActionListener(e -> reload()); + Button dark = iconButton(darkMode ? FontImage.MATERIAL_WB_SUNNY : FontImage.MATERIAL_BRIGHTNESS_3, + darkMode ? "Switch to light mode" : "Switch to dark mode", "toggle.darkMode"); + dark.setUIID(uiid("CWDarkToggle")); + dark.addActionListener(e -> setDarkMode(!darkMode)); + right.add(status); + if (state.credential.configured()) { + Button auto = button("Auto Setup", "btn.autoSetup", "CWStatusOff"); + FontImage.setMaterialIcon(auto, FontImage.MATERIAL_BOLT, 2.6f); + auto.addActionListener(e -> autoSetupCurrentProject()); + right.add(auto); + } + right.add(email).add(dark).add(refresh); + bar.add(BorderLayout.WEST, left); + bar.add(BorderLayout.EAST, right); + return bar; + } + + private Container sidebar() { + Container side = new Container(BoxLayout.y()); + side.setUIID(uiid("CWSidebar")); + label(side, "WORKSPACE", "CWNavLabel"); + nav(side, Section.OVERVIEW, FontImage.MATERIAL_DASHBOARD, "Overview", null); + nav(side, Section.CREDENTIAL, FontImage.MATERIAL_VPN_KEY, "ASC API Key", null); + label(side, "APPLE SIGNING", "CWNavLabel"); + nav(side, Section.CERTIFICATES, FontImage.MATERIAL_CARD_MEMBERSHIP, "Certificates", state.certificates.size()); + nav(side, Section.BUNDLES, FontImage.MATERIAL_APPS, "Bundle IDs", state.bundleIds.size()); + nav(side, Section.DEVICES, FontImage.MATERIAL_PHONE_IPHONE, "Devices", state.devices.size()); + nav(side, Section.PROFILES, FontImage.MATERIAL_DESCRIPTION, "Profiles", state.profiles.size()); + label(side, "PUSH", "CWNavLabel"); + nav(side, Section.APNS, FontImage.MATERIAL_NOTIFICATIONS, "APNs Keys", state.apnsKeys.size()); + label(side, "OTHER PLATFORMS", "CWNavLabel"); + nav(side, Section.MAC, FontImage.MATERIAL_LAPTOP_MAC, "Mac", null); + nav(side, Section.ANDROID, FontImage.MATERIAL_ANDROID, "Android", null); + nav(side, Section.WINDOWS, FontImage.MATERIAL_DESKTOP_WINDOWS, "Windows", null); + label(side, "MAINTENANCE", "CWNavLabel"); + nav(side, Section.MAINTENANCE, FontImage.MATERIAL_DELETE_SWEEP, "Clear Signing Data", null); + return side; + } + + private void nav(Container side, Section target, char icon, String text, Integer count) { + Button b = new Button(count == null ? text : text + " " + count); + b.setName("nav." + target.name().toLowerCase()); + b.setUIID(uiid(section == target ? "CWNavSelected" : "CWNav")); + FontImage.setMaterialIcon(b, icon, 3.2f); + b.addActionListener(e -> go(target)); + side.add(b); + } + + private void renderPage() { + page.removeAll(); + switch (section) { + case CREDENTIAL -> credentialPage(); + case CERTIFICATES -> certificatesPage(); + case BUNDLES -> bundlesPage(); + case DEVICES -> devicesPage(); + case PROFILES -> profilesPage(); + case APNS -> apnsPage(); + case MAC -> macPage(); + case ANDROID -> androidPage(); + case WINDOWS -> windowsPage(); + case MAINTENANCE -> maintenancePage(); + default -> overviewPage(); + } + } + + private void overviewPage() { + pageHead("Overview", "Manage signing assets for Apple, Android and desktop builds from the current project."); + if (!state.credential.configured()) { + Container b = new Container(new BorderLayout()); + b.setUIID(uiid("CWBanner")); + Container text = new Container(BoxLayout.y()); + label(text, "Connect your App Store Connect API key to begin", "CWCardTitle"); + Label copy = new Label("Certificates, bundle IDs, devices and profiles all talk to Apple through this key."); + copy.setUIID(uiid("CWCardMeta")); + copy.setTextSelectionEnabled(true); + text.add(copy); + b.add(BorderLayout.CENTER, text); + Button connect = primary("Connect key", "btn.connectKey"); + connect.addActionListener(e -> go(Section.CREDENTIAL)); + b.add(BorderLayout.EAST, connect); + page.add(b); + } else if (state.expiringCount(System.currentTimeMillis()) > 0) { + banner(state.expiringCount(System.currentTimeMillis()) + " signing asset(s) expire within 30 days.", true); + } + label(page, "SETUP STATUS", "CWNavLabel"); + Container setup = new Container(new GridLayout(1, 3)); + setupCard(setup, "ASC API Key", state.credential.configured() ? "Connected" : "Not configured", + state.credential.configured() ? state.credential.keyId() : "-"); + setupCard(setup, "Apple distribution certificate", + state.certificates.isEmpty() ? "None active" : "Ready", + state.certificates.isEmpty() ? "-" : state.certificates.get(0).displayName()); + setupCard(setup, "App Store profile", + state.profiles.isEmpty() ? "None yet" : "Ready", + state.profiles.isEmpty() ? "-" : state.profiles.get(0).name()); + page.add(setup); + label(page, "YOUR ASSETS", "CWNavLabel"); + Container metrics = new Container(new GridLayout(1, 4)); + metric(metrics, "" + state.certificates.size(), "Certificates", () -> go(Section.CERTIFICATES)); + metric(metrics, "" + state.bundleIds.size(), "Bundle IDs", () -> go(Section.BUNDLES)); + metric(metrics, "" + state.devices.size(), "Devices", () -> go(Section.DEVICES)); + metric(metrics, "" + state.profiles.size(), "Profiles", () -> go(Section.PROFILES)); + page.add(metrics); + Button profile = primary("New profile", "btn.newProfile"); + profile.addActionListener(e -> newProfileDialog()); + Button cert = outline("Generate certificate", "btn.generateCert"); + cert.addActionListener(e -> certificateDialog()); + Button sync = outline("Sync with Apple", "btn.reconcile"); + sync.addActionListener(e -> service.reconcile(r -> afterMutation(r, "Synced with Apple"))); + page.add(actionRow(Component.LEFT, profile, cert, sync)); + } + + private void setupCard(Container parent, String title, String meta, String value) { + Container c = card(); + label(c, title, "CWCardTitle"); + label(c, meta, "CWCardMeta"); + label(c, value, "CWCellMain"); + parent.add(c); + } + + private void credentialPage() { + pageHead("ASC API Key", "The .p8 key is stored by the cloud service and is never returned by the API."); + Container instructions = card(); + label(instructions, "Create an App Store Connect API key", "CWCardTitle"); + label(instructions, "1. Open Users and Access > Integrations > App Store Connect API.", "CWCardMeta"); + label(instructions, "2. If prompted, request API access and accept Apple's terms.", "CWCardMeta"); + label(instructions, "3. On Team Keys, generate a key with Admin access.", "CWCardMeta"); + label(instructions, "4. Copy the Issuer ID and Key ID, then download the .p8 file. Apple only allows one download.", "CWCardMeta"); + Button openAsc = primary("Create .p8 key in App Store Connect", "btn.openAscApiKeys"); + openAsc.addActionListener(e -> openAscApiKeys()); + instructions.add(openAsc); + page.add(instructions); + + Container card = card(); + if (state.credential.configured()) { + row(card, "Key ID", state.credential.keyId(), null); + row(card, "Issuer ID", state.credential.issuerId(), null); + } else { + label(card, "No App Store Connect API key stored.", "CWCardMeta"); + } + TextField key = field("Key ID", "ABCD1234EF"); + key.setName("field.ascKeyId"); + TextField issuer = field("Issuer ID", "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"); + issuer.setName("field.ascIssuerId"); + TextArea p8 = new TextArea(""); + p8.setHint("-----BEGIN PRIVATE KEY-----"); + prepareP8TextArea(p8, 6); + p8.setName("field.ascP8"); + label(card, "Key ID", "CWFieldLabel"); + label(card, "Shown in the generated key row in App Store Connect.", "CWCardMeta"); + card.add(key); + label(card, "Issuer ID", "CWFieldLabel"); + label(card, "Shown above the Team Keys table on the App Store Connect API page.", "CWCardMeta"); + card.add(issuer); + label(card, "Private key .p8", "CWFieldLabel"); + label(card, "Import the downloaded .p8 file or paste its full contents, including BEGIN and END PRIVATE KEY.", "CWCardMeta"); + Button importP8 = outline("Import .p8 file", "btn.importAscP8"); + importP8.addActionListener(e -> importP8(p8)); + card.add(importP8); + card.add(p8); + Button save = primary("Store key", "btn.saveCredential"); + save.addActionListener(e -> service.saveCredential(key.getText(), issuer.getText(), p8.getText(), + r -> { + if (r.ok) { + state = new SigningState(new SigningState.Credential(true, key.getText(), issuer.getText()), + state.certificates, state.bundleIds, state.devices, state.profiles, state.apnsKeys); + storeCredentialState(); + } + afterMutation(r, "Credential stored"); + })); + Button del = danger("Delete key", "btn.deleteCredential"); + del.addActionListener(e -> confirm("Delete API key", "Remove the stored App Store Connect API key from the cloud service?", + "Delete", () -> service.deleteCredential(r -> { + if (r.ok) { + state = new SigningState(new SigningState.Credential(false, null, null), + state.certificates, state.bundleIds, state.devices, state.profiles, state.apnsKeys); + storeCredentialState(); + } + afterMutation(r, "Credential deleted"); + }))); + card.add(actionRow(Component.LEFT, save, del)); + page.add(card); + } + + private void installMenuCommands() { + form.getToolbar().addCommandToOverflowMenu(menuCommand("Refresh", Command.DESKTOP_MENU_FILE, 'R', () -> reload())); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Auto Setup", Command.DESKTOP_MENU_FILE, 'A', () -> autoSetupCurrentProject())); + form.getToolbar().addCommandToOverflowMenu(menuCommand("ASC API Key", Command.DESKTOP_MENU_VIEW, 'K', () -> go(Section.CREDENTIAL))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Overview", Command.DESKTOP_MENU_VIEW, '1', () -> go(Section.OVERVIEW))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Certificates", Command.DESKTOP_MENU_VIEW, '2', () -> go(Section.CERTIFICATES))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Profiles", Command.DESKTOP_MENU_VIEW, '3', () -> go(Section.PROFILES))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Mac Signing", Command.DESKTOP_MENU_VIEW, '4', () -> go(Section.MAC))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Android Signing", Command.DESKTOP_MENU_VIEW, '5', () -> go(Section.ANDROID))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Windows Signing", Command.DESKTOP_MENU_VIEW, '6', () -> go(Section.WINDOWS))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Clear Signing Data", Command.DESKTOP_MENU_FILE, 'L', () -> go(Section.MAINTENANCE))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Toggle Dark Mode", Command.DESKTOP_MENU_VIEW, 'D', () -> setDarkMode(!darkMode))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Increase Font Size", Command.DESKTOP_MENU_VIEW, '+', () -> adjustFontSize(2))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Decrease Font Size", Command.DESKTOP_MENU_VIEW, '-', () -> adjustFontSize(-2))); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Reset Font Size", Command.DESKTOP_MENU_VIEW, '0', () -> resetFontSize())); + form.getToolbar().addCommandToOverflowMenu(menuCommand("Open App Store Connect API Keys", Command.DESKTOP_MENU_HELP, 'O', () -> openAscApiKeys())); + } + + private Command menuCommand(String name, String menu, char shortcut, Runnable action) { + Command cmd = new Command(name) { + @Override + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + action.run(); + } + }; + cmd.setDesktopMenu(menu); + cmd.setDesktopShortcut(shortcut); + return cmd; + } + + private void openAscApiKeys() { + Display.getInstance().execute(ASC_API_KEYS_URL); + } + + private void importP8(TextArea target) { + CN.openFileChooser(e -> { + if (e == null || e.getSource() == null) { + return; + } + String path = (String)e.getSource(); + InputStream in = null; + try { + in = FileSystemStorage.getInstance().openInputStream(path); + String contents = Util.readToString(in, "UTF-8").trim(); + if (contents.length() == 0) { + ToastBar.showErrorMessage("The selected .p8 file is empty"); + return; + } + target.setText(contents); + target.repaint(); + form.revalidate(); + ToastBar.showMessage("Loaded .p8 file", FontImage.MATERIAL_CHECK); + } catch (Exception ex) { + Log.e(ex); + ToastBar.showErrorMessage("Failed to read .p8 file: " + ex.getMessage()); + } finally { + Util.cleanup(in); + } + }, "p8"); + } + + private String friendlyMessage(Throwable ex) { + Throwable t = ex; + while (t.getCause() != null) { + t = t.getCause(); + } + return t.getMessage() == null || t.getMessage().trim().isEmpty() ? t.getClass().getSimpleName() : t.getMessage().trim(); + } + + private void setDarkMode(boolean dark) { + if (darkMode == dark) { + return; + } + darkMode = dark; + CN.setDarkMode(Boolean.valueOf(darkMode)); + Preferences.set(PREF_DARK_MODE, darkMode); + UIManager.getInstance().refreshTheme(); + buildShell(); + form.refreshTheme(false); + applyFontScale(form); + form.revalidate(); + } + + private void adjustFontSize(int deltaPx) { + fontDeltaPx += deltaPx; + if (fontDeltaPx > 8) { + fontDeltaPx = 8; + } else if (fontDeltaPx < -4) { + fontDeltaPx = -4; + } + Preferences.set(PREF_FONT_DELTA, fontDeltaPx); + buildShell(); + form.revalidate(); + } + + 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; + } + 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 resetFontSize() { + fontDeltaPx = 0; + Preferences.set(PREF_FONT_DELTA, fontDeltaPx); + buildShell(); + form.revalidate(); + } + + 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; + } + float baseMm = baseFontMm(c.getUIID()); + int size = Display.getInstance().convertToPixels(baseMm) + 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 ("CWMetricNumber".equals(id)) { + return 5.2f; + } + if ("CWPageTitle".equals(id)) { + return 4.5f; + } + if ("CWModalTitle".equals(id)) { + return 3.4f; + } + if ("CWLogo".equals(id)) { + return 3.1f; + } + if ("CWTitle".equals(id)) { + return 3.0f; + } + if ("CWCardTitle".equals(id)) { + return 2.8f; + } + if ("CWNav".equals(id) || "CWNavSelected".equals(id) || "CWCellMain".equals(id)) { + return 2.65f; + } + if ("CWSub".equals(id)) { + return 2.55f; + } + if ("CWStatus".equals(id) || "CWStatusOff".equals(id) || "CWEmail".equals(id) + || "CWPrimary".equals(id) || "CWAccent".equals(id) || "CWOutline".equals(id) + || "CWDanger".equals(id) || "CWDarkToggle".equals(id)) { + return 2.45f; + } + if ("CWCardMeta".equals(id) || "CWMetricLabel".equals(id) || "CWFieldLabel".equals(id) + || "CWSegment".equals(id) || "CWSegmentSelected".equals(id)) { + return 2.35f; + } + if ("CWCellSub".equals(id)) { + return 2.25f; + } + if ("CWPillOk".equals(id) || "CWPillWarn".equals(id) || "CWPillBad".equals(id) + || "CWPillMuted".equals(id)) { + return 2.2f; + } + if ("CWNavLabel".equals(id) || "CWTableHeader".equals(id)) { + return 2.1f; + } + return 2.75f; + } + + private boolean isBoldUiid(String uiid) { + String id = stripDark(uiid); + return id.indexOf("Title") > -1 || id.indexOf("Logo") > -1 || id.indexOf("Status") > -1 + || id.indexOf("Selected") > -1 || id.indexOf("Primary") > -1 || id.indexOf("Accent") > -1 + || id.indexOf("Outline") > -1 || id.indexOf("Danger") > -1 || id.indexOf("Pill") > -1 + || id.indexOf("Segment") > -1 || id.indexOf("CellMain") > -1 + || "CWNavLabel".equals(id) || "CWTableHeader".equals(id) || "CWMetricNumber".equals(id) + || "CWDarkToggle".equals(id); + } + + private String stripDark(String uiid) { + if (uiid != null && uiid.startsWith("DarkCW")) { + return uiid.substring(4); + } + return uiid == null ? "" : uiid; + } + + private void prepareP8TextArea(TextArea p8, int rows) { + p8.setRows(rows); + p8.setGrowByContent(false); + p8.setTextSelectionEnabled(true); + p8.getHintLabel().setUIID(uiid("CWFieldHint")); + p8.setUIID(uiid("CWField")); + } + + private void certificatesPage() { + pageHead("Certificates", "Generate, sync, install, and revoke iOS signing certificates."); + Button add = primary("Generate", "btn.generateCert"); + add.addActionListener(e -> certificateDialog()); + Button sync = outline("Sync with Apple", "btn.reconcile"); + sync.addActionListener(e -> service.reconcile(r -> afterMutation(r, "Synced with Apple"))); + page.add(actionRow(Component.LEFT, add, sync)); + Container table = card(); + Container body = tableBody(Section.CERTIFICATES); + table.add(filterRow(Section.CERTIFICATES, body)); + populateTableBody(Section.CERTIFICATES, body); + table.add(body); + page.add(table); + } + + private void bundlesPage() { + pageHead("Bundle IDs", "Register app identifiers and enable capabilities."); + Button add = primary("Register bundle ID", "btn.addBundle"); + add.addActionListener(e -> bundleDialog(null, null)); + page.add(actionRow(Component.LEFT, add)); + Container table = card(); + Container body = tableBody(Section.BUNDLES); + table.add(filterRow(Section.BUNDLES, body)); + populateTableBody(Section.BUNDLES, body); + table.add(body); + page.add(table); + } + + private void devicesPage() { + pageHead("Devices", "Register devices for development and ad-hoc provisioning profiles."); + Button add = primary("Register device", "btn.addDevice"); + add.addActionListener(e -> deviceDialog()); + page.add(actionRow(Component.LEFT, add)); + Container table = card(); + Container body = tableBody(Section.DEVICES); + table.add(filterRow(Section.DEVICES, body)); + populateTableBody(Section.DEVICES, body); + table.add(body); + page.add(table); + } + + private void profilesPage() { + pageHead("Provisioning Profiles", "Create and install fresh .mobileprovision files."); + Button add = primary("New profile", "btn.newProfile"); + add.addActionListener(e -> newProfileDialog()); + page.add(actionRow(Component.LEFT, add)); + Container table = card(); + Container body = tableBody(Section.PROFILES); + table.add(filterRow(Section.PROFILES, body)); + populateTableBody(Section.PROFILES, body); + table.add(body); + page.add(table); + } + + private void apnsPage() { + pageHead("APNs Auth Keys", "Store token-based push notification keys for later use."); + Button add = primary("Add key", "btn.addApns"); + add.addActionListener(e -> apnsDialog()); + page.add(actionRow(Component.LEFT, add)); + Container table = card(); + Container body = tableBody(Section.APNS); + table.add(filterRow(Section.APNS, body)); + populateTableBody(Section.APNS, body); + table.add(body); + page.add(table); + } + + private void macPage() { + pageHead("Mac Signing", "Create Apple signing assets for Mac App Store, Developer ID and notarized builds."); + Container info = card(); + label(info, "Mac native builds use Apple signing assets that are separate from iOS certificates.", "CWCardMeta"); + label(info, "Use Developer ID for direct distribution and Mac App Distribution/Installer for the Mac App Store.", "CWCardMeta"); + page.add(info); + Button appStore = primary("Generate Mac App Store assets", "btn.macAppStore"); + appStore.addActionListener(e -> autoSetupMacProject(PROFILE_MAC_STORE)); + Button direct = outline("Generate Developer ID assets", "btn.macDeveloperId"); + direct.addActionListener(e -> autoSetupMacProject(PROFILE_MAC_DIRECT)); + Button cert = outline("Generate Mac certificate", "btn.macCert"); + cert.addActionListener(e -> certificateDialog()); + page.add(actionRow(Component.LEFT, appStore, direct, cert)); + + Container certTable = card(); + label(certTable, "Mac certificates", "CWCardTitle"); + for (SigningState.Certificate c : state.certificates) { + if (isMacCertificate(c.certificateType())) { + macAssetRow(certTable, typeLabel(c.certificateType()), + firstNonEmpty(c.displayName(), c.appleCertId(), "-"), + "btn.installMacCert." + c.id(), () -> installCertificate(c)); + } + } + page.add(certTable); + + Container profileTable = card(); + label(profileTable, "Mac profiles", "CWCardTitle"); + for (SigningState.Profile p : state.profiles) { + if (isMacProfile(p.profileType())) { + macAssetRow(profileTable, profileTypeLabel(p.profileType()), + firstNonEmpty(p.name(), p.appleProfileId(), "-"), + "btn.installMacProfile." + p.id(), () -> installProfile(p)); + } + } + page.add(profileTable); + + if (binding != null && binding.settings() != null && !binding.settings().trim().isEmpty()) { + Container current = card(); + label(current, "Current Mac project settings", "CWCardTitle"); + row(current, "Certificate", readSetting(binding.settings(), "codename1.mac.certificate"), null); + row(current, "Provisioning profile", readSetting(binding.settings(), "codename1.mac.provision"), null); + row(current, "Distribution", readSetting(binding.settings(), "codename1.arg.macNative.distribution"), null); + page.add(current); + } + } + + private void androidPage() { + pageHead("Android Signing", "Generate a local Android keystore and install it into the current project."); + if (!canInstallIntoProject()) { + return; + } + ProjectDefaults defaults = projectDefaults(); + TextField alias = field("Alias", "androidKey"); + alias.setName("field.androidAlias"); + alias.setText("androidKey"); + TextField password = field("Password", "at least 6 characters"); + password.setName("field.androidPassword"); + TextField commonName = field("Common name", androidCommonName(defaults)); + commonName.setName("field.androidCommonName"); + commonName.setText(androidCommonName(defaults)); + TextField orgUnit = field("Organizational unit", "Development"); + orgUnit.setName("field.androidOrgUnit"); + orgUnit.setText("Development"); + TextField organization = field("Organization", "MyCompany"); + organization.setName("field.androidOrganization"); + organization.setText("MyCompany"); + TextField locality = field("City/locality", "MyCity"); + locality.setName("field.androidLocality"); + locality.setText("MyCity"); + TextField stateName = field("State/province", "MyState"); + stateName.setName("field.androidState"); + stateName.setText("MyState"); + TextField country = field("Country code", "US"); + country.setName("field.androidCountry"); + country.setText("US"); + Container c = card(); + label(c, "Android uses a self-signed keystore. Back up the generated file and password.", "CWCardMeta"); + label(c, "Alias", "CWFieldLabel"); + c.add(alias); + label(c, "Password", "CWFieldLabel"); + c.add(password); + label(c, "Certificate details", "CWFieldLabel"); + c.add(commonName); + c.add(twoColumn(orgUnit, organization)); + c.add(twoColumn(locality, stateName)); + c.add(country); + Button generate = primary("Generate and install", "btn.androidGenerate"); + generate.addActionListener(e -> generateAndroidKeystore(alias.getText(), password.getText(), + androidDistinguishedName(commonName.getText(), orgUnit.getText(), organization.getText(), + locality.getText(), stateName.getText(), country.getText()))); + c.add(actionRow(Component.LEFT, generate)); + page.add(c); + Container current = card(); + label(current, "Current project settings", "CWCardTitle"); + row(current, "Keystore", readSetting(binding.settings(), "codename1.android.keystore"), null); + row(current, "Alias", readSetting(binding.settings(), "codename1.android.keystoreAlias"), null); + page.add(current); + } + + private void windowsPage() { + pageHead("Windows Signing", "Install a PKCS#12 Authenticode certificate for native Windows builds."); + Container c = card(); + label(c, "Windows certificates are issued by a code-signing certificate authority.", "CWCardMeta"); + label(c, "Recommended routes include Azure Trusted Signing, DigiCert KeyLocker, GlobalSign GCC, or another OV/EV code-signing CA.", "CWCardMeta"); + label(c, "If you already have a .pfx/.p12 file, configure these project properties:", "CWCardMeta"); + row(c, "codename1.windows.signing.certificate", readSetting(binding == null ? null : binding.settings(), + "codename1.windows.signing.certificate"), null); + row(c, "codename1.windows.signing.password", readSetting(binding == null ? null : binding.settings(), + "codename1.windows.signing.password"), null); + row(c, "codename1.windows.signing.timestamp", firstNonEmpty(readSetting(binding == null ? null : binding.settings(), + "codename1.windows.signing.timestamp"), "http://timestamp.digicert.com"), null); + Button docs = primary("Open Windows signing docs", "btn.windowsDocs"); + docs.addActionListener(e -> Display.getInstance().execute("https://www.codenameone.com/manual/advanced-topics.html#_windows_native_port_only")); + Button certs = outline("Open Azure Trusted Signing", "btn.windowsAzure"); + certs.addActionListener(e -> Display.getInstance().execute("https://azure.microsoft.com/products/trusted-signing")); + c.add(actionRow(Component.LEFT, docs, certs)); + page.add(c); + } + + private void maintenancePage() { + pageHead("Clear Signing Data", "Privacy tool for deleting cached cloud signing data."); + Container c = card(); + label(c, "This is not recommended for normal signing setup or troubleshooting. Use it only when you need to remove cached signing data from the Codename One cloud service for privacy reasons.", "CWCardMeta"); + label(c, "It clears cloud-side Apple signing cache for this account, including stored API keys, generated certificate private keys, profiles, APNs keys and notarization data.", "CWCardMeta"); + label(c, "It does not delete assets from Apple, but future cloud builds will need signing data to be regenerated or re-imported.", "CWCardMeta"); + Button clear = danger("Clear cloud signing data", "btn.clearSigningData"); + clear.addActionListener(e -> confirm("Permanently clear signing data", + "This privacy tool deletes all cached cloud signing data for this Codename One account. It is not recommended for normal setup and cannot be undone. Continue?", + "Clear signing data", () -> service.clearSigningData(r -> { + if (r.ok) { + state = SigningState.empty(); + storeCredentialState(); + } + afterMutation(r, "Signing data cleared"); + }))); + c.add(actionRow(Component.LEFT, clear)); + page.add(c); + } + + private void certificateDialog() { + InteractionDialog d = modal("Generate certificate"); + final String[] type = {"IOS_DISTRIBUTION"}; + label(d, "Certificate type", "CWFieldLabel"); + Button dist = segment("iOS Distribution", true); + Button dev = segment("iOS Development", false); + Button macStore = segment("Mac App Store", false); + Button developerId = segment("Developer ID", false); + Button installer = segment("Mac Installer", false); + Button[] typeButtons = {dist, dev, macStore, developerId, installer}; + String[] typeValues = {"IOS_DISTRIBUTION", "IOS_DEVELOPMENT", "MAC_APP_DISTRIBUTION", + "DEVELOPER_ID_APPLICATION", "MAC_INSTALLER_DISTRIBUTION"}; + for (int i = 0; i < typeButtons.length; i++) { + final int typeIndex = i; + typeButtons[i].addActionListener(e -> { + type[0] = typeValues[typeIndex]; + updateSegmentButtons(typeButtons, typeValues, type[0]); + d.revalidate(); + }); + } + TextField name = field("Display name", "App Store Distribution"); + d.add(actionRow(Component.LEFT, dist, dev, macStore)); + d.add(actionRow(Component.LEFT, developerId, installer)); + label(d, "Display name", "CWFieldLabel"); + d.add(name); + Button gen = primary("Generate", "modal.generateCert.submit"); + gen.addActionListener(e -> { d.dispose(); service.createCertificate(type[0], name.getText(), r -> afterMutation(r, "Certificate generated")); }); + addDialogActions(d, gen); + showModal(d); + } + + private void p12Dialog(SigningState.Certificate c) { + InteractionDialog d = modal("Download .p12"); + label(d, "Choose a new password for this downloaded .p12 file. Save it with the file.", "CWCardMeta"); + label(d, "This is not the App Store Connect Issuer ID and it does not come from Apple.", "CWCardMeta"); + label(d, "P12 password", "CWFieldLabel"); + TextField pass = field("P12 password", "New .p12 password"); + pass.setName("modal.p12.password"); + d.add(pass); + Button dl = primary("Download .p12", "modal.p12.submit"); + dl.addActionListener(e -> { + d.dispose(); + service.downloadP12(c.id(), pass.getText(), safeFileName(c.displayName()) + ".p12", + x -> afterCertificateDownload(x, c, pass.getText())); + }); + addDialogActions(d, dl); + showModal(d); + } + + private void bundleDialog(String initialIdentifier, String initialName) { + InteractionDialog d = modal("Register bundle ID"); + TextField id = field("Identifier", "com.example.app"); + TextField name = field("Name", "My App"); + if (initialIdentifier != null) { + id.setText(initialIdentifier); + } + if (initialName != null) { + name.setText(initialName); + } + CheckBox push = new CheckBox("Enable Push Notifications"); + push.setUIID(uiid("CWFieldLabel")); + d.add(id).add(name).add(push); + Button save = primary("Register", "modal.bundle.submit"); + save.addActionListener(e -> { + d.dispose(); + service.createBundleId(id.getText(), name.getText(), push.isSelected(), r -> afterMutation(r, "Bundle ID registered")); + }); + addDialogActions(d, save); + showModal(d); + } + + private void deviceDialog() { + InteractionDialog d = modal("Register device"); + TextField name = field("Device name", "QA iPhone"); + TextField udid = field("UDID", "00008120-000A1C3E0C68201E"); + d.add(name).add(udid); + Button save = primary("Register", "modal.device.submit"); + save.addActionListener(e -> { d.dispose(); service.registerDevice(name.getText(), udid.getText(), r -> afterMutation(r, "Device registered")); }); + addDialogActions(d, save); + showModal(d); + } + + private void apnsDialog() { + InteractionDialog d = modal("Add APNs auth key"); + TextField name = field("Display name", "Production APNs"); + TextField key = field("Key ID", "A1B2C3D4E5"); + TextField team = field("Team ID", "9WQ7X2K4LM"); + TextArea p8 = new TextArea(""); + p8.setHint("-----BEGIN PRIVATE KEY-----"); + prepareP8TextArea(p8, 5); + d.add(name).add(key).add(team); + label(d, "Auth key .p8", "CWFieldLabel"); + Button importP8 = outline("Import .p8 file", "modal.apns.importP8"); + importP8.addActionListener(e -> importP8(p8)); + d.add(importP8); + d.add(p8); + Button save = primary("Store key", "modal.apns.submit"); + save.addActionListener(e -> { d.dispose(); service.saveApnsKey(key.getText(), team.getText(), p8.getText(), name.getText(), r -> afterMutation(r, "APNs key stored")); }); + addDialogActions(d, save); + showModal(d); + } + + private void newProfileDialog() { + InteractionDialog d = modalFrame("New provisioning profile"); + Container content = new Container(BoxLayout.y()); + content.setScrollableY(true); + content.setUIID(uiid("CWDialogContent")); + d.add(BorderLayout.CENTER, content); + + final String[] profileType = {null}; + final String[] bundleId = {null}; + final String[] certificateId = {null}; + final String[] profileName = {projectDefaults().appName + " App Store"}; + final List certs = new ArrayList(); + final List devs = new ArrayList(); + final Button[] createRef = new Button[1]; + + Button store = segment("iOS App Store", true); + Button adhoc = segment("iOS Ad Hoc", false); + Button dev = segment("iOS Development", false); + Button macStore = segment("Mac App Store", false); + Button macDirect = segment("Mac Direct", false); + Button macDev = segment("Mac Development", false); + Button[] typeButtons = {store, adhoc, dev, macStore, macDirect, macDev}; + String[] typeValues = {"IOS_APP_STORE", "IOS_APP_ADHOC", "IOS_APP_DEVELOPMENT", + "MAC_APP_STORE", "MAC_APP_DIRECT", "MAC_APP_DEVELOPMENT"}; + label(content, "Profile type", "CWFieldLabel"); + content.add(actionRow(Component.LEFT, store, adhoc, dev)); + content.add(actionRow(Component.LEFT, macStore, macDirect, macDev)); + + label(content, "Bundle ID", "CWFieldLabel"); + List