From d93e08240cdbac8cc578fc9a483bc4edac7ee2ca Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 13 May 2026 04:48:52 +0300 Subject: [PATCH] simulator: guard window-bounds prefs against corruption from Larger Text scaling Picking Simulate -> Larger Text -> Extra Extra Extra Large (and the larger Accessibility steps) could collapse the simulator frame; refreshSkin()'s pack() produced an unusably small geometry that the existing componentResized listener wrote straight into prefs. The next launch restored the bad bounds, leaving the window stuck. Add a shared validator (JavaSEPort.isUsableWindowBounds) used on both write and read sides to reject degenerate, oversize, or off-screen rectangles, and extract parsePersistedBounds so a malformed pref no longer aborts init() with a NumberFormatException. On read, corrupt entries are removed so a single bad event does not survive across launches. AppPanel applies the same guards to its per-panel preferredWindowBounds keys. Adds JavaSEPortWindowBoundsTest covering null/tiny/huge/off-screen inputs, parse rejection of malformed strings, and a write/parse roundtrip. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/codename1/impl/javase/JavaSEPort.java | 106 ++++++++++-- .../impl/javase/simulator/AppPanel.java | 27 +++- .../javase/JavaSEPortWindowBoundsTest.java | 151 ++++++++++++++++++ 3 files changed, 267 insertions(+), 17 deletions(-) create mode 100644 maven/javase/src/test/java/com/codename1/impl/javase/JavaSEPortWindowBoundsTest.java diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index eb83a51e5e..b50eca6335 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -206,6 +206,16 @@ public class JavaSEPort extends CodenameOneImplementation { private boolean largerTextEnabled = false; private static final String PREF_LARGER_TEXT_SCALE = "cn1.simulator.largerTextScale"; + // Floor below which any persisted window dimension is treated as the + // product of a layout glitch (eg. a pack() with a collapsed canvas, an + // iconified frame, or hand-edited prefs) rather than a real user state. + static final int MIN_PERSISTED_WINDOW_DIMENSION = 100; + + // Defensive ceiling so absurd values from a corrupted pref or runaway + // scaling math cannot make a subsequent setBounds() throw or place the + // frame off every reachable display. + static final int MAX_PERSISTED_WINDOW_DIMENSION = 32767; + static { IOS_NATIVE_FONT_CANDIDATES.put("native:MainThin", new String[] { "SF Pro Display", "SF Pro Text", @@ -5825,6 +5835,75 @@ public void run() { }); } + /** + * Returns true when {@code r} represents a window position we are willing + * to persist or restore. Rejects null, NaN-equivalent (non-positive) sizes, + * sub-floor dimensions, oversized values, and frames that no longer have a + * useful overlap with any connected display. Any caller that fails this + * check should fall back to default bounds and remove the corrupt pref so + * the bad state does not survive across launches. + */ + public static boolean isUsableWindowBounds(Rectangle r) { + if (r == null) { + return false; + } + if (r.width < MIN_PERSISTED_WINDOW_DIMENSION || r.height < MIN_PERSISTED_WINDOW_DIMENSION) { + return false; + } + if (r.width > MAX_PERSISTED_WINDOW_DIMENSION || r.height > MAX_PERSISTED_WINDOW_DIMENSION) { + return false; + } + try { + if (GraphicsEnvironment.isHeadless()) { + // No screens to validate against; size has already cleared the + // sanity floor, so accept. The simulator is not really expected + // to run headless, but tests do. + return true; + } + GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment(); + GraphicsDevice[] devs = ge.getScreenDevices(); + if (devs == null || devs.length == 0) { + return true; + } + Rectangle union = new Rectangle(0, 0, 0, 0); + for (GraphicsDevice gd : devs) { + union.add(gd.getDefaultConfiguration().getBounds()); + } + Rectangle visible = union.intersection(r); + // Require enough overlap that the title bar and drag region remain + // reachable; a one-pixel touch is not good enough to "remember". + return visible.width >= MIN_PERSISTED_WINDOW_DIMENSION && visible.height >= 50; + } catch (Throwable t) { + return false; + } + } + + /** + * Parses the comma-separated {@code "x,y,width,height"} form written to + * {@code window.bounds}. Returns {@code null} when the input is missing, + * has the wrong field count, or contains non-numeric data. The bounds + * returned have not been validated for screen overlap or dimensions; + * pair with {@link #isUsableWindowBounds(Rectangle)}. + */ + static Rectangle parsePersistedBounds(String s) { + if (s == null) { + return null; + } + try { + String[] parts = s.split(","); + if (parts.length != 4) { + return null; + } + return new Rectangle( + Integer.parseInt(parts[0].trim()), + Integer.parseInt(parts[1].trim()), + Integer.parseInt(parts[2].trim()), + Integer.parseInt(parts[3].trim())); + } catch (NumberFormatException e) { + return null; + } + } + private ArrayList deinitializeHooks = new ArrayList<>(); public void addDeinitializeHook(Runnable r) { @@ -6249,9 +6328,17 @@ public void windowDeactivated(WindowEvent e) { private void saveBounds(ComponentEvent e) { if (e.getComponent() instanceof JFrame) { Frame f = (JFrame)e.getComponent(); + // The NORMAL check filters maximized/iconified states, + // but a misbehaving pack() can still produce tiny or + // off-screen geometry while remaining NORMAL. Run the + // shared sanity check so a broken layout pass cannot + // corrupt the persisted bounds. if (f.getExtendedState() == JFrame.NORMAL) { - Preferences pref = Preferences.userNodeForPackage(JavaSEPort.class); Rectangle bounds = f.getBounds(); + if (!isUsableWindowBounds(bounds)) { + return; + } + Preferences pref = Preferences.userNodeForPackage(JavaSEPort.class); pref.put("window.bounds", bounds.x+","+bounds.y+","+bounds.width+","+bounds.height); } } @@ -6360,18 +6447,13 @@ public void componentHidden(ComponentEvent e) { } String lastBounds = pref.get("window.bounds", null); if (lastBounds != null) { - String[] parts = lastBounds.split(","); - Rectangle r = new Rectangle(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2]), Integer.parseInt(parts[3])); - Rectangle bounds = new Rectangle(0, 0, 0, 0); - GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment(); - GraphicsDevice lstGDs[] = ge.getScreenDevices(); - for (GraphicsDevice gd : lstGDs) { - bounds.add(gd.getDefaultConfiguration().getBounds()); - } - - if (bounds.intersects(r)) { - + Rectangle r = parsePersistedBounds(lastBounds); + if (isUsableWindowBounds(r)) { window.setBounds(r); + } else { + // Drop the bad value so a single bad pack() cannot lock the + // user out across restarts. + pref.remove("window.bounds"); } } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/AppPanel.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/AppPanel.java index 169657afb9..935da25fb8 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/AppPanel.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/AppPanel.java @@ -311,6 +311,11 @@ public void savePreferences(AppFrame frame, Preferences prefs) { JFrame window = frame.getWindow(this); if (window != null) { Rectangle r = window.getBounds(); + // Skip persistence when the geometry is degenerate: a layout + // glitch or minimized frame must not corrupt the saved state. + if (!JavaSEPort.isUsableWindowBounds(r)) { + return; + } prefs.putInt(getPreferencesPrefix(frame) + "preferredWindowBounds.x", r.x); prefs.putInt(getPreferencesPrefix(frame) + "preferredWindowBounds.y", r.y); prefs.putInt(getPreferencesPrefix(frame) + "preferredWindowBounds.width", r.width); @@ -327,11 +332,23 @@ public void applyPreferences(AppFrame frame, Preferences prefs) { setPreferredFrame(AppFrame.FrameLocation.valueOf(preferredFrameName)); } catch (IllegalArgumentException ex){} } - preferredWindowBounds = new Rectangle(0, 0, getPreferredSize().width, getPreferredSize().height); - preferredWindowBounds.x = prefs.getInt(getPreferencesPrefix(frame)+"preferredWindowBounds.x", preferredWindowBounds.x); - preferredWindowBounds.y = prefs.getInt(getPreferencesPrefix(frame)+"preferredWindowBounds.y", preferredWindowBounds.y); - preferredWindowBounds.width = prefs.getInt(getPreferencesPrefix(frame)+"preferredWindowBounds.width", preferredWindowBounds.width); - preferredWindowBounds.height = prefs.getInt(getPreferencesPrefix(frame)+"preferredWindowBounds.height", preferredWindowBounds.height); + Rectangle defaults = new Rectangle(0, 0, getPreferredSize().width, getPreferredSize().height); + Rectangle loaded = new Rectangle( + prefs.getInt(getPreferencesPrefix(frame)+"preferredWindowBounds.x", defaults.x), + prefs.getInt(getPreferencesPrefix(frame)+"preferredWindowBounds.y", defaults.y), + prefs.getInt(getPreferencesPrefix(frame)+"preferredWindowBounds.width", defaults.width), + prefs.getInt(getPreferencesPrefix(frame)+"preferredWindowBounds.height", defaults.height)); + if (JavaSEPort.isUsableWindowBounds(loaded)) { + preferredWindowBounds = loaded; + } else { + preferredWindowBounds = defaults; + // Clear the corrupt entries so a one-time glitch does not haunt + // every future launch of the simulator. + prefs.remove(getPreferencesPrefix(frame)+"preferredWindowBounds.x"); + prefs.remove(getPreferencesPrefix(frame)+"preferredWindowBounds.y"); + prefs.remove(getPreferencesPrefix(frame)+"preferredWindowBounds.width"); + prefs.remove(getPreferencesPrefix(frame)+"preferredWindowBounds.height"); + } preferredAlwaysOnTop = prefs.getBoolean(getPreferencesPrefix(frame)+"preferredAlwaysOnTop", preferredAlwaysOnTop); diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEPortWindowBoundsTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEPortWindowBoundsTest.java new file mode 100644 index 0000000000..27f92c3f1c --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEPortWindowBoundsTest.java @@ -0,0 +1,151 @@ +package com.codename1.impl.javase; + +import java.awt.GraphicsDevice; +import java.awt.GraphicsEnvironment; +import java.awt.Rectangle; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +/** + * Guards against regressions in the simulator's window-bounds persistence. + * The bug that prompted these tests: picking Simulate -> Larger Text -> + * Extra Extra Extra Large collapsed the frame, the resulting tiny geometry + * was written to prefs, and every subsequent launch restored the unusable + * window. The helpers tested here are the choke points that must reject + * those values on both write and read. + */ +public class JavaSEPortWindowBoundsTest { + + @Test + public void isUsableWindowBoundsRejectsNull() { + assertFalse(JavaSEPort.isUsableWindowBounds(null)); + } + + @Test + public void isUsableWindowBoundsRejectsCollapsedFrame() { + assertFalse(JavaSEPort.isUsableWindowBounds(new Rectangle(0, 0, 0, 0))); + assertFalse(JavaSEPort.isUsableWindowBounds(new Rectangle(0, 0, 1, 1))); + assertFalse(JavaSEPort.isUsableWindowBounds(new Rectangle(0, 0, 50, 50))); + } + + @Test + public void isUsableWindowBoundsRejectsJustBelowFloor() { + // 99 sits just under MIN_PERSISTED_WINDOW_DIMENSION (100). Verifies the + // floor is enforced strictly, not as "approximately". + assertFalse(JavaSEPort.isUsableWindowBounds(new Rectangle(0, 0, 99, 200))); + assertFalse(JavaSEPort.isUsableWindowBounds(new Rectangle(0, 0, 200, 99))); + } + + @Test + public void isUsableWindowBoundsRejectsAbsurdSize() { + // A corrupt or overflowing pref could produce arbitrarily large values. + // Both dimensions are checked independently. + assertFalse(JavaSEPort.isUsableWindowBounds(new Rectangle(0, 0, 40000, 800))); + assertFalse(JavaSEPort.isUsableWindowBounds(new Rectangle(0, 0, 800, 40000))); + assertFalse(JavaSEPort.isUsableWindowBounds(new Rectangle(0, 0, Integer.MAX_VALUE, Integer.MAX_VALUE))); + } + + @Test + public void isUsableWindowBoundsAcceptsReasonableOnDefaultScreen() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + GraphicsDevice gd = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice(); + Rectangle screen = gd.getDefaultConfiguration().getBounds(); + Rectangle window = new Rectangle(screen.x + 50, screen.y + 50, 800, 600); + assertTrue(JavaSEPort.isUsableWindowBounds(window), + "a normally placed 800x600 window on the default screen must be accepted"); + } + + @Test + public void isUsableWindowBoundsRejectsFarOffScreen() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + // No reachable display extends out this far; the user could never + // recover this window with the mouse. + Rectangle window = new Rectangle(-100000, -100000, 800, 600); + assertFalse(JavaSEPort.isUsableWindowBounds(window)); + } + + @Test + public void isUsableWindowBoundsRejectsSliverOnScreen() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + GraphicsDevice gd = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice(); + Rectangle screen = gd.getDefaultConfiguration().getBounds(); + // Window is mostly off the right edge of the screen, leaving only ~10px + // visible. The old intersects() check accepted this; the new floor of + // 100px visible width must reject it. + Rectangle window = new Rectangle(screen.x + screen.width - 10, screen.y + 50, 800, 600); + assertFalse(JavaSEPort.isUsableWindowBounds(window)); + } + + @Test + public void parsePersistedBoundsAcceptsWellFormed() { + Rectangle r = JavaSEPort.parsePersistedBounds("10,20,800,600"); + assertNotNull(r); + assertEquals(10, r.x); + assertEquals(20, r.y); + assertEquals(800, r.width); + assertEquals(600, r.height); + } + + @Test + public void parsePersistedBoundsToleratesWhitespace() { + // Defensive against hand-edited prefs or future formatting tweaks; the + // current writer does not insert spaces but readers should not be + // fragile about it. + Rectangle r = JavaSEPort.parsePersistedBounds(" 10 , 20 , 800 , 600 "); + assertNotNull(r); + assertEquals(10, r.x); + assertEquals(600, r.height); + } + + @Test + public void parsePersistedBoundsAcceptsNegativeOrigin() { + // Multi-monitor setups can legitimately place a window at negative + // coordinates relative to the primary display. + Rectangle r = JavaSEPort.parsePersistedBounds("-1200,-300,800,600"); + assertNotNull(r); + assertEquals(-1200, r.x); + assertEquals(-300, r.y); + } + + @Test + public void parsePersistedBoundsRejectsNull() { + assertNull(JavaSEPort.parsePersistedBounds(null)); + } + + @Test + public void parsePersistedBoundsRejectsEmpty() { + assertNull(JavaSEPort.parsePersistedBounds("")); + } + + @Test + public void parsePersistedBoundsRejectsWrongFieldCount() { + assertNull(JavaSEPort.parsePersistedBounds("10,20,30")); + assertNull(JavaSEPort.parsePersistedBounds("10,20,30,40,50")); + assertNull(JavaSEPort.parsePersistedBounds("10")); + } + + @Test + public void parsePersistedBoundsRejectsNonNumeric() { + // A NumberFormatException leaking out of init() would abort the entire + // simulator startup; the parser must swallow it and return null. + assertNull(JavaSEPort.parsePersistedBounds("not,a,number,here")); + assertNull(JavaSEPort.parsePersistedBounds("10,20,800,abc")); + assertNull(JavaSEPort.parsePersistedBounds("10.5,20,800,600")); + } + + @Test + public void roundTripWriteAndParse() { + // Mirrors the save format used by saveBounds(): "x,y,width,height". + Rectangle saved = new Rectangle(150, 75, 1024, 768); + String written = saved.x + "," + saved.y + "," + saved.width + "," + saved.height; + Rectangle restored = JavaSEPort.parsePersistedBounds(written); + assertEquals(saved, restored); + } +}