diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..515ffb98 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +java zulu-8 +maven 3.9.9 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 00000000..e65d90ce --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,99 @@ +# jDeploy Local Development Guide + +This guide covers building jDeploy from source and manually testing the CLI, GUI, and installer during development. + +## Prerequisites + +- **JDK 8** — the project targets Java 8 (`maven.compiler.source`/`target` are `1.8`) +- **Maven 3.9+** + +The repository includes a [`.tool-versions`](.tool-versions) file pinning `java zulu-8` and `maven 3.9.9`, so if you use [mise](https://mise.jdx.dev/) (or another asdf-compatible tool manager), the correct toolchain is selected automatically. + +## Project Structure + +| Module | Description | +|---|---| +| `shared/` | Shared library used by the CLI and installer (Maven) | +| `cli/` | The main jDeploy CLI/GUI application (Maven) | +| `installer/` | The native installer application (Maven) | + +The root `pom.xml` is an aggregator that builds `shared`, `cli`, and `installer` in order. + +## Building + +```bash +# Build everything and run integration tests +./build_and_test.sh + +# Build a single module and the modules it depends on (skipping tests) +mvn -pl cli -am package -DskipTests +mvn -pl installer -am package -DskipTests +``` + +Each module's `package` phase copies its runtime dependencies into `target/libs/` (via the `maven-dependency-plugin`), so a module can be run directly from `target/classes` plus `target/libs/*` without shading. + +Set `JDEPLOY_SKIP_INTEGRATION_TESTS=1` to skip integration tests during `./build_and_test.sh`. + +## Manual Testing Scripts + +These scripts replicate the IntelliJ run/debug configurations used during development, so you can test from the command line without an IDE. Both scripts: + +- Select a JDK 8 automatically (they honor `$JAVA_HOME` if it points to a JDK, otherwise fall back to `/usr/libexec/java_home -v 1.8` on macOS) +- Build the required Maven modules on first run, or when invoked with `--build` + +### `test_jdeploy_gui.sh` — Launch the jDeploy GUI + +Launches the jDeploy GUI (`ca.weblite.jdeploy.JDeploy` from the `jdeploy-cli` module) against a project directory, the same way the **JDeploy** IntelliJ run configuration does. + +```bash +# Open the default demo project (/Users/shannah/jdeploy-demos/jdeploy-service-example) +./test_jdeploy_gui.sh + +# Open a specific project +./test_jdeploy_gui.sh /path/to/project + +# Force a rebuild of the cli module first +./test_jdeploy_gui.sh --build [/path/to/project] +``` + +Environment overrides: + +| Variable | Purpose | +|---|---| +| `JDEPLOY_PROJECT_DIR` | Project to open (same as passing a project dir argument) | +| `JAVA_HOME` | JDK to use | + +### `test_installer_debug.sh` — Test the Install Wizard + +Runs the installer in debug mode (`ca.weblite.jdeploy.installer.MainDebug` from the `jdeploy-installer` module), the same way the **JDeploy Installer** IntelliJ run configuration does. It downloads the `.jdeploy-files` bundle for an app code from the registry, then launches the install wizard against it. + +```bash +# Install the default test app (code 26AD, version 1.0.15) +./test_installer_debug.sh + +# Install a specific app code and version +./test_installer_debug.sh [version] + +# Headless install (no GUI; output logged to ~/.jdeploy/log/jdeploy-headless-install.log) +./test_installer_debug.sh install + +# Force a rebuild of the installer module first +./test_installer_debug.sh --build [args...] +``` + +Environment overrides: + +| Variable | Purpose | +|---|---| +| `JDEPLOY_REGISTRY_URL` | Registry to download the bundle from (default `https://dev.jdeploy.com/`) | +| `JAVA_HOME` | JDK to use | + +The script sets `JDEPLOY_DEBUG=1`, so HTTP requests and other debug information are printed to the console. Installer output is also logged to `~/.jdeploy/log/jdeploy-installer.log`. + +## Running Integration Tests + +```bash +cd tests && bash test.sh +``` + +Integration test projects live in `tests/projects/`, each exercising a different deployment configuration. diff --git a/README.md b/README.md index 69c0cd9d..12f221ab 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,10 @@ See [the jDeploy Developer Guide](https://www.jdeploy.com/docs/manual/#_publishi | `npm_token` | The `NPM_TOKEN` for publishing to npm. Only required if `deploy_target`==`npm` | `null` |` | `jdeploy_version` | The jdeploy version to use for building the installers. | `4.0.0-alpha.38` | +## Development + +See the [Local Development Guide](DEVELOPMENT.md) for instructions on building jDeploy from source and manually testing the GUI and installer. + ## License [Apache2](LICENSE) diff --git a/cli/src/main/java/ca/weblite/jdeploy/gui/JDeployProjectEditor.java b/cli/src/main/java/ca/weblite/jdeploy/gui/JDeployProjectEditor.java index 4a7504bf..dfe45739 100644 --- a/cli/src/main/java/ca/weblite/jdeploy/gui/JDeployProjectEditor.java +++ b/cli/src/main/java/ca/weblite/jdeploy/gui/JDeployProjectEditor.java @@ -28,6 +28,7 @@ import ca.weblite.jdeploy.gui.tabs.PublishSettingsPanel; import ca.weblite.jdeploy.gui.tabs.RuntimeArgsPanel; import ca.weblite.jdeploy.gui.tabs.SplashScreensPanel; +import ca.weblite.jdeploy.gui.tabs.UpdateSettingsPanel; import ca.weblite.jdeploy.gui.tabs.UrlSchemesPanel; import ca.weblite.jdeploy.helpers.NPMApplicationHelper; import ca.weblite.jdeploy.models.NPMApplication; @@ -81,6 +82,7 @@ public class JDeployProjectEditor { private HelperActionsPanel helperActionsPanel; private RuntimeArgsPanel runtimeArgsPanel; private CheerpJSettingsPanel cheerpJSettingsPanel; + private UpdateSettingsPanel updateSettingsPanel; private SplashScreensPanel splashScreensPanel; private EditorPanelRegistry registry; private PublishActionHandler publishActionHandler; @@ -639,6 +641,18 @@ private EditorPanelRegistry createPanelRegistry() { )); } + // Update Settings Panel + updateSettingsPanel = new UpdateSettingsPanel(); + registry.register(NavigablePanelAdapter.forJdeployPanel( + "Updates", + MenuBarBuilder.JDEPLOY_WEBSITE_URL + "docs/help/#updates", + FontIcon.of(Material.SYSTEM_UPDATE), + updateSettingsPanel.getRoot(), + json -> updateSettingsPanel.load(json), + json -> updateSettingsPanel.save(json), + listener -> updateSettingsPanel.addChangeListener(listener) + )); + // Permissions Panel permissionsPanel = new PermissionsPanel(); registry.register(NavigablePanelAdapter.forPackageJsonPanel( diff --git a/cli/src/main/java/ca/weblite/jdeploy/gui/tabs/UpdateSettingsPanel.java b/cli/src/main/java/ca/weblite/jdeploy/gui/tabs/UpdateSettingsPanel.java new file mode 100644 index 00000000..98a08d15 --- /dev/null +++ b/cli/src/main/java/ca/weblite/jdeploy/gui/tabs/UpdateSettingsPanel.java @@ -0,0 +1,311 @@ +package ca.weblite.jdeploy.gui.tabs; + +import ca.weblite.jdeploy.gui.util.SwingUtils; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.ActionListener; + +import org.json.JSONObject; + +/** + * Editor panel for application update settings stored in the {@code jdeploy} object + * of package.json. + * + *

Exposes:

+ *
    + *
  • appUpdateMode — {@code "auto"} (default, silent update on launch) or + * {@code "prompt"} (ask the user before updating).
  • + *
  • minLauncherInitialAppVersion / minLauncherInitialAppVersionMode — + * the minimum initial app version required to run new releases. Either an explicit + * version, or the sentinel mode {@code "latest"} which jDeploy resolves to the + * published version at publish time.
  • + *
  • requireLauncherUpdate — force a full launcher update for users on an older + * launcher.
  • + *
+ * + *

In the UI these are framed as App-Only Updates ({@code appUpdateMode} — updates + * that replace only the app's jar files, applied seamlessly on launch) and Full Updates + * ({@code minLauncherInitialAppVersion} / {@code requireLauncherUpdate} — updates that run the + * installer again to update the native launcher along with the app).

+ * + *

Follows the established remove-when-default convention: keys are only written when + * they differ from the default, and removed otherwise.

+ */ +public class UpdateSettingsPanel extends JPanel { + + static final String KEY_APP_UPDATE_MODE = "appUpdateMode"; + static final String KEY_MIN_INITIAL_VERSION = "minLauncherInitialAppVersion"; + static final String KEY_MIN_INITIAL_VERSION_MODE = "minLauncherInitialAppVersionMode"; + static final String KEY_REQUIRE_LAUNCHER_UPDATE = "requireLauncherUpdate"; + + static final String UPDATE_MODE_AUTO = "auto"; + static final String UPDATE_MODE_PROMPT = "prompt"; + static final String MIN_VERSION_MODE_LATEST = "latest"; + + private final JRadioButton autoUpdateRadio = new JRadioButton("Update automatically on launch (default)"); + private final JRadioButton promptUpdateRadio = new JRadioButton("Prompt the user before updating"); + + private final JRadioButton minNoneRadio = new JRadioButton("Never require a full update (default)"); + private final JRadioButton minLatestRadio = new JRadioButton("Require a full update for every new release"); + private final JRadioButton minExplicitRadio = new JRadioButton("Require a full update for installations older than:"); + private final JTextField minExplicitField = new PlaceholderTextField("App version, e.g. 1.2.0", 14); + + private final JCheckBox requireLauncherUpdateCheckbox = + new JCheckBox("Don't allow the app to run until the full update is completed"); + + private static final String APP_ONLY_HELP = + "

App-only updates replace just your app's jar files, " + + "leaving the native launcher untouched. They are fast, and can be applied " + + "seamlessly in the background when the user launches your app.

" + + "

App-only updates are generally sufficient for " + + "distributing new releases. If a release depends on newer jDeploy features, " + + "or on features of your app that require running the installer again, use " + + "Full Updates to require a full update.

"; + + private static final String FULL_UPDATE_HELP = + "

A full update runs through the installation wizard " + + "again, updating the native launcher (the harness that hosts your app) in " + + "addition to the app's jar files. Full updates are slower than app-only " + + "updates and require interaction from the user, so they should only be " + + "required when necessary.

" + + "

A full update is sometimes required — for example, " + + "when a release depends on jDeploy features that weren't available in " + + "previous versions of your app, or when you've added features (such as new " + + "file associations or services) that require a full install.

" + + "

The version threshold refers to the version the " + + "user first installed: users whose original installation is older than the " + + "threshold are asked to download a new installer and perform a full update " + + "before they can run newer releases.

"; + + private ActionListener changeListener; + + public UpdateSettingsPanel() { + // Anchor the content at the top: BorderLayout.NORTH keeps the sections at + // their preferred heights instead of stretching them (and the rows inside + // them) to fill the editor's content area. + setLayout(new BorderLayout()); + buildUi(); + initializeChangeListeners(); + updateEnabledState(); + } + + private void buildUi() { + JPanel content = new JPanel(); + content.setLayout(new BoxLayout(content, BoxLayout.Y_AXIS)); + content.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + + // --- App-only updates (jar files only, applied seamlessly on launch) --- + JPanel modePanel = new JPanel(); + modePanel.setLayout(new BoxLayout(modePanel, BoxLayout.Y_AXIS)); + modePanel.setAlignmentX(Component.LEFT_ALIGNMENT); + modePanel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createTitledBorder("App-Only Updates"), + BorderFactory.createEmptyBorder(4, 8, 8, 8))); + modePanel.add(wrapLeft(createHelpLink("App-Only Updates", APP_ONLY_HELP))); + modePanel.add(wrapLeft(new JLabel( + "

App-only updates replace your app's jar files, " + + "and can be applied seamlessly when the user launches your app. " + + "Choose what happens when one is available.

"))); + modePanel.add(Box.createVerticalStrut(6)); + ButtonGroup modeGroup = new ButtonGroup(); + modeGroup.add(autoUpdateRadio); + modeGroup.add(promptUpdateRadio); + modePanel.add(wrapLeft(autoUpdateRadio)); + modePanel.add(wrapLeft(promptUpdateRadio)); + content.add(modePanel); + + content.add(Box.createVerticalStrut(10)); + + // --- Full updates (minimum initial app version + require launcher update) --- + JPanel minPanel = new JPanel(); + minPanel.setLayout(new BoxLayout(minPanel, BoxLayout.Y_AXIS)); + minPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + minPanel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createTitledBorder("Full Updates"), + BorderFactory.createEmptyBorder(4, 8, 8, 8))); + minPanel.add(wrapLeft(createHelpLink("Full Updates", FULL_UPDATE_HELP))); + minPanel.add(wrapLeft(new JLabel( + "

A full update runs the installer again, updating " + + "the native launcher along with the app. Choose when users are " + + "required to perform one.

"))); + minPanel.add(Box.createVerticalStrut(6)); + ButtonGroup minGroup = new ButtonGroup(); + minGroup.add(minNoneRadio); + minGroup.add(minLatestRadio); + minGroup.add(minExplicitRadio); + minPanel.add(wrapLeft(minNoneRadio)); + minPanel.add(wrapLeft(minLatestRadio)); + minExplicitField.setToolTipText( + "

The minimum version of your app (not " + + "jDeploy). Users who first installed a version older than this will be " + + "required to perform a full update.

"); + JPanel explicitRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + explicitRow.add(minExplicitRadio); + explicitRow.add(Box.createHorizontalStrut(6)); + explicitRow.add(minExplicitField); + minPanel.add(wrapLeft(explicitRow)); + minPanel.add(wrapLeft(requireLauncherUpdateCheckbox)); + content.add(minPanel); + + add(content, BorderLayout.NORTH); + } + + private static JComponent wrapLeft(JComponent c) { + c.setAlignmentX(Component.LEFT_ALIGNMENT); + JPanel row = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 2)); + row.setAlignmentX(Component.LEFT_ALIGNMENT); + row.add(c); + return row; + } + + /** + * Creates a small link-styled label that pops up a fuller description of a section + * when clicked. + */ + private JComponent createHelpLink(String title, String helpHtml) { + JLabel link = new JLabel("What are " + title.toLowerCase() + "?"); + link.setForeground(new Color(100, 149, 237)); // Cornflower blue + link.setFont(link.getFont().deriveFont(link.getFont().getSize2D() - 1f)); + link.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + link.addMouseListener(new java.awt.event.MouseAdapter() { + @Override + public void mouseClicked(java.awt.event.MouseEvent e) { + JOptionPane.showMessageDialog( + UpdateSettingsPanel.this, + new JLabel(helpHtml), + title, + JOptionPane.INFORMATION_MESSAGE); + } + }); + return link; + } + + private void initializeChangeListeners() { + autoUpdateRadio.addItemListener(e -> fireChangeEvent()); + promptUpdateRadio.addItemListener(e -> fireChangeEvent()); + + minNoneRadio.addItemListener(e -> { updateEnabledState(); fireChangeEvent(); }); + minLatestRadio.addItemListener(e -> { updateEnabledState(); fireChangeEvent(); }); + minExplicitRadio.addItemListener(e -> { updateEnabledState(); fireChangeEvent(); }); + SwingUtils.addChangeListenerTo(minExplicitField, this::fireChangeEvent); + + requireLauncherUpdateCheckbox.addItemListener(e -> fireChangeEvent()); + } + + private void updateEnabledState() { + minExplicitField.setEnabled(minExplicitRadio.isSelected()); + // The "require full launcher update" flag only matters when a minimum is in effect. + requireLauncherUpdateCheckbox.setEnabled(!minNoneRadio.isSelected()); + } + + public JPanel getRoot() { + return this; + } + + public void load(JSONObject jdeploy) { + // Auto-update mode + String mode = jdeploy == null ? UPDATE_MODE_AUTO + : jdeploy.optString(KEY_APP_UPDATE_MODE, UPDATE_MODE_AUTO); + if (UPDATE_MODE_PROMPT.equals(mode)) { + promptUpdateRadio.setSelected(true); + } else { + autoUpdateRadio.setSelected(true); + } + + // Minimum initial app version + String explicit = jdeploy == null ? "" : jdeploy.optString(KEY_MIN_INITIAL_VERSION, ""); + String minMode = jdeploy == null ? "" : jdeploy.optString(KEY_MIN_INITIAL_VERSION_MODE, ""); + if (MIN_VERSION_MODE_LATEST.equals(minMode)) { + minLatestRadio.setSelected(true); + minExplicitField.setText(""); + } else if (explicit != null && !explicit.isEmpty()) { + minExplicitRadio.setSelected(true); + minExplicitField.setText(explicit); + } else { + minNoneRadio.setSelected(true); + minExplicitField.setText(""); + } + + // Require full launcher update + boolean requireUpdate = jdeploy != null && jdeploy.optBoolean(KEY_REQUIRE_LAUNCHER_UPDATE, false); + requireLauncherUpdateCheckbox.setSelected(requireUpdate); + + updateEnabledState(); + } + + public void save(JSONObject jdeploy) { + if (jdeploy == null) { + return; + } + + // Auto-update mode: only persist the non-default "prompt" value. + if (promptUpdateRadio.isSelected()) { + jdeploy.put(KEY_APP_UPDATE_MODE, UPDATE_MODE_PROMPT); + } else { + jdeploy.remove(KEY_APP_UPDATE_MODE); + } + + // Minimum initial app version (the two keys are mutually exclusive). + if (minLatestRadio.isSelected()) { + jdeploy.put(KEY_MIN_INITIAL_VERSION_MODE, MIN_VERSION_MODE_LATEST); + jdeploy.remove(KEY_MIN_INITIAL_VERSION); + } else if (minExplicitRadio.isSelected() && !minExplicitField.getText().trim().isEmpty()) { + jdeploy.put(KEY_MIN_INITIAL_VERSION, minExplicitField.getText().trim()); + jdeploy.remove(KEY_MIN_INITIAL_VERSION_MODE); + } else { + jdeploy.remove(KEY_MIN_INITIAL_VERSION); + jdeploy.remove(KEY_MIN_INITIAL_VERSION_MODE); + } + + // Require full launcher update: only meaningful when a minimum is set. + boolean minimumSet = minLatestRadio.isSelected() + || (minExplicitRadio.isSelected() && !minExplicitField.getText().trim().isEmpty()); + if (minimumSet && requireLauncherUpdateCheckbox.isSelected()) { + jdeploy.put(KEY_REQUIRE_LAUNCHER_UPDATE, true); + } else { + jdeploy.remove(KEY_REQUIRE_LAUNCHER_UPDATE); + } + } + + public void addChangeListener(ActionListener listener) { + this.changeListener = listener; + } + + private void fireChangeEvent() { + if (changeListener != null) { + changeListener.actionPerformed(new java.awt.event.ActionEvent(this, 0, "changed")); + } + } + + /** + * A text field that paints a grayed-out placeholder hint while it is empty. + */ + private static class PlaceholderTextField extends JTextField { + + private final String placeholder; + + PlaceholderTextField(String placeholder, int columns) { + super(columns); + this.placeholder = placeholder; + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + if (getText().isEmpty()) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint( + RenderingHints.KEY_TEXT_ANTIALIASING, + RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g2.setColor(Color.GRAY); + g2.setFont(getFont().deriveFont(Font.ITALIC)); + Insets insets = getInsets(); + FontMetrics fm = g2.getFontMetrics(); + g2.drawString(placeholder, insets.left + 2, + (getHeight() - fm.getHeight()) / 2 + fm.getAscent()); + g2.dispose(); + } + } + } +} diff --git a/cli/src/main/java/ca/weblite/jdeploy/publishing/BasePublishDriver.java b/cli/src/main/java/ca/weblite/jdeploy/publishing/BasePublishDriver.java index 641650d5..d0692f63 100644 --- a/cli/src/main/java/ca/weblite/jdeploy/publishing/BasePublishDriver.java +++ b/cli/src/main/java/ca/weblite/jdeploy/publishing/BasePublishDriver.java @@ -84,6 +84,9 @@ public void prepare( JSONObject jdeployObj = packageJSON.getJSONObject("jdeploy"); + // Resolve the "auto-set to latest" sentinel for the minimum initial app version. + resolveMinInitialAppVersionSentinel(packageJSON, jdeployObj); + File icon = new File(context.packagingContext.directory, "icon.png"); JSONObject checksums = new JSONObject(); jdeployObj.put("checksums", checksums); @@ -164,4 +167,24 @@ private String getPackageSigningVersionString(JSONObject packageJSON) { return versionString; } + + /** + * Resolves the "auto-set to latest" sentinel for the minimum initial app version. + * + *

When {@code jdeploy.minLauncherInitialAppVersionMode == "latest"}, the version + * being published is stamped into {@code jdeploy.minLauncherInitialAppVersion} (and + * the sentinel mode key removed) in the published package.json. The developer's source + * package.json is unaffected because this operates on the in-memory copy that is + * written to the publish directory.

+ * + * @param packageJSON the package.json being published (must contain "version") + * @param jdeployObj the "jdeploy" object within {@code packageJSON} + */ + static void resolveMinInitialAppVersionSentinel(JSONObject packageJSON, JSONObject jdeployObj) { + if ("latest".equals(jdeployObj.optString("minLauncherInitialAppVersionMode", "")) + && packageJSON.has("version")) { + jdeployObj.put("minLauncherInitialAppVersion", packageJSON.getString("version")); + jdeployObj.remove("minLauncherInitialAppVersionMode"); + } + } } diff --git a/cli/src/test/java/ca/weblite/jdeploy/gui/tabs/UpdateSettingsPanelTest.java b/cli/src/test/java/ca/weblite/jdeploy/gui/tabs/UpdateSettingsPanelTest.java new file mode 100644 index 00000000..f3e7d400 --- /dev/null +++ b/cli/src/test/java/ca/weblite/jdeploy/gui/tabs/UpdateSettingsPanelTest.java @@ -0,0 +1,232 @@ +package ca.weblite.jdeploy.gui.tabs; + +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("UpdateSettingsPanel") +public class UpdateSettingsPanelTest implements ActionListener { + private UpdateSettingsPanel panel; + private AtomicInteger changeListenerCallCount; + + @BeforeEach + void setUp() { + panel = new UpdateSettingsPanel(); + changeListenerCallCount = new AtomicInteger(0); + panel.addChangeListener(this); + } + + @Override + public void actionPerformed(ActionEvent e) { + changeListenerCallCount.incrementAndGet(); + } + + @Test + @DisplayName("Should create panel with root component") + void testGetRoot() { + assertNotNull(panel.getRoot()); + assertEquals(panel, panel.getRoot()); + } + + // --- Auto-update mode --- + + @Test + @DisplayName("Should default to auto mode when jdeploy is null") + void testLoadNullDefaultsToAuto() { + panel.load(null); + JSONObject out = new JSONObject(); + panel.save(out); + assertFalse(out.has(UpdateSettingsPanel.KEY_APP_UPDATE_MODE), + "auto is the default and should not be written"); + } + + @Test + @DisplayName("Should default to auto mode when appUpdateMode is absent") + void testLoadMissingDefaultsToAuto() { + JSONObject jdeploy = new JSONObject(); + jdeploy.put("other", "value"); + panel.load(jdeploy); + + JSONObject out = new JSONObject(); + panel.save(out); + assertFalse(out.has(UpdateSettingsPanel.KEY_APP_UPDATE_MODE)); + } + + @Test + @DisplayName("Should load prompt mode") + void testLoadPromptMode() { + JSONObject jdeploy = new JSONObject(); + jdeploy.put(UpdateSettingsPanel.KEY_APP_UPDATE_MODE, "prompt"); + panel.load(jdeploy); + + JSONObject out = new JSONObject(); + panel.save(out); + assertEquals("prompt", out.getString(UpdateSettingsPanel.KEY_APP_UPDATE_MODE)); + } + + @Test + @DisplayName("Should remove appUpdateMode when reverted to auto") + void testSaveAutoRemovesKey() { + JSONObject jdeploy = new JSONObject(); + jdeploy.put(UpdateSettingsPanel.KEY_APP_UPDATE_MODE, "prompt"); + panel.load(jdeploy); + // Switch back to auto + panel.load(new JSONObject()); + + JSONObject out = new JSONObject(); + out.put(UpdateSettingsPanel.KEY_APP_UPDATE_MODE, "prompt"); + panel.save(out); + assertFalse(out.has(UpdateSettingsPanel.KEY_APP_UPDATE_MODE)); + } + + // --- Minimum initial app version --- + + @Test + @DisplayName("Should default to no minimum") + void testLoadNoMinimum() { + panel.load(new JSONObject()); + JSONObject out = new JSONObject(); + panel.save(out); + assertFalse(out.has(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION)); + assertFalse(out.has(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION_MODE)); + } + + @Test + @DisplayName("Should load and round-trip an explicit minimum version") + void testLoadExplicitMinimum() { + JSONObject jdeploy = new JSONObject(); + jdeploy.put(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION, "1.4.0"); + panel.load(jdeploy); + + JSONObject out = new JSONObject(); + panel.save(out); + assertEquals("1.4.0", out.getString(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION)); + assertFalse(out.has(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION_MODE)); + } + + @Test + @DisplayName("Should load and round-trip the latest sentinel mode") + void testLoadLatestSentinel() { + JSONObject jdeploy = new JSONObject(); + jdeploy.put(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION_MODE, "latest"); + panel.load(jdeploy); + + JSONObject out = new JSONObject(); + panel.save(out); + assertEquals("latest", out.getString(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION_MODE)); + assertFalse(out.has(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION)); + } + + @Test + @DisplayName("Latest sentinel should take precedence over explicit version on load") + void testLoadLatestSentinelPrecedence() { + JSONObject jdeploy = new JSONObject(); + jdeploy.put(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION, "1.0.0"); + jdeploy.put(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION_MODE, "latest"); + panel.load(jdeploy); + + JSONObject out = new JSONObject(); + panel.save(out); + assertEquals("latest", out.getString(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION_MODE)); + assertFalse(out.has(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION)); + } + + @Test + @DisplayName("Should trim whitespace from explicit version on save") + void testSaveTrimsExplicitVersion() { + JSONObject jdeploy = new JSONObject(); + jdeploy.put(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION, " 2.0.1 "); + panel.load(jdeploy); + + JSONObject out = new JSONObject(); + panel.save(out); + assertEquals("2.0.1", out.getString(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION)); + } + + // --- Require launcher update --- + + @Test + @DisplayName("Should persist requireLauncherUpdate only when a minimum is set") + void testRequireLauncherUpdateNeedsMinimum() { + JSONObject jdeploy = new JSONObject(); + // requireLauncherUpdate true but no minimum -> should be dropped + jdeploy.put(UpdateSettingsPanel.KEY_REQUIRE_LAUNCHER_UPDATE, true); + panel.load(jdeploy); + + JSONObject out = new JSONObject(); + panel.save(out); + assertFalse(out.has(UpdateSettingsPanel.KEY_REQUIRE_LAUNCHER_UPDATE), + "requireLauncherUpdate is meaningless without a minimum and should be removed"); + } + + @Test + @DisplayName("Should persist requireLauncherUpdate with an explicit minimum") + void testRequireLauncherUpdateWithMinimum() { + JSONObject jdeploy = new JSONObject(); + jdeploy.put(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION, "1.4.0"); + jdeploy.put(UpdateSettingsPanel.KEY_REQUIRE_LAUNCHER_UPDATE, true); + panel.load(jdeploy); + + JSONObject out = new JSONObject(); + panel.save(out); + assertTrue(out.getBoolean(UpdateSettingsPanel.KEY_REQUIRE_LAUNCHER_UPDATE)); + assertEquals("1.4.0", out.getString(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION)); + } + + // --- Round trip & isolation --- + + @Test + @DisplayName("Should round-trip a fully populated config without data loss") + void testFullRoundTrip() { + JSONObject jdeploy = new JSONObject(); + jdeploy.put(UpdateSettingsPanel.KEY_APP_UPDATE_MODE, "prompt"); + jdeploy.put(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION, "3.2.1"); + jdeploy.put(UpdateSettingsPanel.KEY_REQUIRE_LAUNCHER_UPDATE, true); + panel.load(jdeploy); + + JSONObject out = new JSONObject(); + panel.save(out); + assertEquals("prompt", out.getString(UpdateSettingsPanel.KEY_APP_UPDATE_MODE)); + assertEquals("3.2.1", out.getString(UpdateSettingsPanel.KEY_MIN_INITIAL_VERSION)); + assertTrue(out.getBoolean(UpdateSettingsPanel.KEY_REQUIRE_LAUNCHER_UPDATE)); + } + + @Test + @DisplayName("Should not affect unrelated jdeploy fields on save") + void testSaveDoesNotAffectOtherFields() { + JSONObject jdeploy = new JSONObject(); + jdeploy.put("name", "my-app"); + jdeploy.put(UpdateSettingsPanel.KEY_APP_UPDATE_MODE, "prompt"); + panel.load(jdeploy); + + panel.save(jdeploy); + assertEquals("my-app", jdeploy.getString("name")); + } + + @Test + @DisplayName("Should handle null jdeploy on save without throwing") + void testSaveNullJdeploy() { + panel.load(new JSONObject()); + panel.save(null); + } + + // --- Change listener --- + + @Test + @DisplayName("Should fire change listener when toggling prompt mode") + void testChangeListenerFires() { + int initial = changeListenerCallCount.get(); + panel.load(new JSONObject()); + JSONObject jdeploy = new JSONObject(); + jdeploy.put(UpdateSettingsPanel.KEY_APP_UPDATE_MODE, "prompt"); + panel.load(jdeploy); + assertTrue(changeListenerCallCount.get() > initial); + } +} diff --git a/cli/src/test/java/ca/weblite/jdeploy/publishing/BasePublishDriverSentinelTest.java b/cli/src/test/java/ca/weblite/jdeploy/publishing/BasePublishDriverSentinelTest.java new file mode 100644 index 00000000..0a28cf37 --- /dev/null +++ b/cli/src/test/java/ca/weblite/jdeploy/publishing/BasePublishDriverSentinelTest.java @@ -0,0 +1,75 @@ +package ca.weblite.jdeploy.publishing; + +import org.json.JSONObject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link BasePublishDriver#resolveMinInitialAppVersionSentinel} — the + * publish-time resolution of the "auto-set to latest" minimum initial app version. + */ +@DisplayName("BasePublishDriver minInitialAppVersion sentinel resolution") +public class BasePublishDriverSentinelTest { + + @Test + @DisplayName("Resolves the latest sentinel to the published version") + void testResolvesLatestSentinel() { + JSONObject packageJSON = new JSONObject(); + packageJSON.put("version", "2.5.0"); + JSONObject jdeploy = new JSONObject(); + jdeploy.put("minLauncherInitialAppVersionMode", "latest"); + packageJSON.put("jdeploy", jdeploy); + + BasePublishDriver.resolveMinInitialAppVersionSentinel(packageJSON, jdeploy); + + assertEquals("2.5.0", jdeploy.getString("minLauncherInitialAppVersion")); + assertFalse(jdeploy.has("minLauncherInitialAppVersionMode"), + "sentinel mode key should be removed after resolution"); + } + + @Test + @DisplayName("Leaves an explicit minimum version untouched") + void testLeavesExplicitVersion() { + JSONObject packageJSON = new JSONObject(); + packageJSON.put("version", "2.5.0"); + JSONObject jdeploy = new JSONObject(); + jdeploy.put("minLauncherInitialAppVersion", "1.0.0"); + packageJSON.put("jdeploy", jdeploy); + + BasePublishDriver.resolveMinInitialAppVersionSentinel(packageJSON, jdeploy); + + assertEquals("1.0.0", jdeploy.getString("minLauncherInitialAppVersion")); + assertFalse(jdeploy.has("minLauncherInitialAppVersionMode")); + } + + @Test + @DisplayName("Does nothing when no sentinel and no explicit version are present") + void testNoOpWhenAbsent() { + JSONObject packageJSON = new JSONObject(); + packageJSON.put("version", "2.5.0"); + JSONObject jdeploy = new JSONObject(); + packageJSON.put("jdeploy", jdeploy); + + BasePublishDriver.resolveMinInitialAppVersionSentinel(packageJSON, jdeploy); + + assertFalse(jdeploy.has("minLauncherInitialAppVersion")); + assertFalse(jdeploy.has("minLauncherInitialAppVersionMode")); + } + + @Test + @DisplayName("Does not resolve the sentinel when the version is missing") + void testNoVersion() { + JSONObject packageJSON = new JSONObject(); + JSONObject jdeploy = new JSONObject(); + jdeploy.put("minLauncherInitialAppVersionMode", "latest"); + packageJSON.put("jdeploy", jdeploy); + + BasePublishDriver.resolveMinInitialAppVersionSentinel(packageJSON, jdeploy); + + // With no version to substitute, the sentinel is preserved unresolved. + assertEquals("latest", jdeploy.getString("minLauncherInitialAppVersionMode")); + assertFalse(jdeploy.has("minLauncherInitialAppVersion")); + } +} diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java b/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java index badf7c84..7459c23f 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java @@ -1992,6 +1992,9 @@ private void install() throws Exception { appInfo().setInitialAppVersion(initialAppVersion); } + // Record the auto-update mode so it can be written to the user's preferences. + appInfo().setAppUpdateMode(npmPackageVersion().getAppUpdateMode()); + // Configure JCEF frameworks if using JBR with JCEF variant JCEFConfigurer.configureJCEF(bundlerSettings, npmPackageVersion()); @@ -2355,7 +2358,7 @@ private void install() throws Exception { // Save installer preferences for future installs try { new InstallerPreferencesService(fullyQualifiedPackageName) - .save(appInfo().getNpmVersion(), appInfo().isNpmAllowPrerelease()); + .save(appInfo().getNpmVersion(), appInfo().isNpmAllowPrerelease(), appInfo().getAppUpdateMode()); } catch (Exception e) { System.err.println("Warning: Failed to save installer preferences: " + e.getMessage()); } diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/npm/NPMPackageVersion.java b/installer/src/main/java/ca/weblite/jdeploy/installer/npm/NPMPackageVersion.java index 89ec0bb1..7d1a5376 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/npm/NPMPackageVersion.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/npm/NPMPackageVersion.java @@ -64,6 +64,16 @@ public String getVersion() { return version; } + /** + * The auto-update mode declared in the package's {@code jdeploy} config. + * + * @return {@code "prompt"} if the launcher should prompt before updating, otherwise + * {@code "auto"} (the default). + */ + public String getAppUpdateMode() { + return jdeploy().optString("appUpdateMode", "auto"); + } + public String getJavaVersion() { if (jdeploy().has("javaVersion")) { return jdeploy().getString("javaVersion"); diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/services/InstallerPreferencesService.java b/installer/src/main/java/ca/weblite/jdeploy/installer/services/InstallerPreferencesService.java index 48c9cf71..40c285c0 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/services/InstallerPreferencesService.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/services/InstallerPreferencesService.java @@ -16,6 +16,7 @@ public class InstallerPreferencesService { private static final String KEY_VERSION = "version"; private static final String KEY_PRERELEASE = "prerelease"; + private static final String KEY_APP_UPDATE_MODE = "app-update-mode"; private final File preferencesFile; @@ -34,10 +35,42 @@ public InstallerPreferencesService(String fullyQualifiedPackageName) { * @param prerelease the prerelease flag */ public void save(String version, boolean prerelease) { + save(version, prerelease, null); + } + + /** + * Saves the version, prerelease and auto-update mode preferences. + * + *

The preferences file is read first so that pre-existing keys are preserved + * (read-merge-write). The {@code app-update-mode} key is the value the launcher + * reads to decide whether to prompt before updating.

+ * + * @param version the computed version string (e.g., "latest", "^1", "~1.2", "1.2.3") + * @param prerelease the prerelease flag + * @param appUpdateMode the auto-update mode ("auto" or "prompt"). When {@code null} + * or empty, any existing {@code app-update-mode} value is left + * untouched. + */ + public void save(String version, boolean prerelease, String appUpdateMode) { Properties props = new Properties(); + if (preferencesFile.exists()) { + try (FileInputStream in = new FileInputStream(preferencesFile)) { + props.load(in); + } catch (IOException e) { + System.err.println("Warning: Failed to read existing installer preferences: " + e.getMessage()); + } + } + props.setProperty(KEY_VERSION, version); props.setProperty(KEY_PRERELEASE, String.valueOf(prerelease)); + // Only write the mode when the published metadata specifies one. Writing it + // explicitly (auto or prompt) means switching modes between releases works; + // a null/empty value leaves any existing preference in place. + if (appUpdateMode != null && !appUpdateMode.isEmpty()) { + props.setProperty(KEY_APP_UPDATE_MODE, appUpdateMode); + } + preferencesFile.getParentFile().mkdirs(); try (FileOutputStream out = new FileOutputStream(preferencesFile)) { props.store(out, "jDeploy Installer Preferences"); diff --git a/installer/src/test/java/ca/weblite/jdeploy/installer/services/InstallerPreferencesServiceTest.java b/installer/src/test/java/ca/weblite/jdeploy/installer/services/InstallerPreferencesServiceTest.java new file mode 100644 index 00000000..b6ff304a --- /dev/null +++ b/installer/src/test/java/ca/weblite/jdeploy/installer/services/InstallerPreferencesServiceTest.java @@ -0,0 +1,119 @@ +package ca.weblite.jdeploy.installer.services; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.FileInputStream; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests that {@link InstallerPreferencesService} writes the {@code app-update-mode} + * preference (consumed by the launcher) to preferences.properties. + */ +public class InstallerPreferencesServiceTest { + + private String originalUserHome; + private File tempHome; + + @BeforeEach + public void setUp() throws Exception { + originalUserHome = System.getProperty("user.home"); + tempHome = File.createTempFile("jdeploy-prefs-test", ""); + assertTrue(tempHome.delete()); + assertTrue(tempHome.mkdirs()); + System.setProperty("user.home", tempHome.getAbsolutePath()); + } + + @AfterEach + public void tearDown() { + if (originalUserHome != null) { + System.setProperty("user.home", originalUserHome); + } + } + + private File prefsFileFor(String fqpn) { + return new File(tempHome, ".jdeploy" + File.separator + "preferences" + + File.separator + fqpn + File.separator + "preferences.properties"); + } + + private Properties read(File f) throws Exception { + Properties props = new Properties(); + try (FileInputStream in = new FileInputStream(f)) { + props.load(in); + } + return props; + } + + @Test + public void testWritesAppUpdateModePrompt() throws Exception { + String fqpn = "my-app"; + new InstallerPreferencesService(fqpn).save("latest", false, "prompt"); + + File f = prefsFileFor(fqpn); + assertTrue(f.exists(), "preferences.properties should be created"); + + Properties props = read(f); + assertEquals("prompt", props.getProperty("app-update-mode")); + assertEquals("latest", props.getProperty("version")); + assertEquals("false", props.getProperty("prerelease")); + } + + @Test + public void testWritesAppUpdateModeAuto() throws Exception { + String fqpn = "my-app"; + new InstallerPreferencesService(fqpn).save("latest", true, "auto"); + + Properties props = read(prefsFileFor(fqpn)); + assertEquals("auto", props.getProperty("app-update-mode")); + assertEquals("true", props.getProperty("prerelease")); + } + + @Test + public void testNullModeDoesNotWriteKey() throws Exception { + String fqpn = "my-app"; + new InstallerPreferencesService(fqpn).save("latest", false, null); + + Properties props = read(prefsFileFor(fqpn)); + assertNull(props.getProperty("app-update-mode")); + } + + @Test + public void testEmptyModeDoesNotWriteKey() throws Exception { + String fqpn = "my-app"; + new InstallerPreferencesService(fqpn).save("latest", false, ""); + + Properties props = read(prefsFileFor(fqpn)); + assertNull(props.getProperty("app-update-mode")); + } + + @Test + public void testPreservesExistingModeWhenNullOnReinstall() throws Exception { + String fqpn = "my-app"; + InstallerPreferencesService service = new InstallerPreferencesService(fqpn); + + // First install sets prompt. + service.save("latest", false, "prompt"); + // A subsequent save where the published metadata carries no mode (null) must + // not clobber the existing user preference. + service.save("^1", true, null); + + Properties props = read(prefsFileFor(fqpn)); + assertEquals("prompt", props.getProperty("app-update-mode")); + assertEquals("^1", props.getProperty("version")); + assertEquals("true", props.getProperty("prerelease")); + } + + @Test + public void testTwoArgSaveStillWorks() throws Exception { + String fqpn = "my-app"; + new InstallerPreferencesService(fqpn).save("latest", false); + + Properties props = read(prefsFileFor(fqpn)); + assertEquals("latest", props.getProperty("version")); + assertNull(props.getProperty("app-update-mode")); + } +} diff --git a/plans/update-settings-panel-plan.md b/plans/update-settings-panel-plan.md new file mode 100644 index 00000000..c6c52f94 --- /dev/null +++ b/plans/update-settings-panel-plan.md @@ -0,0 +1,218 @@ +# Plan: "Update Settings" panel in jDeploy GUI + +Expose two launcher-update features (already supported by the client4jgo launcher) +as first-class, GUI-editable settings in jDeploy: + +1. **Auto-update mode** — `auto` (silent update on launch, the default) vs `prompt` + (ask the user on launch when an update is available). +2. **Minimum initial app version** — forces users on an older launcher to download a + fresh installer + do a full update. Either a hard-coded version or auto-set to the + published version at publish time. + +Plus one companion field confirmed in scope: **Require full launcher update** +(`requireLauncherUpdate`). + +Out of scope for this PR (offered, not selected): exposing `minLauncherVersion` in the +panel, and stamping `app-update-mode` into app.xml. + +--- + +## How the launcher consumes these (from client4jgo research) + +| Concept | Launcher reads from | Exact key/attr | Values | +|---|---|---|---| +| Auto-update mode | `~/.jdeploy/preferences//preferences.properties` **and** `app.xml` attr | `app-update-mode` | `auto` / `prompt` (only literal `prompt` => prompt; everything else => auto) | +| Min initial app version | published version's `jdeploy` metadata | `minLauncherInitialAppVersion` | semver string | +| Require launcher update | published `jdeploy` metadata | `requireLauncherUpdate` | bool | +| Min launcher version | published `jdeploy` metadata | `minLauncherVersion` | semver string | +| Initial app version (stamped) | `app.xml` attr | `initial-app-version` | already emitted by `LauncherWriterHelper` | + +`` = package name for npm, or `md5(source) + "." + packageName` for GitHub +(jDeploy already computes this identically — `Main.java:466`, and +`InstallerPreferencesService` already writes that exact prefs file). + +### Decisions taken +- Auto-update mode is delivered by the **installer writing `preferences.properties`** + (not baking into app.xml). +- Min-version "auto" mode is a **sentinel in package.json, resolved to the concrete + version in the *published* package.json at publish time** (developer's source file is + never rewritten). +- package.json key for the mode is **`appUpdateMode`** (camelCase, consistent with the + other `jdeploy.*` keys); the installer translates it to the launcher's + `app-update-mode` prefs key. +- Companion field **`requireLauncherUpdate`** is in scope. `minLauncherVersion` is not. +- We are **not** also stamping `app-update-mode` into app.xml in this PR. + +--- + +## package.json schema (under the `jdeploy` object) + +```jsonc +"jdeploy": { + "appUpdateMode": "prompt", // "auto" (default, omitted) | "prompt" + "minLauncherInitialAppVersion": "1.4.0", // explicit version + // OR auto mode (mutually exclusive with an explicit version): + "minLauncherInitialAppVersionMode": "latest", + "requireLauncherUpdate": true // optional +} +``` + +At publish time, if `minLauncherInitialAppVersionMode == "latest"`, jDeploy writes +`minLauncherInitialAppVersion = ` into the **published** +package.json and drops the sentinel. The on-disk source package.json keeps the sentinel. + +--- + +## Implementation + +### Phase 1 — GUI panel (cli module) + +**New file:** `cli/src/main/java/ca/weblite/jdeploy/gui/tabs/UpdateSettingsPanel.java` +Modeled on `CheerpJSettingsPanel` (checkbox + nested fields, raw `JSONObject` load/save, +`SwingUtils.addChangeListenerTo` for text fields, `ItemListener` for toggles/radios). + +Controls: +- **Auto-update behaviour** — radio group / combo: "Update automatically on launch + (default)" vs "Prompt me before updating". A short explanatory label. Maps to + `appUpdateMode` (`auto` => remove key; `prompt` => `jdeploy.put("appUpdateMode","prompt")`). +- **Minimum initial app version** — radio group: + - "No minimum" (default) — remove both keys. + - "Auto-set to latest on publish" — `minLauncherInitialAppVersionMode = "latest"`, + remove explicit key. + - "Require at least:" + text field — `minLauncherInitialAppVersion = `, + remove the mode key. + Plus a one-paragraph explanation of what it does (forces full launcher reinstall for + users below the threshold). +- **Require full launcher update** checkbox — `requireLauncherUpdate` (true => set; + false => remove). + +`load(JSONObject jdeploy)` / `save(JSONObject jdeploy)` follow the established +remove-when-default convention; `getRoot()` returns the panel; `addChangeListener` +stores the listener and fires on every edit. + +**Register the panel:** in `JDeployProjectEditor.createPanelRegistry()` (~line 506-704), +add a field `private UpdateSettingsPanel updateSettingsPanel;` and a registration block: + +```java +updateSettingsPanel = new UpdateSettingsPanel(); +registry.register(NavigablePanelAdapter.forJdeployPanel( + "Updates", + MenuBarBuilder.JDEPLOY_WEBSITE_URL + "docs/help/#updates", + FontIcon.of(Material.SYSTEM_UPDATE), + updateSettingsPanel.getRoot(), + json -> updateSettingsPanel.load(json), + json -> updateSettingsPanel.save(json), + listener -> updateSettingsPanel.addChangeListener(listener) +)); +``` + +Load/save/dirty-tracking/file-write are all automatic via the registry — no changes to +`handleSave` or the JSON I/O are required. + +### Phase 2 — Model accessors (shared module, optional but recommended) + +Add typed getters to +`shared/src/main/java/ca/weblite/jdeploy/models/JDeployProject.java` for non-GUI +consumers (publish + installer): +`getAppUpdateMode()`, `getMinLauncherInitialAppVersion()`, +`getMinLauncherInitialAppVersionMode()`, `isRequireLauncherUpdate()` — each reading +from the `jdeploy` object with sensible defaults, matching the existing `isSingleton()` +pattern. + +### Phase 3 — Publish-time sentinel resolution (cli module) + +In `cli/src/main/java/ca/weblite/jdeploy/publishing/BasePublishDriver.java` +`prepareDirectory(...)` (where it already does +`jdeployObj = packageJSON.getJSONObject("jdeploy")` and writes the published +package.json): + +```java +if ("latest".equals(jdeployObj.optString("minLauncherInitialAppVersionMode", ""))) { + jdeployObj.put("minLauncherInitialAppVersion", packageJSON.getString("version")); + jdeployObj.remove("minLauncherInitialAppVersionMode"); +} +``` + +This only touches the published copy in `publishDir`, exactly like the existing +`commandName` mutation. `appUpdateMode` and `requireLauncherUpdate` +are already present verbatim and need no transformation here — the launcher reads +`minLauncherInitialAppVersion`/`requireLauncherUpdate` straight from the published +metadata. + +### Phase 4 — Installer writes `app-update-mode` to preferences (installer + read path) + +**a. Read the mode from app.xml/package metadata into AppInfo.** +- Add `appUpdateMode` field + getter/setter to + `shared/src/main/java/ca/weblite/jdeploy/app/AppInfo.java`. +- Expose it from the published metadata reader + `installer/.../npm/NPMPackageVersion.java` (e.g. `getAppUpdateMode()` reading + `jdeploy().optString("appUpdateMode","auto")`). +- In `installer/.../Main.java` (near the `initialAppVersion`/`launcherVersion` block + ~line 1983-1993), set `appInfo().setAppUpdateMode(npmPackageVersion().getAppUpdateMode())`. + +**b. Persist it to preferences at install time.** +Extend `installer/.../services/InstallerPreferencesService.java`: +- Add `KEY_APP_UPDATE_MODE = "app-update-mode"`. +- Add an overload `save(String version, boolean prerelease, String appUpdateMode)` that + also writes the `app-update-mode` line (only when `prompt`; for `auto`/empty, leave it + absent or write `auto` — both resolve to auto in the launcher). This service already + writes to the exact `~/.jdeploy/preferences//preferences.properties` file the + launcher reads, so no path/FQPN logic is new. +- Update the existing call site in `Main.java` (~line 2356-2358) to pass + `appInfo().getAppUpdateMode()`. +- Preserve `app-update-mode` across reinstalls when the published value is unset + (don't clobber a user's existing pref unnecessarily — read-merge-write). + +> Note: the launcher's properties reader is a hand-rolled parser; Java's +> `Properties.store` escapes `=`/`:` and writes a date comment, but plain +> `key=value` lines (and `#` comments) are read fine, so the existing +> `Properties`-based writer is compatible. Keep values simple (`auto`/`prompt`). + +### Phase 5 — Tests + +- **cli:** unit test for `UpdateSettingsPanel.load/save` round-trips (mode, the three + min-version states incl. sentinel, requireLauncherUpdate; + remove-when-default behaviour). Test publish sentinel resolution in + `BasePublishDriver` (or via an existing mock-network publish test) — `latest` => + concrete version in published package.json, source untouched. +- **installer:** unit test that `InstallerPreferencesService` writes + `app-update-mode=prompt` and that `auto` leaves it absent/auto; round-trip read. +- **shared:** `JDeployProject` getter tests for the new keys. + +### Phase 6 — Docs + +- Update the jDeploy help docs page anchor referenced by the panel (`#updates`). +- Note the new `jdeploy.*` keys in any package.json reference docs. +- Optionally update `client4jgo/xsd/app.xsd` (out of date — missing `app-update-mode`) + if app.xml stamping is added later; not required for this PR. + +--- + +## Files touched (summary) + +**jdeploy / cli** +- `gui/tabs/UpdateSettingsPanel.java` (new) +- `gui/JDeployProjectEditor.java` (register panel) +- `publishing/BasePublishDriver.java` (sentinel resolution) + +**jdeploy / shared** +- `app/AppInfo.java` (appUpdateMode field) +- `models/JDeployProject.java` (typed getters) + +**jdeploy / installer** +- `npm/NPMPackageVersion.java` (getAppUpdateMode) +- `Main.java` (read mode, pass to prefs save) +- `services/InstallerPreferencesService.java` (write app-update-mode) + +**Tests** across cli/installer/shared as above. + +No client4jgo changes are required — it already consumes every key/attribute involved. + +## Risks / notes +- `minLauncherInitialAppVersion` is enforced *per published version's* metadata, so it + only affects users when a **new** version carries the constraint — correct behaviour + (a future release says "you must be on launcher initial-app-version >= X"). +- The XSD in client4jgo lacks `app-update-mode`; harmless today (we use prefs, not the + attr) but worth fixing if app.xml stamping is added later. +- `appUpdateMode=prompt` only triggers a prompt on GUI/first-launch paths and when an + update is actually available; headless stays silent — matches launcher logic. diff --git a/shared/src/main/java/ca/weblite/jdeploy/app/AppInfo.java b/shared/src/main/java/ca/weblite/jdeploy/app/AppInfo.java index 9c0d46ce..71f1e9f3 100644 --- a/shared/src/main/java/ca/weblite/jdeploy/app/AppInfo.java +++ b/shared/src/main/java/ca/weblite/jdeploy/app/AppInfo.java @@ -82,6 +82,8 @@ public class AppInfo { */ private String initialAppVersion; + private String appUpdateMode; + private Map documentMimetypes; private Map documentTypeIcons; @@ -1712,4 +1714,15 @@ public String getInitialAppVersion() { public void setInitialAppVersion(String initialAppVersion) { this.initialAppVersion = initialAppVersion; } + + /** + * The auto-update mode for the launcher ({@code "auto"} or {@code "prompt"}). + */ + public String getAppUpdateMode() { + return appUpdateMode; + } + + public void setAppUpdateMode(String appUpdateMode) { + this.appUpdateMode = appUpdateMode; + } } diff --git a/shared/src/main/java/ca/weblite/jdeploy/models/JDeployProject.java b/shared/src/main/java/ca/weblite/jdeploy/models/JDeployProject.java index 450a178e..47d7d614 100644 --- a/shared/src/main/java/ca/weblite/jdeploy/models/JDeployProject.java +++ b/shared/src/main/java/ca/weblite/jdeploy/models/JDeployProject.java @@ -143,4 +143,39 @@ public void setSingleton(boolean singleton) { jdeployConfig.remove("singleton"); } } + + /** + * The auto-update mode for the launcher. + * + * @return {@code "prompt"} if the launcher should prompt before updating, otherwise + * {@code "auto"} (the default — silent update on launch). + */ + public String getAppUpdateMode() { + return getJDeployConfig().optString("appUpdateMode", "auto"); + } + + /** + * The minimum initial app version required to run new releases, or empty if none. + * Users whose initial app version is below this are forced to perform a full update. + */ + public String getMinLauncherInitialAppVersion() { + return getJDeployConfig().optString("minLauncherInitialAppVersion", ""); + } + + /** + * The sentinel mode controlling how the minimum initial app version is resolved at + * publish time. A value of {@code "latest"} means jDeploy should substitute the + * version being published. Empty when an explicit version (or no minimum) is used. + */ + public String getMinLauncherInitialAppVersionMode() { + return getJDeployConfig().optString("minLauncherInitialAppVersionMode", ""); + } + + /** + * Whether users below the minimum initial app version must perform a full launcher + * update before they can run newer releases. + */ + public boolean isRequireLauncherUpdate() { + return getJDeployConfig().optBoolean("requireLauncherUpdate", false); + } } diff --git a/shared/src/main/resources/com/joshondesign/appbundler/linux/arm64/Client4JLauncher b/shared/src/main/resources/com/joshondesign/appbundler/linux/arm64/Client4JLauncher index ba500376..7a1b2555 100755 Binary files a/shared/src/main/resources/com/joshondesign/appbundler/linux/arm64/Client4JLauncher and b/shared/src/main/resources/com/joshondesign/appbundler/linux/arm64/Client4JLauncher differ diff --git a/shared/src/main/resources/com/joshondesign/appbundler/linux/x64/Client4JLauncher b/shared/src/main/resources/com/joshondesign/appbundler/linux/x64/Client4JLauncher index 1b24313c..a2bb4dc7 100644 Binary files a/shared/src/main/resources/com/joshondesign/appbundler/linux/x64/Client4JLauncher and b/shared/src/main/resources/com/joshondesign/appbundler/linux/x64/Client4JLauncher differ diff --git a/shared/src/main/resources/com/joshondesign/appbundler/mac/arm64/Client4JLauncher b/shared/src/main/resources/com/joshondesign/appbundler/mac/arm64/Client4JLauncher index 9b4cbd00..c423b95b 100644 Binary files a/shared/src/main/resources/com/joshondesign/appbundler/mac/arm64/Client4JLauncher and b/shared/src/main/resources/com/joshondesign/appbundler/mac/arm64/Client4JLauncher differ diff --git a/shared/src/main/resources/com/joshondesign/appbundler/mac/x64/Client4JLauncher b/shared/src/main/resources/com/joshondesign/appbundler/mac/x64/Client4JLauncher index 14fa591f..c6f340a7 100644 Binary files a/shared/src/main/resources/com/joshondesign/appbundler/mac/x64/Client4JLauncher and b/shared/src/main/resources/com/joshondesign/appbundler/mac/x64/Client4JLauncher differ diff --git a/shared/src/main/resources/com/joshondesign/appbundler/win/arm64/Client4JLauncher.exe b/shared/src/main/resources/com/joshondesign/appbundler/win/arm64/Client4JLauncher.exe index b0af97bf..34dd3ecf 100644 Binary files a/shared/src/main/resources/com/joshondesign/appbundler/win/arm64/Client4JLauncher.exe and b/shared/src/main/resources/com/joshondesign/appbundler/win/arm64/Client4JLauncher.exe differ diff --git a/shared/src/main/resources/com/joshondesign/appbundler/win/x64/Client4JLauncher.exe b/shared/src/main/resources/com/joshondesign/appbundler/win/x64/Client4JLauncher.exe index 6583a42c..d928c649 100644 Binary files a/shared/src/main/resources/com/joshondesign/appbundler/win/x64/Client4JLauncher.exe and b/shared/src/main/resources/com/joshondesign/appbundler/win/x64/Client4JLauncher.exe differ diff --git a/shared/src/test/java/ca/weblite/jdeploy/models/JDeployProjectUpdateSettingsTest.java b/shared/src/test/java/ca/weblite/jdeploy/models/JDeployProjectUpdateSettingsTest.java new file mode 100644 index 00000000..c39382e2 --- /dev/null +++ b/shared/src/test/java/ca/weblite/jdeploy/models/JDeployProjectUpdateSettingsTest.java @@ -0,0 +1,95 @@ +package ca.weblite.jdeploy.models; + +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for update-settings accessors in JDeployProject (appUpdateMode, + * minLauncherInitialAppVersion, minLauncherInitialAppVersionMode, requireLauncherUpdate). + */ +public class JDeployProjectUpdateSettingsTest { + + private JDeployProject project; + private JSONObject packageJSON; + + @BeforeEach + public void setUp() { + packageJSON = new JSONObject(); + } + + private void createProject() { + project = new JDeployProject(Paths.get("package.json"), packageJSON); + } + + private JSONObject jdeploy() { + JSONObject jdeploy = new JSONObject(); + packageJSON.put("jdeploy", jdeploy); + return jdeploy; + } + + @Test + public void testAppUpdateMode_DefaultAuto() { + createProject(); + assertEquals("auto", project.getAppUpdateMode()); + } + + @Test + public void testAppUpdateMode_Prompt() { + jdeploy().put("appUpdateMode", "prompt"); + createProject(); + assertEquals("prompt", project.getAppUpdateMode()); + } + + @Test + public void testMinLauncherInitialAppVersion_DefaultEmpty() { + createProject(); + assertEquals("", project.getMinLauncherInitialAppVersion()); + } + + @Test + public void testMinLauncherInitialAppVersion_Explicit() { + jdeploy().put("minLauncherInitialAppVersion", "1.4.0"); + createProject(); + assertEquals("1.4.0", project.getMinLauncherInitialAppVersion()); + } + + @Test + public void testMinLauncherInitialAppVersionMode_DefaultEmpty() { + createProject(); + assertEquals("", project.getMinLauncherInitialAppVersionMode()); + } + + @Test + public void testMinLauncherInitialAppVersionMode_Latest() { + jdeploy().put("minLauncherInitialAppVersionMode", "latest"); + createProject(); + assertEquals("latest", project.getMinLauncherInitialAppVersionMode()); + } + + @Test + public void testRequireLauncherUpdate_DefaultFalse() { + createProject(); + assertFalse(project.isRequireLauncherUpdate()); + } + + @Test + public void testRequireLauncherUpdate_True() { + jdeploy().put("requireLauncherUpdate", true); + createProject(); + assertTrue(project.isRequireLauncherUpdate()); + } + + @Test + public void testDefaults_WhenNoJdeployObject() { + createProject(); + assertEquals("auto", project.getAppUpdateMode()); + assertEquals("", project.getMinLauncherInitialAppVersion()); + assertEquals("", project.getMinLauncherInitialAppVersionMode()); + assertFalse(project.isRequireLauncherUpdate()); + } +} diff --git a/test_installer_debug.sh b/test_installer_debug.sh new file mode 100755 index 00000000..5daf1072 --- /dev/null +++ b/test_installer_debug.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Manually test the jDeploy installer. +# +# Replicates the "JDeploy Installer" IntelliJ run configuration: +# Module classpath: jdeploy-installer +# VM options: -Djdeploy.registry.url="https://dev.jdeploy.com/" +# Main class: ca.weblite.jdeploy.installer.MainDebug +# Program args: 26AD 1.0.15 +# Working directory: +# Environment: JDEPLOY_DEBUG=1 +# +# Usage: +# ./test_installer_debug.sh # defaults: code=26AD version=1.0.15 +# ./test_installer_debug.sh [version] # e.g. ./test_installer_debug.sh 26AD 1.0.16 +# ./test_installer_debug.sh install # headless install mode +# ./test_installer_debug.sh --build [args...] # force rebuild of installer module first +# +# Environment overrides: +# JDEPLOY_REGISTRY_URL registry to download the bundle from (default https://dev.jdeploy.com/) +# JAVA_HOME JDK to use (default: JDK 1.8 located via /usr/libexec/java_home) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# The IntelliJ run config uses Java 1.8. Locate a JDK 8 unless JAVA_HOME is +# already a JDK (current JAVA_HOME may be a jDeploy-bundled JRE with no javac). +if [ ! -x "$JAVA_HOME/bin/javac" ]; then + if command -v /usr/libexec/java_home >/dev/null 2>&1; then + JAVA_HOME="$(/usr/libexec/java_home -v 1.8 2>/dev/null || /usr/libexec/java_home)" + export JAVA_HOME + fi +fi +export PATH="$JAVA_HOME/bin:$PATH" +echo "Using JAVA_HOME: $JAVA_HOME" + +REGISTRY_URL="${JDEPLOY_REGISTRY_URL:-https://dev.jdeploy.com/}" + +FORCE_BUILD=0 +if [ "$1" = "--build" ]; then + FORCE_BUILD=1 + shift +fi + +CODE="${1:-26AD}" +VERSION="${2:-1.0.15}" +shift 2 2>/dev/null || shift $# 2>/dev/null || true + +CLASSES_DIR="$SCRIPT_DIR/installer/target/classes" +LIBS_DIR="$SCRIPT_DIR/installer/target/libs" + +# Build the installer module (and its jdeploy-shared dependency) if needed. +# The maven-dependency-plugin copies runtime deps into target/libs during 'package'. +if [ "$FORCE_BUILD" = "1" ] || [ ! -d "$CLASSES_DIR" ] || [ ! -d "$LIBS_DIR" ]; then + echo "Building jdeploy-installer module..." + mvn -q -pl installer -am package -DskipTests +fi + +echo "Registry: $REGISTRY_URL" +echo "Code: $CODE" +echo "Version: $VERSION" +echo + +JDEPLOY_DEBUG=1 java \ + -Djdeploy.registry.url="$REGISTRY_URL" \ + -cp "$CLASSES_DIR:$LIBS_DIR/*" \ + ca.weblite.jdeploy.installer.MainDebug \ + "$CODE" "$VERSION" "$@" diff --git a/test_jdeploy_gui.sh b/test_jdeploy_gui.sh new file mode 100755 index 00000000..6a93463c --- /dev/null +++ b/test_jdeploy_gui.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Launch the jDeploy GUI for manual testing. +# +# Replicates the "JDeploy" IntelliJ run configuration: +# Module classpath: jdeploy-cli +# Main class: ca.weblite.jdeploy.JDeploy +# Program args: (none) +# Working directory: /Users/shannah/jdeploy-demos/jdeploy-service-example +# +# Usage: +# ./test_jdeploy_gui.sh # open the default demo project +# ./test_jdeploy_gui.sh # open a different project +# ./test_jdeploy_gui.sh --build [project-dir] # force rebuild of cli module first +# +# Environment overrides: +# JDEPLOY_PROJECT_DIR project to open (same as passing ) +# JAVA_HOME JDK to use (default: JDK 1.8 located via /usr/libexec/java_home) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# The IntelliJ run config uses Java 8. Locate a JDK 8 unless JAVA_HOME is +# already a JDK (current JAVA_HOME may be a jDeploy-bundled JRE with no javac). +if [ ! -x "$JAVA_HOME/bin/javac" ]; then + if command -v /usr/libexec/java_home >/dev/null 2>&1; then + JAVA_HOME="$(/usr/libexec/java_home -v 1.8 2>/dev/null || /usr/libexec/java_home)" + export JAVA_HOME + fi +fi +export PATH="$JAVA_HOME/bin:$PATH" +echo "Using JAVA_HOME: $JAVA_HOME" + +FORCE_BUILD=0 +if [ "$1" = "--build" ]; then + FORCE_BUILD=1 + shift +fi + +PROJECT_DIR="${1:-${JDEPLOY_PROJECT_DIR:-/Users/shannah/jdeploy-demos/jdeploy-service-example}}" + +if [ ! -d "$PROJECT_DIR" ]; then + echo "Error: project directory does not exist: $PROJECT_DIR" >&2 + exit 1 +fi + +CLASSES_DIR="$SCRIPT_DIR/cli/target/classes" +LIBS_DIR="$SCRIPT_DIR/cli/target/libs" + +# Build the cli module (and its jdeploy-shared/jdeploy-installer dependencies) +# if needed. The maven-dependency-plugin copies runtime deps into target/libs +# during 'package'. +if [ "$FORCE_BUILD" = "1" ] || [ ! -d "$CLASSES_DIR" ] || [ ! -d "$LIBS_DIR" ]; then + echo "Building jdeploy-cli module..." + (cd "$SCRIPT_DIR" && mvn -q -pl cli -am package -DskipTests) +fi + +echo "Project: $PROJECT_DIR" +echo + +cd "$PROJECT_DIR" +java \ + -cp "$CLASSES_DIR:$LIBS_DIR/*" \ + ca.weblite.jdeploy.JDeploy