Browse Source

Merge pull request #2957 from cryptomator/feature/preprocess-properties

Feature: Preprocess JVM properties at app start to adjust to OS environment.

Closes #2838
Armin Schrenk 1 year ago
parent
commit
067814d5db

+ 6 - 6
.github/workflows/appimage.yml

@@ -85,12 +85,12 @@ jobs:
           --java-options "-Xmx256m"
           --java-options "-Dcryptomator.appVersion=\"${{  needs.get-version.outputs.semVerStr }}\""
           --java-options "-Dfile.encoding=\"utf-8\""
-          --java-options "-Dcryptomator.logDir=\"~/.local/share/Cryptomator/logs\""
-          --java-options "-Dcryptomator.pluginDir=\"~/.local/share/Cryptomator/plugins\""
-          --java-options "-Dcryptomator.settingsPath=\"~/.config/Cryptomator/settings.json:~/.Cryptomator/settings.json\""
-          --java-options "-Dcryptomator.p12Path=\"~/.config/Cryptomator/key.p12\""
-          --java-options "-Dcryptomator.ipcSocketPath=\"~/.config/Cryptomator/ipc.socket\""
-          --java-options "-Dcryptomator.mountPointsDir=\"~/.local/share/Cryptomator/mnt\""
+          --java-options "-Dcryptomator.logDir=\"@{userhome}/.local/share/Cryptomator/logs\""
+          --java-options "-Dcryptomator.pluginDir=\"@{userhome}/.local/share/Cryptomator/plugins\""
+          --java-options "-Dcryptomator.settingsPath=\"@{userhome}/.config/Cryptomator/settings.json:@{userhome}/.Cryptomator/settings.json\""
+          --java-options "-Dcryptomator.p12Path=\"@{userhome}/.config/Cryptomator/key.p12\""
+          --java-options "-Dcryptomator.ipcSocketPath=\"@{userhome}/.config/Cryptomator/ipc.socket\""
+          --java-options "-Dcryptomator.mountPointsDir=\"@{userhome}/.local/share/Cryptomator/mnt\""
           --java-options "-Dcryptomator.showTrayIcon=false"
           --java-options "-Dcryptomator.buildNumber=\"appimage-${{  needs.get-version.outputs.revNum }}\""
           --add-launcher Cryptomator-gtk2=launcher-gtk2.properties

+ 6 - 6
.github/workflows/mac-dmg.yml

@@ -98,13 +98,13 @@ jobs:
           --java-options "-Dapple.awt.enableTemplateImages=true"
           --java-options "-Dsun.java2d.metal=true"
           --java-options "-Dcryptomator.appVersion=\"${{ needs.get-version.outputs.semVerStr }}\""
-          --java-options "-Dcryptomator.logDir=\"~/Library/Logs/Cryptomator\""
-          --java-options "-Dcryptomator.pluginDir=\"~/Library/Application Support/Cryptomator/Plugins\""
-          --java-options "-Dcryptomator.settingsPath=\"~/Library/Application Support/Cryptomator/settings.json\""
-          --java-options "-Dcryptomator.p12Path=\"~/Library/Application Support/Cryptomator/key.p12\""
-          --java-options "-Dcryptomator.ipcSocketPath=\"~/Library/Application Support/Cryptomator/ipc.socket\""
+          --java-options "-Dcryptomator.logDir=\"@{userhome}/Library/Logs/Cryptomator\""
+          --java-options "-Dcryptomator.pluginDir=\"@{userhome}/Library/Application Support/Cryptomator/Plugins\""
+          --java-options "-Dcryptomator.settingsPath=\"@{userhome}/Library/Application Support/Cryptomator/settings.json\""
+          --java-options "-Dcryptomator.p12Path=\"@{userhome}/Library/Application Support/Cryptomator/key.p12\""
+          --java-options "-Dcryptomator.ipcSocketPath=\"@{userhome}/Library/Application Support/Cryptomator/ipc.socket\""
           --java-options "-Dcryptomator.integrationsMac.keychainServiceName=\"Cryptomator\""
-          --java-options "-Dcryptomator.mountPointsDir=\"~/Cryptomator\""
+          --java-options "-Dcryptomator.mountPointsDir=\"@{userhome}/Cryptomator\""
           --java-options "-Dcryptomator.showTrayIcon=true"
           --java-options "-Dcryptomator.buildNumber=\"dmg-${{ needs.get-version.outputs.revNum }}\""
           --mac-package-identifier org.cryptomator

+ 7 - 7
.github/workflows/win-exe.yml

@@ -112,17 +112,17 @@ jobs:
           --java-options "-Xmx256m"
           --java-options "-Dcryptomator.appVersion=\"${{ needs.get-version.outputs.semVerStr }}\""
           --java-options "-Dfile.encoding=\"utf-8\""
-          --java-options "-Dcryptomator.logDir=\"~/AppData/Roaming/Cryptomator\""
-          --java-options "-Dcryptomator.pluginDir=\"~/AppData/Roaming/Cryptomator/Plugins\""
-          --java-options "-Dcryptomator.settingsPath=\"~/AppData/Roaming/Cryptomator/settings.json\""
-          --java-options "-Dcryptomator.p12Path=\"~/AppData/Roaming/Cryptomator/key.p12\""
-          --java-options "-Dcryptomator.ipcSocketPath=\"~/AppData/Roaming/Cryptomator/ipc.socket\""
-          --java-options "-Dcryptomator.mountPointsDir=\"~/Cryptomator\""
+          --java-options "-Dcryptomator.logDir=\"@{localappdata}/Cryptomator\""
+          --java-options "-Dcryptomator.pluginDir=\"@{appdata}/Cryptomator/Plugins\""
+          --java-options "-Dcryptomator.settingsPath=\"@{appdata}/Cryptomator/settings.json:@{userhome}/AppData/Roaming/Cryptomator/settings.json\""
+          --java-options "-Dcryptomator.p12Path=\"@{appdata}/Cryptomator/key.p12:@{userhome}/AppData/Roaming/Cryptomator/key.p12\""
+          --java-options "-Dcryptomator.ipcSocketPath=\"@{localappdata}/Cryptomator/ipc.socket\""
+          --java-options "-Dcryptomator.mountPointsDir=\"@{userhome}/Cryptomator\""
           --java-options "-Dcryptomator.loopbackAlias=\"${{ env.LOOPBACK_ALIAS }}\""
           --java-options "-Dcryptomator.showTrayIcon=true"
           --java-options "-Dcryptomator.buildNumber=\"msi-${{ needs.get-version.outputs.revNum }}\""
           --java-options "-Dcryptomator.integrationsWin.autoStartShellLinkName=\"Cryptomator\""
-          --java-options "-Dcryptomator.integrationsWin.keychainPaths=\"~/AppData/Roaming/Cryptomator/keychain.json\""
+          --java-options "-Dcryptomator.integrationsWin.keychainPaths=\"@{appdata}/Cryptomator/keychain.json:@{userhome}/AppData/Roaming/Cryptomator/keychain.json\""
           --java-options "-Djavafx.verbose=${{ inputs.isDebug }}"
           --resource-dir dist/win/resources
           --icon dist/win/resources/Cryptomator.ico

File diff suppressed because it is too large
+ 1 - 1
.idea/runConfigurations/Cryptomator_Linux.xml


File diff suppressed because it is too large
+ 1 - 1
.idea/runConfigurations/Cryptomator_Linux_Dev.xml


File diff suppressed because it is too large
+ 1 - 1
.idea/runConfigurations/Cryptomator_Windows.xml


File diff suppressed because it is too large
+ 1 - 1
.idea/runConfigurations/Cryptomator_Windows_Dev.xml


File diff suppressed because it is too large
+ 1 - 1
.idea/runConfigurations/Cryptomator_macOS.xml


File diff suppressed because it is too large
+ 1 - 1
.idea/runConfigurations/Cryptomator_macOS_Dev.xml


+ 6 - 6
dist/linux/appimage/build.sh

@@ -50,12 +50,12 @@ ${JAVA_HOME}/bin/jpackage \
     --java-options "-Xmx256m" \
     --app-version "${VERSION}.${REVISION_NO}" \
     --java-options "-Dfile.encoding=\"utf-8\"" \
-    --java-options "-Dcryptomator.logDir=\"~/.local/share/Cryptomator/logs\"" \
-    --java-options "-Dcryptomator.pluginDir=\"~/.local/share/Cryptomator/plugins\"" \
-    --java-options "-Dcryptomator.settingsPath=\"~/.config/Cryptomator/settings.json:~/.Cryptomator/settings.json\"" \
-    --java-options "-Dcryptomator.p12Path=\"~/.config/Cryptomator/key.p12\"" \
-    --java-options "-Dcryptomator.ipcSocketPath=\"~/.config/Cryptomator/ipc.socket\"" \
-    --java-options "-Dcryptomator.mountPointsDir=\"~/.local/share/Cryptomator/mnt\"" \
+    --java-options "-Dcryptomator.logDir=\"@{userhome}/.local/share/Cryptomator/logs\"" \
+    --java-options "-Dcryptomator.pluginDir=\"@{userhome}/.local/share/Cryptomator/plugins\"" \
+    --java-options "-Dcryptomator.settingsPath=\"@{userhome}/.config/Cryptomator/settings.json:@{userhome}/.Cryptomator/settings.json\"" \
+    --java-options "-Dcryptomator.p12Path=\"@{userhome}/.config/Cryptomator/key.p12\"" \
+    --java-options "-Dcryptomator.ipcSocketPath=\"@{userhome}/.config/Cryptomator/ipc.socket\"" \
+    --java-options "-Dcryptomator.mountPointsDir=\"@{userhome}/.local/share/Cryptomator/mnt\"" \
     --java-options "-Dcryptomator.showTrayIcon=false" \
     --java-options "-Dcryptomator.buildNumber=\"appimage-${REVISION_NO}\"" \
     --add-launcher cryptomator-gtk2=launcher-gtk2.properties \

+ 6 - 6
dist/linux/debian/rules

@@ -48,12 +48,12 @@ override_dh_auto_build:
 		--java-options "-Xss5m" \
 		--java-options "-Xmx256m" \
 		--java-options "-Dfile.encoding=\"utf-8\"" \
-		--java-options "-Dcryptomator.logDir=\"~/.local/share/Cryptomator/logs\"" \
-		--java-options "-Dcryptomator.pluginDir=\"~/.local/share/Cryptomator/plugins\"" \
-		--java-options "-Dcryptomator.settingsPath=\"~/.config/Cryptomator/settings.json:~/.Cryptomator/settings.json\"" \
-		--java-options "-Dcryptomator.p12Path=\"~/.config/Cryptomator/key.p12\"" \
-		--java-options "-Dcryptomator.ipcSocketPath=\"~/.config/Cryptomator/ipc.socket\"" \
-		--java-options "-Dcryptomator.mountPointsDir=\"~/.local/share/Cryptomator/mnt\"" \
+		--java-options "-Dcryptomator.logDir=\"@{userhome}/.local/share/Cryptomator/logs\"" \
+		--java-options "-Dcryptomator.pluginDir=\"@{userhome}/.local/share/Cryptomator/plugins\"" \
+		--java-options "-Dcryptomator.settingsPath=\"@{userhome}/.config/Cryptomator/settings.json:@{userhome}/.Cryptomator/settings.json\"" \
+		--java-options "-Dcryptomator.p12Path=\"@{userhome}/.config/Cryptomator/key.p12\"" \
+		--java-options "-Dcryptomator.ipcSocketPath=\"@{userhome}/.config/Cryptomator/ipc.socket\"" \
+		--java-options "-Dcryptomator.mountPointsDir=\"@{userhome}/.local/share/Cryptomator/mnt\"" \
 		--java-options "-Dcryptomator.showTrayIcon=false" \
 		--java-options "-Dcryptomator.buildNumber=\"deb-${REVISION_NUM}\"" \
 		--java-options "-Dcryptomator.appVersion=\"${SEMVER_STR}\"" \

+ 6 - 6
dist/mac/dmg/build.sh

@@ -74,13 +74,13 @@ ${JAVA_HOME}/bin/jpackage \
     --java-options "-Dapple.awt.enableTemplateImages=true" \
     --java-options "-Dsun.java2d.metal=true" \
     --java-options "-Dcryptomator.appVersion=\"${VERSION_NO}\"" \
-    --java-options "-Dcryptomator.logDir=\"~/Library/Logs/${APP_NAME}\"" \
-    --java-options "-Dcryptomator.pluginDir=\"~/Library/Application Support/${APP_NAME}/Plugins\"" \
-    --java-options "-Dcryptomator.settingsPath=\"~/Library/Application Support/${APP_NAME}/settings.json\"" \
-    --java-options "-Dcryptomator.ipcSocketPath=\"~/Library/Application Support/${APP_NAME}/ipc.socket\"" \
-    --java-options "-Dcryptomator.p12Path=\"~/Library/Application Support/${APP_NAME}/key.p12\"" \
+    --java-options "-Dcryptomator.logDir=\"@{userhome}/Library/Logs/${APP_NAME}\"" \
+    --java-options "-Dcryptomator.pluginDir=\"@{userhome}/Library/Application Support/${APP_NAME}/Plugins\"" \
+    --java-options "-Dcryptomator.settingsPath=\"@{userhome}/Library/Application Support/${APP_NAME}/settings.json\"" \
+    --java-options "-Dcryptomator.ipcSocketPath=\"@{userhome}/Library/Application Support/${APP_NAME}/ipc.socket\"" \
+    --java-options "-Dcryptomator.p12Path=\"@{userhome}/Library/Application Support/${APP_NAME}/key.p12\"" \
     --java-options "-Dcryptomator.integrationsMac.keychainServiceName=\"${APP_NAME}\"" \
-    --java-options "-Dcryptomator.mountPointsDir=\"~/${APP_NAME}\"" \
+    --java-options "-Dcryptomator.mountPointsDir=\"@{userhome}/${APP_NAME}\"" \
     --java-options "-Dcryptomator.showTrayIcon=true" \
     --java-options "-Dcryptomator.buildNumber=\"dmg-${REVISION_NO}\"" \
     --mac-package-identifier ${PACKAGE_IDENTIFIER} \

+ 7 - 7
dist/win/build.ps1

@@ -82,15 +82,15 @@ if ($clean -and (Test-Path -Path $appPath)) {
 	--java-options "-Dcryptomator.appVersion=`"$semVerNo`"" `
 	--app-version "$semVerNo.$revisionNo" `
 	--java-options "-Dfile.encoding=`"utf-8`"" `
-	--java-options "-Dcryptomator.logDir=`"~/AppData/Roaming/$AppName`"" `
-	--java-options "-Dcryptomator.pluginDir=`"~/AppData/Roaming/$AppName/Plugins`"" `
-	--java-options "-Dcryptomator.settingsPath=`"~/AppData/Roaming/$AppName/settings.json`"" `
-	--java-options "-Dcryptomator.ipcSocketPath=`"~/AppData/Roaming/$AppName/ipc.socket`"" `
-	--java-options "-Dcryptomator.p12Path=`"~/AppData/Roaming/$AppName/key.p12`"" `
-	--java-options "-Dcryptomator.mountPointsDir=`"~/$AppName`"" `
+	--java-options "-Dcryptomator.logDir=`"@{localappdata}/$AppName`"" `
+	--java-options "-Dcryptomator.pluginDir=`"@{appdata}/$AppName/Plugins`"" `
+	--java-options "-Dcryptomator.settingsPath=`"@{appdata}/$AppName/settings.json:@{userhome}/AppData/Roaming/$AppName/settings.json`"" `
+	--java-options "-Dcryptomator.ipcSocketPath=`"@{localappdata}/$AppName/ipc.socket`"" `
+	--java-options "-Dcryptomator.p12Path=`"@{appdata}/$AppName/key.p12:@{userhome}/AppData/Roaming/$AppName/key.p12`"" `
+	--java-options "-Dcryptomator.mountPointsDir=`"@{userhome}/$AppName`"" `
 	--java-options "-Dcryptomator.loopbackAlias=`"$LoopbackAlias`"" `
 	--java-options "-Dcryptomator.integrationsWin.autoStartShellLinkName=`"$AppName`"" `
-	--java-options "-Dcryptomator.integrationsWin.keychainPaths=`"~/AppData/Roaming/$AppName/keychain.json`"" `
+	--java-options "-Dcryptomator.integrationsWin.keychainPaths=`"@{appdata}/$AppName/keychain.json:@{userhome}/AppData/Roaming/$AppName/keychain.json`"" `
 	--java-options "-Dcryptomator.showTrayIcon=true" `
 	--java-options "-Dcryptomator.buildNumber=`"msi-$revisionNo`"" `
 	--resource-dir resources `

+ 6 - 20
src/main/java/org/cryptomator/common/Environment.java

@@ -18,7 +18,6 @@ import java.util.stream.StreamSupport;
 public class Environment {
 
 	private static final Logger LOG = LoggerFactory.getLogger(Environment.class);
-	private static final Path RELATIVE_HOME_DIR = Paths.get("~");
 	private static final char PATH_LIST_SEP = ':';
 	private static final int DEFAULT_MIN_PW_LENGTH = 8;
 	private static final String SETTINGS_PATH_PROP_NAME = "cryptomator.settingsPath";
@@ -80,7 +79,7 @@ public class Environment {
 		return getPaths(P12_PATH_PROP_NAME);
 	}
 
-	public Stream<Path> ipcSocketPath() {
+	public Stream<Path> getIpcSocketPath() {
 		return getPaths(IPC_SOCKET_PATH_PROP_NAME);
 	}
 
@@ -89,7 +88,7 @@ public class Environment {
 	}
 
 	public Optional<Path> getLogDir() {
-		return getPath(LOG_DIR_PROP_NAME).map(this::replaceHomeDir);
+		return getPath(LOG_DIR_PROP_NAME);
 	}
 
 	public Optional<String> getLoopbackAlias() {
@@ -97,11 +96,11 @@ public class Environment {
 	}
 
 	public Optional<Path> getPluginDir() {
-		return getPath(PLUGIN_DIR_PROP_NAME).map(this::replaceHomeDir);
+		return getPath(PLUGIN_DIR_PROP_NAME);
 	}
 
 	public Optional<Path> getMountPointsDir() {
-		return getPath(MOUNTPOINT_DIR_PROP_NAME).map(this::replaceHomeDir);
+		return getPath(MOUNTPOINT_DIR_PROP_NAME);
 	}
 
 	/**
@@ -131,22 +130,9 @@ public class Environment {
 	}
 
 	// visible for testing
-	public Path getHomeDir() {
-		return getPath("user.home").orElseThrow();
-	}
-
-	// visible for testing
-	public Stream<Path> getPaths(String propertyName) {
+	Stream<Path> getPaths(String propertyName) {
 		Stream<String> rawSettingsPaths = getRawList(propertyName, PATH_LIST_SEP);
-		return rawSettingsPaths.filter(Predicate.not(Strings::isNullOrEmpty)).map(Paths::get).map(this::replaceHomeDir);
-	}
-
-	private Path replaceHomeDir(Path path) {
-		if (path.startsWith(RELATIVE_HOME_DIR)) {
-			return getHomeDir().resolve(RELATIVE_HOME_DIR.relativize(path));
-		} else {
-			return path;
-		}
+		return rawSettingsPaths.filter(Predicate.not(Strings::isNullOrEmpty)).map(Path::of);
 	}
 
 	private Stream<String> getRawList(String propertyName, char separator) {

+ 171 - 0
src/main/java/org/cryptomator/common/PropertiesDecorator.java

@@ -0,0 +1,171 @@
+package org.cryptomator.common;
+
+import org.jetbrains.annotations.Nullable;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.PrintStream;
+import java.io.PrintWriter;
+import java.io.Reader;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.util.Collection;
+import java.util.Enumeration;
+import java.util.InvalidPropertiesFormatException;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+class PropertiesDecorator extends Properties {
+
+	protected final Properties delegate;
+
+	PropertiesDecorator(Properties delegate) {
+		this.delegate = delegate;
+	}
+
+	@Override
+	public String getProperty(String key) {return delegate.getProperty(key);}
+
+	@Override
+	public String getProperty(String key, String defaultValue) {return delegate.getProperty(key, defaultValue);}
+
+	@Override
+	public synchronized Object setProperty(String key, String value) {
+		return delegate.setProperty(key, value);
+	}
+
+	@Override
+	public synchronized void load(Reader reader) throws IOException {delegate.load(reader);}
+
+	@Override
+	public synchronized void load(InputStream inStream) throws IOException {delegate.load(inStream);}
+
+	@Override
+	public void store(Writer writer, String comments) throws IOException {delegate.store(writer, comments);}
+
+	@Override
+	public void store(OutputStream out, @Nullable String comments) throws IOException {delegate.store(out, comments);}
+
+	@Override
+	public synchronized void loadFromXML(InputStream in) throws IOException, InvalidPropertiesFormatException {delegate.loadFromXML(in);}
+
+	@Override
+	public void storeToXML(OutputStream os, String comment) throws IOException {delegate.storeToXML(os, comment);}
+
+	@Override
+	public void storeToXML(OutputStream os, String comment, String encoding) throws IOException {delegate.storeToXML(os, comment, encoding);}
+
+	@Override
+	public void storeToXML(OutputStream os, String comment, Charset charset) throws IOException {delegate.storeToXML(os, comment, charset);}
+
+	@Override
+	public Enumeration<?> propertyNames() {return delegate.propertyNames();}
+
+	@Override
+	public Set<String> stringPropertyNames() {return delegate.stringPropertyNames();}
+
+	@Override
+	public void list(PrintStream out) {delegate.list(out);}
+
+	@Override
+	public void list(PrintWriter out) {delegate.list(out);}
+
+	@Override
+	public int size() {return delegate.size();}
+
+	@Override
+	public boolean isEmpty() {return delegate.isEmpty();}
+
+	@Override
+	public Enumeration<Object> keys() {return delegate.keys();}
+
+	@Override
+	public Enumeration<Object> elements() {return delegate.elements();}
+
+	@Override
+	public boolean contains(Object value) {return delegate.contains(value);}
+
+	@Override
+	public boolean containsValue(Object value) {return delegate.containsValue(value);}
+
+	@Override
+	public boolean containsKey(Object key) {return delegate.containsKey(key);}
+
+	@Override
+	public Object get(Object key) {return delegate.get(key);}
+
+	@Override
+	public synchronized Object put(Object key, Object value) {return delegate.put(key, value);}
+
+	@Override
+	public synchronized Object remove(Object key) {return delegate.remove(key);}
+
+	@Override
+	public synchronized void putAll(Map<?, ?> t) {delegate.putAll(t);}
+
+	@Override
+	public synchronized void clear() {delegate.clear();}
+
+	@Override
+	public synchronized String toString() {return delegate.toString();}
+
+	@Override
+	public Set<Object> keySet() {return delegate.keySet();}
+
+	@Override
+	public Collection<Object> values() {return delegate.values();}
+
+	@Override
+	public Set<Map.Entry<Object, Object>> entrySet() {return delegate.entrySet();}
+
+	@Override
+	public synchronized boolean equals(Object o) {return delegate.equals(o);}
+
+	@Override
+	public synchronized int hashCode() {return delegate.hashCode();}
+
+	@Override
+	public Object getOrDefault(Object key, Object defaultValue) {return delegate.getOrDefault(key, defaultValue);}
+
+	@Override
+	public synchronized void forEach(BiConsumer<? super Object, ? super Object> action) {delegate.forEach(action);}
+
+	@Override
+	public synchronized void replaceAll(BiFunction<? super Object, ? super Object, ?> function) {delegate.replaceAll(function);}
+
+	@Override
+	public synchronized Object putIfAbsent(Object key, Object value) {return delegate.putIfAbsent(key, value);}
+
+	@Override
+	public synchronized boolean remove(Object key, Object value) {return delegate.remove(key, value);}
+
+	@Override
+	public synchronized boolean replace(Object key, Object oldValue, Object newValue) {return delegate.replace(key, oldValue, newValue);}
+
+	@Override
+	public synchronized Object replace(Object key, Object value) {return delegate.replace(key, value);}
+
+	@Override
+	public synchronized Object computeIfAbsent(Object key, Function<? super Object, ?> mappingFunction) {return delegate.computeIfAbsent(key, mappingFunction);}
+
+	@Override
+	public synchronized Object computeIfPresent(Object key, BiFunction<? super Object, ? super Object, ?> remappingFunction) {return delegate.computeIfPresent(key, remappingFunction);}
+
+	@Override
+	public synchronized Object compute(Object key, BiFunction<? super Object, ? super Object, ?> remappingFunction) {return delegate.compute(key, remappingFunction);}
+
+	@Override
+	public synchronized Object merge(Object key, Object value, BiFunction<? super Object, ? super Object, ?> remappingFunction) {return delegate.merge(key, value, remappingFunction);}
+
+	@Override
+	public synchronized Object clone() {
+		var delegateClone = (Properties) delegate.clone();
+		return new PropertiesDecorator(delegateClone);
+	}
+
+}

+ 70 - 0
src/main/java/org/cryptomator/common/SubstitutingProperties.java

@@ -0,0 +1,70 @@
+package org.cryptomator.common;
+
+import org.jetbrains.annotations.VisibleForTesting;
+import org.slf4j.LoggerFactory;
+
+import java.util.Map;
+import java.util.Properties;
+import java.util.regex.Pattern;
+
+public class SubstitutingProperties extends PropertiesDecorator {
+
+	private static final Pattern TEMPLATE = Pattern.compile("@\\{(\\w+)}");
+
+	private final Map<String, String> env;
+
+	public SubstitutingProperties(Properties props, Map<String, String> systemEnvironment) {
+		super(props);
+		this.env = systemEnvironment;
+	}
+
+	@Override
+	public String getProperty(String key) {
+		var value = delegate.getProperty(key);
+		if (key.startsWith("cryptomator.") && value != null) {
+			return process(value);
+		} else {
+			return value;
+		}
+	}
+
+	@Override
+	public String getProperty(String key, String defaultValue) {
+		var result = getProperty(key);
+		return result != null ? result : defaultValue;
+	}
+
+	@VisibleForTesting
+	String process(String value) {
+		return TEMPLATE.matcher(value).replaceAll(match -> //
+				switch (match.group(1)) {
+					case "appdir" -> resolveFrom("APPDIR", Source.ENV);
+					case "appdata" -> resolveFrom("APPDATA", Source.ENV);
+					case "localappdata" -> resolveFrom("LOCALAPPDATA", Source.ENV);
+					case "userhome" -> resolveFrom("user.home", Source.PROPS);
+					default -> {
+						LoggerFactory.getLogger(SubstitutingProperties.class).warn("Unknown variable {} in property value {}.", match.group(), value);
+						yield match.group();
+					}
+				});
+	}
+
+	private String resolveFrom(String key, Source src) {
+		var val = switch (src) {
+			case ENV -> env.get(key);
+			case PROPS -> delegate.getProperty(key);
+		};
+		if (val == null) {
+			LoggerFactory.getLogger(SubstitutingProperties.class).warn("Variable {} used for substitution not found in {}. Replaced with empty string.", key, src);
+			return "";
+		} else {
+			return val.replace("\\", "\\\\");
+		}
+	}
+
+	private enum Source {
+		ENV,
+		PROPS;
+	}
+
+}

+ 12 - 4
src/main/java/org/cryptomator/launcher/Cryptomator.java

@@ -9,6 +9,7 @@ import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import dagger.Lazy;
 import org.apache.commons.lang3.SystemUtils;
 import org.cryptomator.common.Environment;
+import org.cryptomator.common.SubstitutingProperties;
 import org.cryptomator.common.ShutdownHook;
 import org.cryptomator.ipc.IpcCommunicator;
 import org.cryptomator.logging.DebugMode;
@@ -29,10 +30,18 @@ import java.util.concurrent.Executors;
 public class Cryptomator {
 
 	private static final long STARTUP_TIME = System.currentTimeMillis();
+
+	static {
+		var lazyProcessedProps = new SubstitutingProperties(System.getProperties(), System.getenv());
+		System.setProperties(lazyProcessedProps);
+		CRYPTOMATOR_COMPONENT = DaggerCryptomatorComponent.factory().create(STARTUP_TIME);
+		LOG = LoggerFactory.getLogger(Cryptomator.class);
+	}
+
 	// DaggerCryptomatorComponent gets generated by Dagger.
 	// Run Maven and include target/generated-sources/annotations in your IDE.
-	private static final CryptomatorComponent CRYPTOMATOR_COMPONENT = DaggerCryptomatorComponent.factory().create(STARTUP_TIME);
-	private static final Logger LOG = LoggerFactory.getLogger(Cryptomator.class);
+	private static final CryptomatorComponent CRYPTOMATOR_COMPONENT;
+	private static final Logger LOG;
 
 	private final DebugMode debugMode;
 	private final SupportedLanguages supportedLanguages;
@@ -63,7 +72,6 @@ public class Cryptomator {
 			System.out.printf("Cryptomator version %s (build %s)%n", appVer, buildNumber);
 			return;
 		}
-
 		int exitCode = CRYPTOMATOR_COMPONENT.application().run(args);
 		LOG.info("Exit {}", exitCode);
 		System.exit(exitCode); // end remaining non-daemon threads.
@@ -86,7 +94,7 @@ public class Cryptomator {
 		 * Attempts to create an IPC connection to a running Cryptomator instance and sends it the given args.
 		 * If no external process could be reached, the args will be handled by the loopback IPC endpoint.
 		 */
-		try (var communicator = IpcCommunicator.create(env.ipcSocketPath().toList())) {
+		try (var communicator = IpcCommunicator.create(env.getIpcSocketPath().toList())) {
 			if (communicator.isClient()) {
 				communicator.sendHandleLaunchargs(List.of(args));
 				communicator.sendRevealRunningApp();

+ 36 - 63
src/test/java/org/cryptomator/common/EnvironmentTest.java

@@ -13,6 +13,7 @@ import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.List;
 import java.util.Optional;
+import java.util.stream.Stream;
 
 @DisplayName("Environment Variables Test")
 public class EnvironmentTest {
@@ -22,41 +23,7 @@ public class EnvironmentTest {
 	@BeforeEach
 	public void init() {
 		env = Mockito.spy(Environment.getInstance());
-		Mockito.when(env.getHomeDir()).thenReturn(Path.of("/home/testuser"));
 	}
-
-	@Test
-	@DisplayName("cryptomator.settingsPath=~/.config/Cryptomator/settings.json:~/.Cryptomator/settings.json")
-	public void testSettingsPath() {
-		System.setProperty("cryptomator.settingsPath", "~/.config/Cryptomator/settings.json:~/.Cryptomator/settings.json");
-
-		List<Path> result = env.getSettingsPath().toList();
-		MatcherAssert.assertThat(result, Matchers.hasSize(2));
-		MatcherAssert.assertThat(result, Matchers.contains(Paths.get("/home/testuser/.config/Cryptomator/settings.json"), //
-				Paths.get("/home/testuser/.Cryptomator/settings.json")));
-	}
-
-	@Test
-	@DisplayName("cryptomator.ipcSocketPath=~/.config/Cryptomator/ipc.socket:~/.Cryptomator/ipc.socket")
-	public void testIpcSocketPath() {
-		System.setProperty("cryptomator.ipcSocketPath", "~/.config/Cryptomator/ipc.socket:~/.Cryptomator/ipc.socket");
-
-		List<Path> result = env.ipcSocketPath().toList();
-		MatcherAssert.assertThat(result, Matchers.hasSize(2));
-		MatcherAssert.assertThat(result, Matchers.contains(Paths.get("/home/testuser/.config/Cryptomator/ipc.socket"), //
-				Paths.get("/home/testuser/.Cryptomator/ipc.socket")));
-	}
-
-	@Test
-	@DisplayName("cryptomator.integrationsWin.keychainPaths=~/AppData/Roaming/Cryptomator/keychain.json")
-	public void testKeychainPath() {
-		System.setProperty("cryptomator.integrationsWin.keychainPaths", "~/AppData/Roaming/Cryptomator/keychain.json");
-
-		List<Path> result = env.getKeychainPath().toList();
-		MatcherAssert.assertThat(result, Matchers.hasSize(1));
-		MatcherAssert.assertThat(result, Matchers.contains(Paths.get("/home/testuser/AppData/Roaming/Cryptomator/keychain.json")));
-	}
-
 	@Test
 	@DisplayName("cryptomator.logDir=/foo/bar")
 	public void testAbsoluteLogDir() {
@@ -67,20 +34,9 @@ public class EnvironmentTest {
 		Assertions.assertTrue(logDir.isPresent());
 	}
 
-	@Test
-	@DisplayName("cryptomator.logDir=~/foo/bar")
-	public void testRelativeLogDir() {
-		System.setProperty("cryptomator.logDir", "~/foo/bar");
-
-		Optional<Path> logDir = env.getLogDir();
-
-		Assertions.assertTrue(logDir.isPresent());
-		Assertions.assertEquals(Paths.get("/home/testuser/foo/bar"), logDir.get());
-	}
-
 	@Nested
-	@DisplayName("Path Lists")
-	public class SettingsPath {
+	@DisplayName("Testing parsing path lists")
+	public class PathLists {
 
 		@Test
 		@DisplayName("test.path.property=")
@@ -93,7 +49,7 @@ public class EnvironmentTest {
 
 		@Test
 		@DisplayName("test.path.property=/foo/bar/test")
-		public void testSingleAbsolutePath() {
+		public void testSinglePath() {
 			System.setProperty("test.path.property", "/foo/bar/test");
 			List<Path> result = env.getPaths("test.path.property").toList();
 
@@ -102,27 +58,44 @@ public class EnvironmentTest {
 		}
 
 		@Test
-		@DisplayName("test.path.property=~/test")
-		public void testSingleHomeRelativePath() {
-			System.setProperty("test.path.property", "~/test");
+		@DisplayName("test.path.property=/foo/bar/test:/bar/nez/tost")
+		public void testTwoPaths() {
+			System.setProperty("test.path.property", "/foo/bar/test:bar/nez/tost");
 			List<Path> result = env.getPaths("test.path.property").toList();
 
-			MatcherAssert.assertThat(result, Matchers.hasSize(1));
-			MatcherAssert.assertThat(result, Matchers.hasItem(Paths.get("/home/testuser/test")));
+			MatcherAssert.assertThat(result, Matchers.hasSize(2));
+			MatcherAssert.assertThat(result, Matchers.hasItems(Path.of("/foo/bar/test"), Path.of("bar/nez/tost")));
 		}
 
-		@Test
-		@DisplayName("test.path.property=~/test:~/test2:/foo/bar/test")
-		public void testMultiplePaths() {
-			System.setProperty("test.path.property", "~/test:~/test2:/foo/bar/test");
-			List<Path> result = env.getPaths("test.path.property").toList();
+	}
 
-			MatcherAssert.assertThat(result, Matchers.hasSize(3));
-			MatcherAssert.assertThat(result, Matchers.contains(Paths.get("/home/testuser/test"), //
-					Paths.get("/home/testuser/test2"), //
-					Paths.get("/foo/bar/test")));
-		}
+	@Nested
+	public class VariablesContainingPathLists {
 
+		@Test
+		public void testSettingsPath() {
+			Mockito.doReturn(Stream.of()).when(env).getPaths(Mockito.anyString());
+			env.getSettingsPath();
+			Mockito.verify(env).getPaths("cryptomator.settingsPath");
+		}
+		@Test
+		public void testP12Path() {
+			Mockito.doReturn(Stream.of()).when(env).getPaths(Mockito.anyString());
+			env.getP12Path();
+			Mockito.verify(env).getPaths("cryptomator.p12Path");
+		}
+		@Test
+		public void testIpcSocketPath() {
+			Mockito.doReturn(Stream.of()).when(env).getPaths(Mockito.anyString());
+			env.getIpcSocketPath();
+			Mockito.verify(env).getPaths("cryptomator.ipcSocketPath");
+		}
+		@Test
+		public void testKeychainPath() {
+			Mockito.doReturn(Stream.of()).when(env).getPaths(Mockito.anyString());
+			env.getKeychainPath();
+			Mockito.verify(env).getPaths("cryptomator.integrationsWin.keychainPaths");
+		}
 	}
 
 }

+ 151 - 0
src/test/java/org/cryptomator/common/SubstitutingPropertiesTest.java

@@ -0,0 +1,151 @@
+package org.cryptomator.common;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mockito;
+
+import java.util.Map;
+import java.util.Properties;
+
+public class SubstitutingPropertiesTest {
+
+	SubstitutingProperties inTest;
+
+	@Nested
+	public class Processing {
+
+		@ParameterizedTest
+		@DisplayName("Test template replacement")
+		@CsvSource(textBlock = """
+				unknown.@{testToken}.test, unknown.@{testToken}.test
+				@{only*words*digits*under_score},@{only*words*digits*under_score}
+				C:\\Users\\@{appdir}\\dir, C:\\Users\\foobar\\dir
+				@{@{appdir}},@{foobar}
+				Replacing several @{appdir} with @{appdir}., Replacing several foobar with foobar.""")
+		public void test(String propertyValue, String expected) {
+			SubstitutingProperties inTest = new SubstitutingProperties(Mockito.mock(Properties.class), Map.of("APPDIR", "foobar"));
+			var result = inTest.process(propertyValue);
+			Assertions.assertEquals(expected, result);
+		}
+
+		@Test
+		@DisplayName("@{userhome} is replaced with the user home directory")
+		public void testPropSubstitutions() {
+			var props = new Properties();
+			props.setProperty("user.home", "OneUponABit");
+
+			inTest = new SubstitutingProperties(props, Map.of());
+			var result = inTest.process("@{userhome}");
+			Assertions.assertEquals("OneUponABit", result);
+		}
+
+		@DisplayName("Other keywords are replaced accordingly")
+		@ParameterizedTest(name = "Token \"{0}\" replaced with content of {1}")
+		@CsvSource(value = {"appdir, APPDIR, foobar", "appdata, APPDATA, bazbaz", "localappdata, LOCALAPPDATA, boboAlice"})
+		public void testEnvSubstitutions(String token, String envName, String expected) {
+			inTest = new SubstitutingProperties(new Properties(), Map.of(envName, expected));
+			var result = inTest.process("@{" + token + "}");
+			Assertions.assertEquals(expected, result);
+		}
+
+	}
+
+
+	@Nested
+	public class GetProperty {
+
+		@Test
+		@DisplayName("Undefined properties are not processed")
+		public void testNoProcessingOnNull() {
+			inTest = Mockito.spy(new SubstitutingProperties(new Properties(), Map.of()));
+
+			var result = inTest.getProperty("some.prop");
+			Assertions.assertNull(result);
+			Mockito.verify(inTest, Mockito.never()).process(Mockito.anyString());
+		}
+
+		@ParameterizedTest
+		@DisplayName("Properties not starting with \"cryptomator.\" are not processed")
+		@ValueSource(strings = {"example.foo", "cryptomatorSomething.foo", "org.cryptomator.foo", "cryPtoMAtor.foo"})
+		public void testNoProcessingOnNotCryptomator(String propKey) {
+			var props = new Properties();
+			props.setProperty(propKey, "someValue");
+			inTest = Mockito.spy(new SubstitutingProperties(props, Map.of()));
+
+			var result = inTest.getProperty("some.prop");
+			Assertions.assertNull(result);
+			Mockito.verify(inTest, Mockito.never()).process(Mockito.anyString());
+		}
+
+		@Test
+		@DisplayName("Non-null property starting with \"cryptomator.\" is processed")
+		public void testProcessing() {
+			var props = new Properties();
+			props.setProperty("cryptomator.prop", "someValue");
+			inTest = Mockito.spy(new SubstitutingProperties(props, Map.of()));
+			Mockito.doReturn("someValue").when(inTest).process(Mockito.anyString());
+
+			inTest.getProperty("cryptomator.prop");
+			Mockito.verify(inTest).process("someValue");
+		}
+
+		@Test
+		@DisplayName("Default value is not processed")
+		public void testNoProcessingDefault() {
+			var props = Mockito.mock(Properties.class);
+			Mockito.when(props.getProperty("cryptomator.prop")).thenReturn(null);
+			inTest = Mockito.spy(new SubstitutingProperties(props, Map.of()));
+			Mockito.doReturn("someValue").when(inTest).process(Mockito.anyString());
+
+			var result = inTest.getProperty("cryptomator.prop", "a default");
+			Assertions.assertEquals("a default", result);
+			Mockito.verify(inTest, Mockito.never()).process(Mockito.any());
+		}
+	}
+
+	@ParameterizedTest(name = "{0}={1} -> {0}={2}")
+	@DisplayName("Replace @{userhome} during getProperty()")
+	@CsvSource(quoteCharacter = '"', textBlock = """
+			cryptomator.settingsPath, "@{userhome}/.config/Cryptomator/settings.json:@{userhome}/.Cryptomator/settings.json", "/home/.config/Cryptomator/settings.json:/home/.Cryptomator/settings.json"
+			cryptomator.ipcSocketPath, "@{userhome}/.config/Cryptomator/ipc.socket:@{userhome}/.Cryptomator/ipc.socket", "/home/.config/Cryptomator/ipc.socket:/home/.Cryptomator/ipc.socket"
+			not.cryptomator.not.substituted, "@{userhome}/foo", "@{userhome}/foo"
+			cryptomator.no.placeholder.found, "foo/bar", "foo/bar"
+			""")
+	public void testEndToEndPropsSource(String key, String raw, String substituted) {
+		var delegate = Mockito.mock(Properties.class);
+		Mockito.doReturn("/home").when(delegate).getProperty("user.home");
+		Mockito.doReturn(raw).when(delegate).getProperty(key);
+		var inTest = new SubstitutingProperties(delegate, Map.of());
+
+		var result = inTest.getProperty(key);
+
+		Assertions.assertEquals(substituted, result);
+	}
+
+	@ParameterizedTest(name = "{0}={1} -> {0}={2}")
+	@DisplayName("Replace appdata,localappdata or appdir during getProperty()")
+	@CsvSource(quoteCharacter = '"', textBlock = """
+			cryptomator.settingsPath, "@{appdata}/Cryptomator/settings.json", "C:\\Users\\JimFang\\AppData\\Roaming/Cryptomator/settings.json"
+			cryptomator.ipcSocketPath, "@{localappdata}/Cryptomator/ipc.socket", "C:\\Users\\JimFang\\AppData\\Local/Cryptomator/ipc.socket"
+			cryptomator.integrationsLinux.trayIconsDir, "@{appdir}/hicolor", "/squashfs1337/usr/hicolor"
+			not.cryptomator.not.substituted, "@{appdir}/foo", "@{appdir}/foo"
+			cryptomator.no.placeholder.found, "foo/bar", "foo/bar"
+			""")
+	public void testEndToEndEnvSource(String key, String raw, String substituted) {
+		var delegate = Mockito.mock(Properties.class);
+		Mockito.doReturn(raw).when(delegate).getProperty(key);
+		var env = Map.of("APPDATA", "C:\\Users\\JimFang\\AppData\\Roaming", //
+				"LOCALAPPDATA", "C:\\Users\\JimFang\\AppData\\Local", //
+				"APPDIR", "/squashfs1337/usr");
+		var inTest = new SubstitutingProperties(delegate, env);
+
+		var result = inTest.getProperty(key);
+
+		Assertions.assertEquals(substituted, result);
+	}
+}