diff --git a/app/src/processing/app/Preferences.java b/app/src/processing/app/Preferences.java index b88e1416da..3a792f12f5 100644 --- a/app/src/processing/app/Preferences.java +++ b/app/src/processing/app/Preferences.java @@ -23,62 +23,34 @@ import processing.app.ui.Toolkit; import processing.core.PApplet; -import processing.core.PConstants; -import java.awt.*; -import java.io.*; -import java.util.HashMap; -import java.util.Map; +import java.awt.Color; +import java.awt.Font; +import java.awt.SystemColor; +import java.io.IOException; +import java.io.InputStream; /** - * Storage class for user preferences and environment settings. - *

- * This class does not use the Properties class because .properties files use - * ISO 8859-1 encoding, which is highly likely to be a problem when trying to - * save sketch folders and locations. Like the rest of Processing, we use UTF8. - *

- * We don't use the Java Preferences API because it would entail writing to - * the registry (on Windows), or an obscure file location (on Mac OS X) and - * make it far more difficult (impossible) to remove the preferences.txt to - * reset them (when they become corrupt), or to find the the file to make - * edits for numerous obscure preferences that are not part of the preferences - * window. If we added a generic editor (e.g. about:config in Mozilla) for - * such things, we could start using the Java Preferences API. But wow, that - * sounds like a lot of work. Not unlike writing this paragraph. + * The app-facing side of the preferences. The actual storage (loading and + * saving preferences.txt, the bundled defaults, platform-specific keys) + * lives in the standalone {@link processing.utils.Preferences}; this class + * delegates to it and keeps the pieces that only make sense inside the + * full application: fonts and colors, error dialogs, and environment + * side effects like proxies and the native file chooser. */ public class Preferences { - // had to rename the defaults file because people were editing it - static final String DEFAULTS_FILE = "defaults.txt"; //$NON-NLS-1$ - static final String PREFS_FILE = "preferences.txt"; //$NON-NLS-1$ - static Map defaults; - static Map table = new HashMap<>(); - static File preferencesFile; - private static boolean initialized = false; - - -// /** @return true if the sketchbook file did not exist */ -// static public boolean init() { static public void init() { - initialized = true; // start by loading the defaults, in case something // important was deleted from the user prefs try { - var defaultsStream = Preferences - .class - .getClassLoader() - .getResourceAsStream(DEFAULTS_FILE); - load(defaultsStream); - } catch (Exception e) { + processing.utils.Preferences.loadDefaults(); + } catch (IOException e) { Messages.showError(null, "Could not read default settings.\n" + "You'll need to reinstall Processing.", e); } - // Clone the defaults, then override any them with the user's preferences. - // This ensures that any new/added preference will be present. - defaults = new HashMap<>(table); - // other things that have to be set explicitly for the defaults setColor("run.window.bgcolor", SystemColor.control); //$NON-NLS-1$ @@ -87,32 +59,15 @@ static public void init() { setBoolean("editor.input_method_support", true); } - - // next load user preferences file - preferencesFile = Base.getSettingsFile(PREFS_FILE); - var preferencesFileOverride = System.getProperty("processing.app.preferences.file"); - if (preferencesFileOverride != null && !preferencesFileOverride.isEmpty()) { - preferencesFile = new File(preferencesFileOverride); - } - boolean firstRun = !preferencesFile.exists(); - if (!firstRun) { - try { - load(new FileInputStream(preferencesFile)); - - } catch (Exception ex) { - Messages.showError("Error reading preferences", - "Error reading the preferences file. " + - "Please delete (or move)\n" + - preferencesFile.getAbsolutePath() + - " and restart Processing.", ex); - } - } - - if (checkSketchbookPref() || firstRun) { -// if (firstRun) { - // create a new preferences file if none exists - // saves the defaults out to the file - save(); + // next load user preferences file over the defaults + try { + processing.utils.Preferences.loadUserPrefs(Base.getSettingsFolder()); + } catch (IOException ex) { + Messages.showError("Error reading preferences", + "Error reading the preferences file. " + + "Please delete (or move)\n" + + processing.utils.Preferences.getPreferencesPath() + + " and restart Processing.", ex); } PApplet.useNativeSelect = @@ -140,7 +95,7 @@ static public void init() { * For testing, pretend to load preferences without a real file. */ static public void skipInit() { - initialized = true; + processing.utils.Preferences.skipInit(); } /** @@ -148,7 +103,7 @@ static public void skipInit() { * @return true if Preferences has been initialized */ static public boolean isInitialized() { - return initialized; + return processing.utils.Preferences.isInitialized(); } @@ -165,120 +120,24 @@ static void handleProxy(String protocol, String hostProp, String portProp) { static public String getPreferencesPath() { - return preferencesFile.getAbsolutePath(); + return processing.utils.Preferences.getPreferencesPath(); } // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . - /** - * Load a set of key/value pairs from a UTF-8 encoded file into 'table'. - * For 3.0a6, this removes any platform-specific extensions from keys, so - * that we don't have platform-specific entries in a user's preferences.txt - * file, which would require all prefs to be changed twice, or risk being - * overwritten by the unchanged platform-specific version on reload. - */ static public void load(InputStream input) throws IOException { - HashMap platformSpecific = new HashMap<>(); - - String[] lines = PApplet.loadStrings(input); // Reads as UTF-8 - for (String line : lines) { - if ((line.isEmpty()) || - (line.charAt(0) == '#')) continue; - - line = line.replace("\\", "/"); // normalize slashes in paths - - // this won't properly handle = signs being in the text - int equals = line.indexOf('='); - if (equals != -1) { - String key = line.substring(0, equals).trim(); - String value = line.substring(equals + 1).trim(); - if (!isPlatformSpecific(key, value, platformSpecific)) { - table.put(key, value); - } - } - } - // Now override the keys with any platform-specific defaults we've found. - for (String key : platformSpecific.keySet()) { - table.put(key, platformSpecific.get(key)); - } + processing.utils.Preferences.load(input); } - /** - * @param key original key (may include platform extension) - * @param value the value that goes with the key - * @param specific where to put the key/value pairs for *this* platform - * @return true if a platform-specific key - */ - static protected boolean isPlatformSpecific(String key, String value, - Map specific) { - for (String platform : PConstants.platformNames) { - String ext = "." + platform; - if (key.endsWith(ext)) { - String thisPlatform = PConstants.platformNames[PApplet.platform]; - if (platform.equals(thisPlatform)) { - key = key.substring(0, key.lastIndexOf(ext)); - // store this for later overrides - specific.put(key, value); - //} else { - // ignore platform-specific defaults for other platforms, - // but return 'true' because it needn't be added to the big list - } - return true; - } - } - return false; - } - - - // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . - - static public void save() { - // On startup, this is null, but ignore it. It's trying to update the - // prefs for the open sketch before Preferences.init() has been called. - if (preferencesFile != null) { - try { - File dir = preferencesFile.getParentFile(); - File preferencesTemp = File.createTempFile("preferences", ".txt", dir); - if (!preferencesTemp.setWritable(true, false)) { - throw new IOException("Could not set " + preferencesTemp + " writable"); - } - - // Fix for 0163 to properly use Unicode when writing preferences.txt - PrintWriter writer = PApplet.createWriter(preferencesTemp); - - String[] keyList = table.keySet().toArray(new String[0]); - // Sorting is really helpful for debugging, diffing, and finding keys - keyList = PApplet.sort(keyList); - for (String key : keyList) { - writer.println(key + "=" + table.get(key)); //$NON-NLS-1$ - } - writer.flush(); - writer.close(); - - // Rename preferences.txt to preferences.old - File oldPreferences = new File(dir, "preferences.old"); - if (oldPreferences.exists()) { - if (!oldPreferences.delete()) { - throw new IOException("Could not delete preferences.old"); - } - } - if (preferencesFile.exists() && - !preferencesFile.renameTo(oldPreferences)) { - throw new IOException("Could not replace preferences.old"); - } - // Make the temporary file into the real preferences - if (!preferencesTemp.renameTo(preferencesFile)) { - throw new IOException("Could not move preferences file into place"); - } - - } catch (IOException e) { - Messages.showWarning("Preferences", - "Could not save the Preferences file.", e); - } + try { + processing.utils.Preferences.save(); + } catch (IOException e) { + Messages.showWarning("Preferences", + "Could not save the Preferences file.", e); } } @@ -289,44 +148,31 @@ static public void save() { // all the information from preferences.txt static public String get(String attribute /*, String defaultValue */) { - if (!initialized) { + if (!isInitialized()) { init(); } - return table.get(attribute); + return processing.utils.Preferences.get(attribute); } static public String getDefault(String attribute) { - return defaults.get(attribute); + return processing.utils.Preferences.getDefault(attribute); } static public void set(String attribute, String value) { - table.put(attribute, value); + processing.utils.Preferences.set(attribute, value); } static public void unset(String attribute) { - table.remove(attribute); + processing.utils.Preferences.unset(attribute); } static public boolean getBoolean(String attribute) { String value = get(attribute); //, null); return Boolean.parseBoolean(value); - - /* - supposedly not needed, because anything besides 'true' - (ignoring case) will just be false.. so if malformed -> false - if (value == null) return defaultValue; - - try { - return (new Boolean(value)).booleanValue(); - } catch (NumberFormatException e) { - System.err.println("expecting an integer: " + attribute + " = " + value); - } - return defaultValue; - */ } @@ -385,26 +231,6 @@ static public Font getFont(String familyAttr, String sizeAttr, int style) { // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . - /** - * Check for a 4.0 sketchbook location, and if none exists, - * try to grab it from the 3.0 sketchbook location. - * @return true if a location was found and the pref didn't exist - */ - static protected boolean checkSketchbookPref() { - // If a 4.0 sketchbook location has never been inited - if (getSketchbookPath() == null) { - String threePath = get("sketchbook.path.three"); //$NON-NLS-1$ - // If they've run the 3.0 version, start with that location - if (threePath != null) { - setSketchbookPath(threePath); - return true; // save the sketchbook right away - } - // Otherwise it'll be null, and reset properly by Base - } - return false; - } - - static public String getOldSketchbookPath() { return get("sketchbook.path.three"); //$NON-NLS-1$ } diff --git a/app/utils/src/main/java/processing/utils/Platform.java b/app/utils/src/main/java/processing/utils/Platform.java index 497613e51a..2c117f082d 100644 --- a/app/utils/src/main/java/processing/utils/Platform.java +++ b/app/utils/src/main/java/processing/utils/Platform.java @@ -23,4 +23,16 @@ static public boolean isWindows() { static public boolean isLinux() { return System.getProperty("os.name").contains("Linux"); //$NON-NLS-1$ //$NON-NLS-2$ } + + + /** + * Platform name for the current OS, mirroring core's + * PConstants.platformNames: "windows", "macos", "linux", or "other". + */ + static public String getName() { + if (isWindows()) return "windows"; //$NON-NLS-1$ + if (isMacOS()) return "macos"; //$NON-NLS-1$ + if (isLinux()) return "linux"; //$NON-NLS-1$ + return "other"; //$NON-NLS-1$ + } } diff --git a/app/utils/src/main/java/processing/utils/Preferences.java b/app/utils/src/main/java/processing/utils/Preferences.java new file mode 100644 index 0000000000..10ba01e27f --- /dev/null +++ b/app/utils/src/main/java/processing/utils/Preferences.java @@ -0,0 +1,463 @@ +/* -*- mode: java; c-basic-offset: 2; indent-tabs-mode: nil -*- */ + +/* + Part of the Processing project - http://processing.org + + Copyright (c) 2014-19 The Processing Foundation + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License version 2 + as published by the Free Software Foundation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software Foundation, + Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +*/ + +package processing.utils; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; + + +/** + * Storage class for user preferences and environment settings. + *

+ * This class does not use the Properties class because .properties files use + * ISO 8859-1 encoding, which is highly likely to be a problem when trying to + * save sketch folders and locations. Like the rest of Processing, we use UTF8. + *

+ * We don't use the Java Preferences API because it would entail writing to + * the registry (on Windows), or an obscure file location (on Mac OS X) and + * make it far more difficult (impossible) to remove the preferences.txt to + * reset them (when they become corrupt), or to find the the file to make + * edits for numerous obscure preferences that are not part of the preferences + * window. If we added a generic editor (e.g. about:config in Mozilla) for + * such things, we could start using the Java Preferences API. But wow, that + * sounds like a lot of work. Not unlike writing this paragraph. + *

+ * This is the standalone version: it only loads and saves preferences, and + * reports problems by throwing. Anything that needs the rest of the app + * (error dialogs, fonts, proxy setup) lives in processing.app.Preferences, + * which delegates the storage to this class. + */ +public class Preferences { + // had to rename the defaults file because people were editing it + static final String DEFAULTS_FILE = "defaults.txt"; //$NON-NLS-1$ + static final String PREFS_FILE = "preferences.txt"; //$NON-NLS-1$ + + /** + * Suffixes for platform-specific keys in defaults.txt, mirroring + * PConstants.platformNames. These strings are part of the file format, + * so they must not change independently of core. + */ + static final String[] PLATFORM_NAMES = { + "other", "windows", "macos", "linux" //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ + }; + + static Map defaults; + static Map table = new HashMap<>(); + static File preferencesFile; + static private boolean initialized = false; + + static private final List listeners = + new CopyOnWriteArrayList<>(); + + + /** + * Callback for preference changes made through set() or unset(). + * Loading (init or otherwise) does not fire events: listeners are for + * reacting to changes, not for observing startup. + */ + public interface ChangeListener { + /** + * @param key the preference that changed + * @param value the new value, or null if the key was removed + */ + void preferenceChanged(String key, String value); + } + + + static public void addChangeListener(ChangeListener listener) { + listeners.add(listener); + } + + + static public void removeChangeListener(ChangeListener listener) { + listeners.remove(listener); + } + + + static private void fireChange(String key, String value) { + for (ChangeListener listener : listeners) { + try { + listener.preferenceChanged(key, value); + } catch (Exception e) { + // one broken listener shouldn't prevent the change (or the others) + e.printStackTrace(); + } + } + } + + + /** + * Load the bundled defaults followed by the user's preferences.txt from + * the standard per-platform settings folder, creating the user file from + * the defaults on first run. One-call setup for standalone use. + */ + static public void init() throws IOException { + init(null); + } + + + /** + * Same as {@link #init()}, but with the settings folder passed in by the + * caller instead of resolved via {@link Settings#getFolder()}. The app + * calls the stages separately so it can set its own defaults in between. + * @param settingsFolder folder containing preferences.txt, or null to + * resolve the standard location for this platform + */ + static public void init(File settingsFolder) throws IOException { + loadDefaults(); + loadUserPrefs(settingsFolder); + } + + + /** + * First init stage: read the bundled defaults from the classpath. + * Also keeps a copy of the defaults, so that any new/added preference + * will be present even if missing from the user's preferences file. + */ + static public void loadDefaults() throws IOException { + initialized = true; + + // Name changed for 2.1b2 to avoid problems with users modifying or + // replacing the file after doing a search for "preferences.txt". + try (InputStream input = + Preferences.class.getClassLoader().getResourceAsStream(DEFAULTS_FILE)) { + if (input == null) { + throw new IOException("Could not find " + DEFAULTS_FILE + " on the classpath"); + } + load(input); + } + + // Clone the defaults, then override them with the user's preferences + // as they are loaded on top in the next stage. + defaults = new HashMap<>(table); + } + + + /** + * Second init stage: read the user's preferences.txt over the defaults, + * and write the file out if it did not exist yet (or a 3.x sketchbook + * location was migrated). + * @param settingsFolder folder containing preferences.txt, or null to + * resolve the standard location for this platform + */ + static public void loadUserPrefs(File settingsFolder) throws IOException { + preferencesFile = resolvePreferencesFile(settingsFolder); + boolean firstRun = !preferencesFile.exists(); + if (!firstRun) { + try (InputStream input = new FileInputStream(preferencesFile)) { + load(input); + } catch (IOException e) { + throw new IOException("Could not read " + + preferencesFile.getAbsolutePath(), e); + } + } + + if (checkSketchbookPref() || firstRun) { + // create a new preferences file if none exists + // saves the defaults out to the file + save(); + } + } + + + /** + * Resolve the user preferences file: an explicit system property override + * wins, then the folder passed by the caller, then the standard + * per-platform settings folder. + */ + static File resolvePreferencesFile(File settingsFolder) throws IOException { + String override = System.getProperty("processing.app.preferences.file"); + if (override != null && !override.isEmpty()) { + return new File(override); + } + if (settingsFolder != null) { + return new File(settingsFolder, PREFS_FILE); + } + try { + return new File(Settings.getFolder(), PREFS_FILE); + } catch (Settings.SettingsFolderException e) { + throw new IOException("Could not locate the settings folder", e); + } + } + + + /** + * For testing, pretend to load preferences without a real file. + */ + static public void skipInit() { + initialized = true; + } + + + /** + * For testing, forget all state so the next test starts clean. + */ + static void reset() { + table.clear(); + defaults = null; + preferencesFile = null; + initialized = false; + listeners.clear(); + } + + + /** + * Check whether init() has been called. If not, we are probably not + * running the full application. + * @return true if Preferences has been initialized + */ + static public boolean isInitialized() { + return initialized; + } + + + static public String getPreferencesPath() { + return preferencesFile.getAbsolutePath(); + } + + + // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . + + + /** + * Load a set of key/value pairs from a UTF-8 encoded stream into 'table'. + * For 3.0a6, this removes any platform-specific extensions from keys, so + * that we don't have platform-specific entries in a user's preferences.txt + * file, which would require all prefs to be changed twice, or risk being + * overwritten by the unchanged platform-specific version on reload. + */ + static public void load(InputStream input) throws IOException { + HashMap platformSpecific = new HashMap<>(); + + // Closes the stream when done, like PApplet.loadStrings() did here. + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if ((line.isEmpty()) || + (line.charAt(0) == '#')) continue; + + line = line.replace("\\", "/"); // normalize slashes in paths + + // this won't properly handle = signs being in the text + int equals = line.indexOf('='); + if (equals != -1) { + String key = line.substring(0, equals).trim(); + String value = line.substring(equals + 1).trim(); + if (!isPlatformSpecific(key, value, platformSpecific)) { + table.put(key, value); + } + } + } + } + // Now override the keys with any platform-specific defaults we've found. + for (String key : platformSpecific.keySet()) { + table.put(key, platformSpecific.get(key)); + } + } + + + /** + * @param key original key (may include platform extension) + * @param value the value that goes with the key + * @param specific where to put the key/value pairs for *this* platform + * @return true if a platform-specific key + */ + static protected boolean isPlatformSpecific(String key, String value, + Map specific) { + for (String platform : PLATFORM_NAMES) { + String ext = "." + platform; + if (key.endsWith(ext)) { + if (platform.equals(Platform.getName())) { + key = key.substring(0, key.lastIndexOf(ext)); + // store this for later overrides + specific.put(key, value); + //} else { + // ignore platform-specific defaults for other platforms, + // but return 'true' because it needn't be added to the big list + } + return true; + } + } + return false; + } + + + // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . + + + static public void save() throws IOException { + // On startup, this is null, but ignore it. It's trying to update the + // prefs for the open sketch before init() has been called. + if (preferencesFile != null) { + File dir = preferencesFile.getParentFile(); + // standalone callers may pass a settings folder that doesn't exist yet + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("Could not create " + dir); + } + File preferencesTemp = File.createTempFile("preferences", ".txt", dir); + if (!preferencesTemp.setWritable(true, false)) { + throw new IOException("Could not set " + preferencesTemp + " writable"); + } + + // Fix for 0163 to properly use Unicode when writing preferences.txt + try (PrintWriter writer = new PrintWriter( + new OutputStreamWriter(new FileOutputStream(preferencesTemp), + StandardCharsets.UTF_8))) { + String[] keyList = table.keySet().toArray(new String[0]); + // Sorting is really helpful for debugging, diffing, and finding keys + Arrays.sort(keyList); + for (String key : keyList) { + writer.println(key + "=" + table.get(key)); //$NON-NLS-1$ + } + writer.flush(); + } + + // Rename preferences.txt to preferences.old + File oldPreferences = new File(dir, "preferences.old"); + if (oldPreferences.exists()) { + if (!oldPreferences.delete()) { + throw new IOException("Could not delete preferences.old"); + } + } + if (preferencesFile.exists() && + !preferencesFile.renameTo(oldPreferences)) { + throw new IOException("Could not replace preferences.old"); + } + // Make the temporary file into the real preferences + if (!preferencesTemp.renameTo(preferencesFile)) { + throw new IOException("Could not move preferences file into place"); + } + } + } + + + // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . + + + // all the information from preferences.txt + + static public String get(String attribute) { + return table.get(attribute); + } + + + static public String getDefault(String attribute) { + return defaults.get(attribute); + } + + + static public void set(String attribute, String value) { + String previous = table.put(attribute, value); + if (!Objects.equals(previous, value)) { + fireChange(attribute, value); + } + } + + + static public void unset(String attribute) { + if (table.remove(attribute) != null) { + fireChange(attribute, null); + } + } + + + static public boolean getBoolean(String attribute) { + String value = get(attribute); + return Boolean.parseBoolean(value); + } + + + static public void setBoolean(String attribute, boolean value) { + set(attribute, value ? "true" : "false"); //$NON-NLS-1$ //$NON-NLS-2$ + } + + + static public int getInteger(String attribute) { + try { + return Integer.parseInt(get(attribute)); + } catch (NumberFormatException err) { + try { + return Integer.parseInt(getDefault(attribute)); + } catch (NumberFormatException err2) { + throw new IllegalArgumentException("Cannot parse: " + attribute); + } + } + } + + + static public void setInteger(String key, int value) { + set(key, String.valueOf(value)); + } + + + // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . + + + /** + * Check for a 4.0 sketchbook location, and if none exists, + * try to grab it from the 3.0 sketchbook location. + * @return true if a location was found and the pref didn't exist + */ + static protected boolean checkSketchbookPref() { + // If a 4.0 sketchbook location has never been inited + if (getSketchbookPath() == null) { + String threePath = get("sketchbook.path.three"); //$NON-NLS-1$ + // If they've run the 3.0 version, start with that location + if (threePath != null) { + setSketchbookPath(threePath); + return true; // save the sketchbook right away + } + // Otherwise it'll be null, and reset properly by the application + } + return false; + } + + + static public String getOldSketchbookPath() { + return get("sketchbook.path.three"); //$NON-NLS-1$ + } + + + static public String getSketchbookPath() { + return get("sketchbook.path.four"); //$NON-NLS-1$ + } + + + static public void setSketchbookPath(String path) { + set("sketchbook.path.four", path); //$NON-NLS-1$ + } +} diff --git a/app/src/main/resources/defaults.txt b/app/utils/src/main/resources/defaults.txt similarity index 100% rename from app/src/main/resources/defaults.txt rename to app/utils/src/main/resources/defaults.txt diff --git a/app/utils/src/test/java/processing/utils/PreferencesTest.java b/app/utils/src/test/java/processing/utils/PreferencesTest.java new file mode 100644 index 0000000000..1cdfbab2a8 --- /dev/null +++ b/app/utils/src/test/java/processing/utils/PreferencesTest.java @@ -0,0 +1,316 @@ +package processing.utils; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +import 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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class PreferencesTest { + + @TempDir + File settingsFolder; + + @BeforeEach + public void clearState() { + Preferences.reset(); + } + + @AfterEach + public void clearOverride() { + System.clearProperty("processing.app.preferences.file"); + Preferences.reset(); + } + + + static InputStream stream(String content) { + return new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + } + + + /** + * The bundled defaults load from the classpath, and on a first run the + * table starts out identical to the defaults. + */ + @Test + public void testInitLoadsBundledDefaults() throws IOException { + Preferences.init(settingsFolder); + assertNotNull(Preferences.get("editor.tabs.size")); + assertEquals(Preferences.getDefault("editor.tabs.size"), + Preferences.get("editor.tabs.size")); + assertTrue(Preferences.isInitialized()); + } + + + /** + * A first run writes preferences.txt out to the settings folder. + */ + @Test + public void testFirstRunCreatesPreferencesFile() throws IOException { + var file = new File(settingsFolder, "preferences.txt"); + assertFalse(file.exists()); + Preferences.init(settingsFolder); + assertTrue(file.exists()); + } + + + /** + * Values set and saved come back after a fresh init. + */ + @Test + public void testSavedValuesSurviveReload() throws IOException { + Preferences.init(settingsFolder); + Preferences.set("editor.tabs.size", "7"); + Preferences.save(); + + Preferences.reset(); + Preferences.init(settingsFolder); + assertEquals("7", Preferences.get("editor.tabs.size")); + } + + + /** + * An existing preferences.txt overrides the bundled defaults, + * but the defaults remain available via getDefault(). + */ + @Test + public void testUserPrefsOverrideDefaults() throws IOException { + var file = new File(settingsFolder, "preferences.txt"); + Files.writeString(file.toPath(), "editor.tabs.size=9\n"); + + Preferences.init(settingsFolder); + assertEquals("9", Preferences.get("editor.tabs.size")); + assertEquals("2", Preferences.getDefault("editor.tabs.size")); + } + + + /** + * The processing.app.preferences.file system property wins over the + * settings folder passed in. + */ + @Test + public void testSystemPropertyOverrideWins(@TempDir File otherFolder) + throws IOException { + var override = new File(otherFolder, "prefs-override.txt"); + Files.writeString(override.toPath(), "override.marker=yes\n"); + System.setProperty("processing.app.preferences.file", + override.getAbsolutePath()); + + Preferences.init(settingsFolder); + assertEquals("yes", Preferences.get("override.marker")); + assertEquals(override.getAbsolutePath(), + Preferences.getPreferencesPath()); + } + + + /** + * Keys with a suffix for this platform apply with the suffix stripped; + * keys for other platforms are ignored entirely. + */ + @Test + public void testPlatformSpecificKeys() throws IOException { + Preferences.skipInit(); + Preferences.load(stream( + "key.windows=w\nkey.macos=m\nkey.linux=l\nkey.other=o\n")); + + var expected = switch (Platform.getName()) { + case "windows" -> "w"; + case "macos" -> "m"; + case "linux" -> "l"; + default -> "o"; + }; + assertEquals(expected, Preferences.get("key")); + assertNull(Preferences.get("key." + Platform.getName())); + } + + + /** + * Backslashes in values are normalized to forward slashes on load. + */ + @Test + public void testBackslashesNormalized() throws IOException { + Preferences.skipInit(); + Preferences.load(stream("sketchbook.path.four=C:\\Users\\test\n")); + assertEquals("C:/Users/test", Preferences.get("sketchbook.path.four")); + } + + + /** + * Comment lines and blank lines don't produce entries. + */ + @Test + public void testCommentsAndBlanksIgnored() throws IOException { + Preferences.skipInit(); + Preferences.load(stream("# a comment\n\nkey = value\n")); + assertEquals("value", Preferences.get("key")); + assertNull(Preferences.get("# a comment")); + } + + + /** + * getInteger() falls back to the default when the stored value + * doesn't parse. + */ + @Test + public void testGetIntegerFallsBackToDefault() throws IOException { + Preferences.init(settingsFolder); + Preferences.set("editor.tabs.size", "not-a-number"); + assertEquals(2, Preferences.getInteger("editor.tabs.size")); + } + + + /** + * An unreadable preferences.txt makes init throw instead of + * silently continuing. + */ + @Test + public void testUnreadablePreferencesFileThrows() throws IOException { + // a directory where the file should be cannot be opened for reading + var blocked = new File(settingsFolder, "preferences.txt"); + assertTrue(blocked.mkdir()); + assertThrows(IOException.class, () -> Preferences.init(settingsFolder)); + } + + + /** + * preferences.txt is written with its keys sorted. + */ + @Test + public void testSaveWritesSortedKeys() throws IOException { + Preferences.init(settingsFolder); + Preferences.set("zzz.last", "1"); + Preferences.set("aaa.first", "1"); + Preferences.save(); + + var lines = Files.readAllLines( + new File(settingsFolder, "preferences.txt").toPath()); + // sorted by key, not by line: "a.b=…" sorts after "a=…" as a line, + // but the keys "a" < "a.b" are what save() orders by + var keys = new ArrayList(); + for (var line : lines) { + keys.add(line.substring(0, line.indexOf('='))); + } + var sorted = new ArrayList<>(keys); + sorted.sort(null); + assertEquals(sorted, keys); + } + + + /** + * A settings folder that doesn't exist yet is created on first save, + * so standalone callers can point at any location. + */ + @Test + public void testInitCreatesMissingSettingsFolder() throws IOException { + var missing = new File(settingsFolder, "nested/settings"); + assertFalse(missing.exists()); + + Preferences.init(missing); + assertTrue(new File(missing, "preferences.txt").exists()); + } + + + /** + * A 3.x sketchbook location is migrated to the 4.0 key on init, + * and the migrated preference is written back out right away. + */ + @Test + public void testSketchbookPathMigration() throws IOException { + var file = new File(settingsFolder, "preferences.txt"); + Files.writeString(file.toPath(), + "sketchbook.path.three=/old/sketchbook\n"); + + Preferences.init(settingsFolder); + assertEquals("/old/sketchbook", Preferences.getSketchbookPath()); + assertEquals("/old/sketchbook", Preferences.getOldSketchbookPath()); + + var saved = Files.readString(file.toPath()); + assertTrue(saved.contains("sketchbook.path.four=/old/sketchbook")); + } + + + /** + * An existing 4.0 sketchbook location is left alone, even when a + * 3.x location is also present. + */ + @Test + public void testExistingSketchbookPathNotOverwritten() throws IOException { + var file = new File(settingsFolder, "preferences.txt"); + Files.writeString(file.toPath(), + "sketchbook.path.three=/old\nsketchbook.path.four=/new\n"); + + Preferences.init(settingsFolder); + assertEquals("/new", Preferences.getSketchbookPath()); + } + + + /** + * Listeners hear set() and unset(), but not writes of the same value. + */ + @Test + public void testListenerSemantics() { + Preferences.skipInit(); + List events = new ArrayList<>(); + Preferences.ChangeListener listener = + (key, value) -> events.add(key + "=" + value); + Preferences.addChangeListener(listener); + + Preferences.set("a", "1"); + Preferences.set("a", "1"); // unchanged, no event + Preferences.set("a", "2"); + Preferences.unset("a"); + Preferences.unset("a"); // already gone, no event + assertEquals(List.of("a=1", "a=2", "a=null"), events); + + Preferences.removeChangeListener(listener); + Preferences.set("a", "3"); + assertEquals(3, events.size()); + } + + + /** + * Bulk loading does not fire change events. + */ + @Test + public void testLoadIsSilentForListeners() throws IOException { + Preferences.skipInit(); + List events = new ArrayList<>(); + Preferences.addChangeListener((key, value) -> events.add(key)); + + Preferences.load(stream("a=1\nb=2\n")); + assertTrue(events.isEmpty()); + } + + + /** + * A listener that throws doesn't block the change or other listeners. + */ + @Test + public void testListenerExceptionContained() { + Preferences.skipInit(); + List events = new ArrayList<>(); + Preferences.addChangeListener((key, value) -> { + throw new RuntimeException("broken listener"); + }); + Preferences.addChangeListener((key, value) -> events.add(key)); + + Preferences.set("a", "1"); + assertEquals("1", Preferences.get("a")); + assertEquals(List.of("a"), events); + } +} diff --git a/java/preprocessor/src/main/java/processing/app/Preferences.java b/java/preprocessor/src/main/java/processing/app/Preferences.java index eab3a23974..6c30f2946c 100644 --- a/java/preprocessor/src/main/java/processing/app/Preferences.java +++ b/java/preprocessor/src/main/java/processing/app/Preferences.java @@ -21,47 +21,44 @@ package processing.app; -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.util.Properties; +import java.io.IOException; + /** - * Storage class for user preferences and environment settings. - *

- * This class does not use the Properties class because .properties files use - * ISO 8859-1 encoding, which is highly likely to be a problem when trying to - * save sketch folders and locations. Like the rest of Processing, we use UTF8. - *

- * We don't use the Java Preferences API because it would entail writing to - * the registry (on Windows), or an obscure file location (on Mac OS X) and - * make it far more difficult (impossible) to remove the preferences.txt to - * reset them (when they become corrupt), or to find the the file to make - * edits for numerous obscure preferences that are not part of the preferences - * window. If we added a generic editor (e.g. about:config in Mozilla) for - * such things, we could start using the Java Preferences API. But wow, that - * sounds like a lot of work. Not unlike writing this paragraph. + * Minimal stand-in for the app's Preferences so the preprocessor can be + * used without the PDE. Delegates to the standalone + * {@link processing.utils.Preferences}, which bundles the default settings, + * so preferences resolve even when no preferences.txt exists yet. */ public class Preferences { - static public String get(String attribute /*, String defaultValue */) { - try { - var settingsFile = Base.getSettingsFile("preferences.txt"); - var reader = new BufferedReader(new FileReader(settingsFile)); - - var settings = new Properties(); - settings.load(reader); - reader.close(); - - return settings.getProperty(attribute); - }catch (Exception e) { - return null; - } - } - static public boolean getBoolean(String attribute) { - String value = get(attribute); - return Boolean.parseBoolean(value); - } - static public int getInteger(String attribute /*, int defaultValue*/) { - return Integer.parseInt(get(attribute)); + + static private void ensureInitialized() { + if (!processing.utils.Preferences.isInitialized()) { + try { + processing.utils.Preferences.init(); + } catch (IOException e) { + // The bundled defaults are enough to preprocess with; an unreadable + // preferences.txt shouldn't stop a build. + System.err.println("Could not read preferences: " + e.getMessage()); + } } + } + + + static public String get(String attribute) { + ensureInitialized(); + return processing.utils.Preferences.get(attribute); + } + + + static public boolean getBoolean(String attribute) { + ensureInitialized(); + return processing.utils.Preferences.getBoolean(attribute); + } + + + static public int getInteger(String attribute) { + ensureInitialized(); + return processing.utils.Preferences.getInteger(attribute); + } }