From 2d667d118a4073269e062ac725fab7891c05f4b2 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Tue, 28 Jul 2026 09:23:58 +0200 Subject: [PATCH] add opt-in xdg application directory support --- README.md | 14 +- drongo | 2 +- .../sparrowwallet/sparrow/AppController.java | 4 +- .../sparrowwallet/sparrow/SparrowWallet.java | 11 +- .../sparrow/instance/Instance.java | 74 +++++++--- .../com/sparrowwallet/sparrow/io/Config.java | 4 +- .../com/sparrowwallet/sparrow/io/Hwi.java | 6 +- .../com/sparrowwallet/sparrow/io/Storage.java | 135 ++++++++++++------ .../com/sparrowwallet/sparrow/net/Tor.java | 9 +- 9 files changed, 177 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 3e5d60c9..9285a63f 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,24 @@ When not explicitly configured using the command line argument above, Sparrow st | Platform | Location | |----------| -------- | -| OSX | ~/.sparrow | +| macOS | ~/.sparrow | | Linux | ~/.sparrow | | Windows | %APPDATA%/Sparrow | Testnet3, testnet4, regtest and signet configurations (along with their wallets) are stored in subfolders to allow easy switching between networks. +On macOS and Linux, Sparrow also supports the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/). +This is opt in: for each category below, if the corresponding directory already exists, Sparrow uses it, otherwise it continues to use the home folder above. Categories are resolved independently, so files can be moved across one at a time. + +| Category | Location | Contents | +|----------| -------- |---------------------------------------| +| Config | `$XDG_CONFIG_HOME/sparrow` (default `~/.config/sparrow`) | `config`, `network-*` markers | +| Data | `$XDG_DATA_HOME/sparrow` (default `~/.local/share/sparrow`) | `wallets`, `certs`, `lark` | +| State | `$XDG_STATE_HOME/sparrow` (default `~/.local/state/sparrow`) | `sparrow.log`, `tor/work`, lock files | +| Cache | `$XDG_CACHE_HOME/sparrow` (default `~/.cache/sparrow`) | `tor/cache` | + +Specifying a home folder with the `-d` argument disables XDG resolution entirely, and stores all files in the given folder. + ## Reporting Issues Please use the [Issues](https://github.com/sparrowwallet/sparrow/issues) tab above to report an issue. If possible, look in the sparrow.log file in the configuration directory for information helpful in debugging. diff --git a/drongo b/drongo index 9bb093ff..fa1bb4ee 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 9bb093ffbfd81f82c2cb26ba9ca47d9874f3ac84 +Subproject commit fa1bb4eec2a86e358c796a3c2872a2eaecf06260 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 1ccf222c..4ff89e41 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -524,7 +524,7 @@ public class AppController implements Initializable { } public void showLogFile(ActionEvent event) throws IOException { - File logFile = new File(Storage.getSparrowHome(), "sparrow.log"); + File logFile = new File(Storage.getStateHome(), "sparrow.log"); if(logFile.exists()) { AppServices.get().getApplication().getHostServices().showDocument(logFile.toPath().toUri().toString()); } else { @@ -1040,7 +1040,7 @@ public class AppController implements Initializable { Stage window = new Stage(); DirectoryChooser directoryChooser = new DirectoryChooser(); directoryChooser.setTitle("Choose Sparrow Home Folder"); - directoryChooser.setInitialDirectory(initialDir == null || !initialDir.exists() ? Storage.getSparrowHome() : initialDir); + directoryChooser.setInitialDirectory(initialDir == null || !initialDir.exists() ? Storage.getDefaultHome() : initialDir); File newHome = directoryChooser.showDialog(window); if(newHome != null) { diff --git a/src/main/java/com/sparrowwallet/sparrow/SparrowWallet.java b/src/main/java/com/sparrowwallet/sparrow/SparrowWallet.java index 4f2ba15d..5b725465 100644 --- a/src/main/java/com/sparrowwallet/sparrow/SparrowWallet.java +++ b/src/main/java/com/sparrowwallet/sparrow/SparrowWallet.java @@ -1,6 +1,7 @@ package com.sparrowwallet.sparrow; import com.beust.jcommander.JCommander; +import com.sparrowwallet.drongo.ApplicationDir; import com.sparrowwallet.drongo.Drongo; import com.sparrowwallet.drongo.Network; import com.sparrowwallet.sparrow.io.Storage; @@ -20,7 +21,7 @@ public class SparrowWallet { public static final String APP_NAME = "Sparrow"; public static final String APP_VERSION = "2.5.3"; public static final String APP_VERSION_SUFFIX = ""; - public static final String APP_HOME_PROPERTY = "sparrow.home"; + public static final String APP_HOME_PROPERTY = ApplicationDir.getHomeProperty(APP_NAME); public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK"; public static final String JPACKAGE_APP_PATH = "jpackage.app-path"; @@ -71,17 +72,19 @@ public class SparrowWallet { } } - File testnetFlag = new File(Storage.getSparrowHome(), "network-" + Network.TESTNET.getName()); + Storage.logApplicationDirs(); + + File testnetFlag = new File(Storage.getConfigHome(), "network-" + Network.TESTNET.getName()); if(testnetFlag.exists()) { Network.set(Network.TESTNET); } - File testnet4Flag = new File(Storage.getSparrowHome(), "network-" + Network.TESTNET4.getName()); + File testnet4Flag = new File(Storage.getConfigHome(), "network-" + Network.TESTNET4.getName()); if(testnet4Flag.exists()) { Network.set(Network.TESTNET4); } - File signetFlag = new File(Storage.getSparrowHome(), "network-" + Network.SIGNET.getName()); + File signetFlag = new File(Storage.getConfigHome(), "network-" + Network.SIGNET.getName()); if(signetFlag.exists()) { Network.set(Network.SIGNET); } diff --git a/src/main/java/com/sparrowwallet/sparrow/instance/Instance.java b/src/main/java/com/sparrowwallet/sparrow/instance/Instance.java index a5d65569..1b0c91a5 100644 --- a/src/main/java/com/sparrowwallet/sparrow/instance/Instance.java +++ b/src/main/java/com/sparrowwallet/sparrow/instance/Instance.java @@ -20,6 +20,7 @@ import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; import java.util.Iterator; +import java.util.List; import java.util.Set; public abstract class Instance { @@ -31,6 +32,8 @@ public abstract class Instance { private Selector selector; private ServerSocketChannel serverChannel; + private Path acquiredLockFile; + private Path acquiredLockFilePointer; public Instance(final String applicationId) { this(applicationId, true); @@ -65,6 +68,7 @@ public abstract class Instance { serverChannel.configureBlocking(false); serverChannel.register(selector, SelectionKey.OP_ACCEPT); lockFile.toFile().deleteOnExit(); + acquiredLockFile = lockFile; } catch(Exception e) { throw new InstanceException("Could not open UNIX socket lock file for instance at " + lockFile.toAbsolutePath(), e); } @@ -145,26 +149,27 @@ public abstract class Instance { private Path getLockFile(boolean findExisting) { if(findExisting) { - Path pointer = getUserLockFilePointer(); - try { - if(pointer != null && Files.exists(pointer)) { - if(Files.isSymbolicLink(pointer)) { - return Files.readSymbolicLink(pointer); - } else { - Path lockFile = Path.of(Files.readString(pointer, StandardCharsets.UTF_8)); - if(Files.exists(lockFile)) { - return lockFile; + for(Path pointer : getUserLockFilePointers()) { + try { + if(Files.exists(pointer)) { + if(Files.isSymbolicLink(pointer)) { + return Files.readSymbolicLink(pointer); + } else { + Path lockFile = Path.of(Files.readString(pointer, StandardCharsets.UTF_8)); + if(Files.exists(lockFile)) { + return lockFile; + } } } + } catch(IOException e) { + log.warn("Could not find lock file at " + pointer.toAbsolutePath()); + } catch(Exception e) { + //ignore } - } catch(IOException e) { - log.warn("Could not find lock file at " + pointer.toAbsolutePath()); - } catch(Exception e) { - //ignore } } - return Storage.getSparrowDir().toPath().resolve(applicationId + ".lock"); + return Storage.getStateDir().toPath().resolve(applicationId + ".lock"); } private void createSymlink(Path lockFile) { @@ -173,6 +178,7 @@ public abstract class Instance { if(pointer != null && !Files.exists(pointer, LinkOption.NOFOLLOW_LINKS)) { Files.createSymbolicLink(pointer, lockFile); pointer.toFile().deleteOnExit(); + acquiredLockFilePointer = pointer; } } catch(IOException e) { log.debug("Could not create symlink " + pointer.toAbsolutePath() + " to lockFile at " + lockFile.toAbsolutePath() + ", writing as normal file", e); @@ -180,6 +186,7 @@ public abstract class Instance { try { Files.writeString(pointer, lockFile.toAbsolutePath().toString(), StandardCharsets.UTF_8); pointer.toFile().deleteOnExit(); + acquiredLockFilePointer = pointer; } catch(IOException ex) { log.warn("Could not create pointer " + pointer.toAbsolutePath() + " to lockFile at " + lockFile.toAbsolutePath(), ex); } @@ -188,24 +195,44 @@ public abstract class Instance { } } + /** + * Returns the location this instance writes its pointer to, ignoring any configured home so that instances started with + * and without one still find each other. + */ private Path getUserLockFilePointer() { if(Boolean.parseBoolean(System.getenv(LINK_ENV_PROPERTY))) { return null; } try { - File sparrowHome = Storage.getSparrowHome(true); - if(!sparrowHome.exists()) { - Storage.createOwnerOnlyDirectory(sparrowHome); - sparrowHome.deleteOnExit(); //Will only delete on exit if empty + File stateHome = Storage.getStateHome(true); + if(!stateHome.exists()) { + Storage.createOwnerOnlyDirectory(stateHome); + stateHome.deleteOnExit(); //Will only delete on exit if empty } - return sparrowHome.toPath().resolve(applicationId + ".default"); + return stateHome.toPath().resolve(applicationId + ".default"); } catch(Exception e) { return null; } } + /** + * Returns the locations that may hold a pointer to a running instance, without creating any of them. + * + * An instance started before the state directory was migrated writes its pointer to the default home, so that is searched + * as well, ensuring migrating does not hide an instance that is already running. + */ + private List getUserLockFilePointers() { + Path statePointer = getUserLockFilePointer(); + if(statePointer == null) { + return List.of(); + } + + Path defaultPointer = Storage.getDefaultHome().toPath().resolve(applicationId + ".default"); + return statePointer.equals(defaultPointer) ? List.of(statePointer) : List.of(statePointer, defaultPointer); + } + /** * Free the lock if possible. This is only required to be called from the first instance. * @@ -216,10 +243,13 @@ public abstract class Instance { if(serverChannel != null && serverChannel.isOpen()) { serverChannel.close(); } - if(getUserLockFilePointer() != null) { - Files.deleteIfExists(getUserLockFilePointer()); + //Free the paths this instance acquired, which are not necessarily those that would be resolved now + if(acquiredLockFilePointer != null) { + Files.deleteIfExists(acquiredLockFilePointer); + } + if(acquiredLockFile != null) { + Files.deleteIfExists(acquiredLockFile); } - Files.deleteIfExists(getLockFile(false)); } catch(Exception e) { throw new InstanceException(e); } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Config.java b/src/main/java/com/sparrowwallet/sparrow/io/Config.java index 1dbb24c9..28561ea6 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Config.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Config.java @@ -108,8 +108,8 @@ public class Config { } private static File getConfigFile() { - File sparrowDir = Storage.getSparrowDir(); - return new File(sparrowDir, CONFIG_FILENAME); + File configDir = Storage.getConfigDir(); + return new File(configDir, CONFIG_FILENAME); } private static Config load() { diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Hwi.java b/src/main/java/com/sparrowwallet/sparrow/io/Hwi.java index d62c77c3..fd9fa8d2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Hwi.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Hwi.java @@ -269,7 +269,7 @@ public class Hwi { private static void deleteHwiDir() { try { if(OsType.getCurrent() == OsType.MACOS || OsType.getCurrent() == OsType.WINDOWS) { - File hwiHomeDir = new File(Storage.getSparrowDir(), HWI_HOME_DIR); + File hwiHomeDir = new File(Storage.getCacheDir(), HWI_HOME_DIR); if(hwiHomeDir.exists()) { IOUtils.deleteDirectory(hwiHomeDir); } @@ -544,7 +544,7 @@ public class Hwi { private BitBoxPairingDialog pairingDialog; public BitBoxFxNoiseConfig() { - super(Path.of(Storage.getSparrowHome().getAbsolutePath(), LARK_HOME_DIR, BITBOX_FILENAME).toFile()); + super(Path.of(Storage.getDataHome().getAbsolutePath(), LARK_HOME_DIR, BITBOX_FILENAME).toFile()); } @Override @@ -594,7 +594,7 @@ public class Hwi { private String deviceInfo; public TrezorFxNoiseConfig() { - super(Path.of(Storage.getSparrowHome().getAbsolutePath(), LARK_HOME_DIR, TREZOR_FILENAME).toFile()); + super(Path.of(Storage.getDataHome().getAbsolutePath(), LARK_HOME_DIR, TREZOR_FILENAME).toFile()); } @Override diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java index 4856ef44..ea933579 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java @@ -33,6 +33,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; public class Storage { private static final Logger log = LoggerFactory.getLogger(Storage.class); @@ -41,8 +42,6 @@ public class Storage { private static final DateTimeFormatter BACKUP_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); private static final Pattern DATE_PATTERN = Pattern.compile(".+-([0-9]{14}?).*"); - public static final String SPARROW_DIR = ".sparrow"; - public static final String WINDOWS_SPARROW_DIR = "Sparrow"; public static final String WALLETS_DIR = "wallets"; public static final String WALLETS_BACKUP_DIR = "backup"; public static final String CERTS_DIR = "certs"; @@ -501,7 +500,7 @@ public class Storage { } } if(walletsDir == null) { - walletsDir = new File(getSparrowDir(), WALLETS_DIR); + walletsDir = new File(getDataDir(), WALLETS_DIR); } if(!walletsDir.exists()) { createOwnerOnlyDirectory(walletsDir); @@ -556,7 +555,7 @@ public class Storage { } static File getCertsDir() { - File certsDir = new File(getSparrowDir(), CERTS_DIR); + File certsDir = new File(getDataDir(), CERTS_DIR); if(!certsDir.exists()) { createOwnerOnlyDirectory(certsDir); } @@ -564,73 +563,123 @@ public class Storage { return certsDir; } - public static File getSparrowDir() { - File sparrowDir; + /** + * Returns the network specific directory containing the configuration file. + */ + public static File getConfigDir() { + return getNetworkDir(getConfigHome()); + } + + /** + * Returns the network specific directory containing wallets and certificates. + */ + public static File getDataDir() { + return getNetworkDir(getDataHome()); + } + + /** + * Returns the network specific directory containing regenerable files. + */ + public static File getCacheDir() { + return getNetworkDir(getCacheHome()); + } + + /** + * Returns the network specific directory containing logs and runtime state. + */ + public static File getStateDir() { + return getNetworkDir(getStateHome()); + } + + public static File getConfigHome() { + return ApplicationDir.CONFIG.get(SparrowWallet.APP_NAME); + } + + public static File getDataHome() { + return ApplicationDir.DATA.get(SparrowWallet.APP_NAME); + } + + public static File getCacheHome() { + return ApplicationDir.CACHE.get(SparrowWallet.APP_NAME); + } + + public static File getStateHome() { + return getStateHome(false); + } + + public static File getStateHome(boolean useDefault) { + return ApplicationDir.STATE.get(SparrowWallet.APP_NAME, useDefault); + } + + /** + * Returns the single directory used when the XDG Base Directory Specification is not followed, ignoring any configured home. + * + * Provides a fixed location that does not move as categories are migrated, and is where earlier versions wrote all of their files. + */ + public static File getDefaultHome() { + return ApplicationDir.getDefaultDir(SparrowWallet.APP_NAME); + } + + /** + * Resolves the network specific directory within the given application directory, creating it if necessary. + * + * Where a network has been renamed, any existing directory under the previous name is moved and replaced with a symlink, + * and a symlink under the previous name is otherwise maintained for convenience. + */ + private static File getNetworkDir(File applicationDir) { + File networkDir; Network network = Network.get(); if(network != Network.MAINNET) { - sparrowDir = new File(getSparrowHome(), network.getHome()); - if(!network.getName().equals(network.getHome()) && !sparrowDir.exists()) { - File networkNameDir = new File(getSparrowHome(), network.getName()); + networkDir = new File(applicationDir, network.getHome()); + if(!network.getName().equals(network.getHome()) && !networkDir.exists()) { + File networkNameDir = new File(applicationDir, network.getName()); if(networkNameDir.exists() && networkNameDir.isDirectory() && !Files.isSymbolicLink(networkNameDir.toPath())) { try { - if(networkNameDir.renameTo(sparrowDir) && !isWindows()) { - Files.createSymbolicLink(networkNameDir.toPath(), Path.of(sparrowDir.getName())); + if(networkNameDir.renameTo(networkDir) && !isWindows()) { + Files.createSymbolicLink(networkNameDir.toPath(), Path.of(networkDir.getName())); } } catch(Exception e) { - log.debug("Error creating symlink from " + networkNameDir.getAbsolutePath() + " to " + sparrowDir.getName(), e); + log.debug("Error creating symlink from " + networkNameDir.getAbsolutePath() + " to " + networkDir.getName(), e); } } } } else { - sparrowDir = getSparrowHome(); + networkDir = applicationDir; } - if(!sparrowDir.exists()) { - createOwnerOnlyDirectory(sparrowDir); + if(!networkDir.exists()) { + createOwnerOnlyDirectory(networkDir); } if(!network.getName().equals(network.getHome()) && !isWindows()) { try { - Path networkNamePath = getSparrowHome().toPath().resolve(network.getName()); + Path networkNamePath = applicationDir.toPath().resolve(network.getName()); if(Files.isSymbolicLink(networkNamePath)) { - Path symlinkTarget = getSparrowHome().toPath().resolve(Files.readSymbolicLink(networkNamePath)); - if(!Files.isSameFile(sparrowDir.toPath(), symlinkTarget)) { + Path symlinkTarget = applicationDir.toPath().resolve(Files.readSymbolicLink(networkNamePath)); + if(!Files.isSameFile(networkDir.toPath(), symlinkTarget)) { Files.delete(networkNamePath); - Files.createSymbolicLink(networkNamePath, Path.of(sparrowDir.getName())); + Files.createSymbolicLink(networkNamePath, Path.of(networkDir.getName())); } } else if(!Files.exists(networkNamePath)) { - Files.createSymbolicLink(networkNamePath, Path.of(sparrowDir.getName())); + Files.createSymbolicLink(networkNamePath, Path.of(networkDir.getName())); } } catch(Exception e) { - log.debug("Error updating symlink from " + network.getName() + " to " + sparrowDir.getName(), e); + log.debug("Error updating symlink from " + network.getName() + " to " + networkDir.getName(), e); } } - return sparrowDir; + return networkDir; } - public static File getSparrowHome() { - return getSparrowHome(false); - } - - public static File getSparrowHome(boolean useDefault) { - if(!useDefault && System.getProperty(SparrowWallet.APP_HOME_PROPERTY) != null) { - return new File(System.getProperty(SparrowWallet.APP_HOME_PROPERTY)); + /** + * Logs the application directories in use where they do not all resolve to the default application directory. + */ + public static void logApplicationDirs() { + List xdgDirs = Arrays.stream(ApplicationDir.values()).filter(applicationDir -> applicationDir.isXdg(SparrowWallet.APP_NAME)).toList(); + if(!xdgDirs.isEmpty() && log.isInfoEnabled()) { + log.info("Using XDG base directories for " + xdgDirs.stream().map(applicationDir -> applicationDir.toString().toLowerCase(Locale.ROOT)).collect(Collectors.joining(", ")) + + " (config: " + getConfigHome() + ", data: " + getDataHome() + ", cache: " + getCacheHome() + ", state: " + getStateHome() + ")"); } - - if(isWindows()) { - return new File(getHomeDir(), WINDOWS_SPARROW_DIR); - } - - return new File(getHomeDir(), SPARROW_DIR); - } - - static File getHomeDir() { - if(isWindows()) { - return new File(System.getenv("APPDATA")); - } - - return new File(System.getProperty("user.home")); } public static boolean createOwnerOnlyDirectory(File directory) { diff --git a/src/main/java/com/sparrowwallet/sparrow/net/Tor.java b/src/main/java/com/sparrowwallet/sparrow/net/Tor.java index 4d4ec7b8..94a2898a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/Tor.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/Tor.java @@ -40,15 +40,16 @@ public class Tor implements Closeable { private IPSocketAddress socksAddress; public Tor(OnEvent listener) { - Path path = Path.of(Storage.getSparrowHome().getAbsolutePath()).resolve(TOR_DIR); - File oldInstallDir = path.resolve(WORK_DIR).resolve(OLD_INSTALL_DIR).toFile(); + Path workPath = Path.of(Storage.getStateHome().getAbsolutePath()).resolve(TOR_DIR).resolve(WORK_DIR); + Path cachePath = Path.of(Storage.getCacheHome().getAbsolutePath()).resolve(TOR_DIR).resolve(CACHE_DIR); + File oldInstallDir = workPath.resolve(OLD_INSTALL_DIR).toFile(); if(oldInstallDir.exists()) { IOUtils.deleteDirectory(oldInstallDir); } TorRuntime.Environment env = TorRuntime.Environment.Builder( - path.resolve(WORK_DIR).toFile(), - path.resolve(CACHE_DIR).toFile(), + workPath.toFile(), + cachePath.toFile(), ResourceLoaderTorExec::getOrCreate );