diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 226a80d..b327f97 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,16 +11,17 @@ jobs: strategy: matrix: os: + - windows-latest - ubuntu-latest runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - name: Set up JDK 11 + - name: Set up JDK 22 uses: actions/setup-java@v2 with: - java-version: '11' - distribution: 'adopt' + java-version: '22' + distribution: 'temurin' - name: Run tests run: sbt test diff --git a/src/main/java/dev/dirs/BaseDirectories.java b/src/main/java/dev/dirs/BaseDirectories.java index 137a6fe..ab7aae2 100644 --- a/src/main/java/dev/dirs/BaseDirectories.java +++ b/src/main/java/dev/dirs/BaseDirectories.java @@ -1,6 +1,8 @@ package dev.dirs; -import static dev.dirs.Util.*; +import dev.dirs.impl.Linux; +import dev.dirs.impl.Util; +import dev.dirs.impl.Windows; /** {@code BaseDirectories} provides paths of user-invisible standard directories, following the conventions of the operating system the library is running on. *

@@ -247,22 +249,22 @@ public static BaseDirectories get() { } private BaseDirectories() { - switch (operatingSystem) { - case LIN: - case BSD: - case SOLARIS: - case IBMI: - case AIX: + switch (Constants.operatingSystem) { + case Constants.LIN: + case Constants.BSD: + case Constants.SOLARIS: + case Constants.IBMI: + case Constants.AIX: homeDir = System.getProperty("user.home"); - cacheDir = defaultIfNullOrEmpty(System.getenv("XDG_CACHE_HOME"), homeDir, "/.cache"); - configDir = defaultIfNullOrEmpty(System.getenv("XDG_CONFIG_HOME"), homeDir, "/.config"); - dataDir = defaultIfNullOrEmpty(System.getenv("XDG_DATA_HOME"), homeDir, "/.local/share"); + cacheDir = Util.defaultIfNullOrEmpty(System.getenv("XDG_CACHE_HOME"), homeDir, "/.cache"); + configDir = Util.defaultIfNullOrEmpty(System.getenv("XDG_CONFIG_HOME"), homeDir, "/.config"); + dataDir = Util.defaultIfNullOrEmpty(System.getenv("XDG_DATA_HOME"), homeDir, "/.local/share"); dataLocalDir = dataDir; - executableDir = linuxExecutableDir(homeDir, dataDir); + executableDir = Linux.executableDir(homeDir, dataDir); preferenceDir = configDir; - runtimeDir = linuxRuntimeDir(null); + runtimeDir = Linux.runtimeDir(null); break; - case MAC: + case Constants.MAC: homeDir = System.getProperty("user.home"); cacheDir = homeDir + "/Library/Caches/"; configDir = homeDir + "/Library/Application Support/"; @@ -272,11 +274,10 @@ private BaseDirectories() { preferenceDir = homeDir + "/Library/Preferences/"; runtimeDir = null; break; - case WIN: - String[] winDirs = getWinDirs("5E6C858F-0E22-4760-9AFE-EA3317B67173", "3EB685DB-65F9-4CF6-A03A-E3EF65729F3D", "F1B32785-6FBA-4FCF-9D55-7B8E7F157091"); - homeDir = winDirs[0]; - dataDir = winDirs[1]; - dataLocalDir = winDirs[2]; + case Constants.WIN: + homeDir = Windows.getProfileDir();; + dataDir = Windows.getRoamingAppDataDir();; + dataLocalDir = Windows.getLocalAppDataDir();; configDir = dataDir; cacheDir = dataLocalDir; executableDir = null; @@ -284,13 +285,13 @@ private BaseDirectories() { runtimeDir = null; break; default: - throw new UnsupportedOperatingSystemException("Base directories are not supported on " + operatingSystemName); + throw new UnsupportedOperatingSystemException("Base directories are not supported on " + Constants.operatingSystemName); } } @Override public String toString() { - return "BaseDirectories (" + operatingSystemName + "):\n" + + return "BaseDirectories (" + Constants.operatingSystemName + "):\n" + " homeDir = '" + homeDir + "'\n" + " cacheDir = '" + cacheDir + "'\n" + " configDir = '" + configDir + "'\n" + diff --git a/src/main/java/dev/dirs/Constants.java b/src/main/java/dev/dirs/Constants.java new file mode 100644 index 0000000..3e4b452 --- /dev/null +++ b/src/main/java/dev/dirs/Constants.java @@ -0,0 +1,39 @@ +package dev.dirs; + +import java.util.Locale; + +public class Constants { + + static final String operatingSystemName = System.getProperty("os.name"); + public static final char operatingSystem; + static final char LIN = 'l'; + static final char MAC = 'm'; + static final char WIN = 'w'; + static final char BSD = 'b'; + static final char SOLARIS = 's'; + static final char IBMI = 'i'; + static final char AIX = 'a'; + + public static final String UTF8_BOM = "\ufeff"; + + static { + final String os = operatingSystemName.toLowerCase(Locale.ROOT); + if (os.contains("linux")) + operatingSystem = LIN; + else if (os.contains("mac")) + operatingSystem = MAC; + else if (os.contains("windows")) + operatingSystem = WIN; + else if (os.contains("bsd")) + operatingSystem = BSD; + else if (os.contains("sunos")) + operatingSystem = SOLARIS; + else if (os.contains("os/400") || os.contains("os400")) + operatingSystem = IBMI; + else if (os.contains("aix")) + operatingSystem = AIX; + else + throw new UnsupportedOperatingSystemException("directories are not supported on " + operatingSystemName); + } + +} diff --git a/src/main/java/dev/dirs/ProjectDirectories.java b/src/main/java/dev/dirs/ProjectDirectories.java index ac54a8a..2bec318 100644 --- a/src/main/java/dev/dirs/ProjectDirectories.java +++ b/src/main/java/dev/dirs/ProjectDirectories.java @@ -1,6 +1,11 @@ package dev.dirs; -import static dev.dirs.Util.*; +import dev.dirs.impl.Linux; +import dev.dirs.impl.MacOs; +import dev.dirs.impl.Util; +import dev.dirs.impl.Windows; + +import java.util.Objects; /** {@code ProjectDirectories} computes the location of cache, config or data directories for a specific application, * which are derived from the standard directories and the name of the project/organization. @@ -28,7 +33,7 @@ private ProjectDirectories( final String preferenceDir, final String runtimeDir) { - requireNonNull(projectPath); + Objects.requireNonNull(projectPath); this.projectPath = projectPath; this.cacheDir = cacheDir; @@ -230,21 +235,21 @@ public static ProjectDirectories fromPath(String path) { String dataLocalDir; String preferenceDir; String runtimeDir = null; - switch (operatingSystem) { - case LIN: - case BSD: - case SOLARIS: - case IBMI: - case AIX: + switch (Constants.operatingSystem) { + case Constants.LIN: + case Constants.BSD: + case Constants.SOLARIS: + case Constants.IBMI: + case Constants.AIX: homeDir = System.getProperty("user.home"); - cacheDir = defaultIfNullOrEmptyExtended(System.getenv("XDG_CACHE_HOME"), path, homeDir + "/.cache/", path); - configDir = defaultIfNullOrEmptyExtended(System.getenv("XDG_CONFIG_HOME"), path, homeDir + "/.config/", path); - dataDir = defaultIfNullOrEmptyExtended(System.getenv("XDG_DATA_HOME"), path, homeDir + "/.local/share/", path); + cacheDir = Util.defaultIfNullOrEmptyExtended(System.getenv("XDG_CACHE_HOME"), path, homeDir + "/.cache/", path); + configDir = Util.defaultIfNullOrEmptyExtended(System.getenv("XDG_CONFIG_HOME"), path, homeDir + "/.config/", path); + dataDir = Util.defaultIfNullOrEmptyExtended(System.getenv("XDG_DATA_HOME"), path, homeDir + "/.local/share/", path); dataLocalDir = dataDir; preferenceDir = configDir; - runtimeDir = linuxRuntimeDir(path); + runtimeDir = Linux.runtimeDir(path); break; - case MAC: + case Constants.MAC: homeDir = System.getProperty("user.home"); cacheDir = homeDir + "/Library/Caches/" + path; configDir = homeDir + "/Library/Application Support/" + path; @@ -252,10 +257,9 @@ public static ProjectDirectories fromPath(String path) { dataLocalDir = dataDir; preferenceDir = homeDir + "/Library/Preferences/" + path; break; - case WIN: - String[] winDirs = getWinDirs("3EB685DB-65F9-4CF6-A03A-E3EF65729F3D", "F1B32785-6FBA-4FCF-9D55-7B8E7F157091"); - String appDataRoaming = winDirs[0] + '\\' + path; - String appDataLocal = winDirs[1] + '\\' + path; + case Constants.WIN: + String appDataRoaming = Windows.getRoamingAppDataDir() + '\\' + path; + String appDataLocal = Windows.getLocalAppDataDir() + '\\' + path; dataDir = appDataRoaming + "\\data"; dataLocalDir = appDataLocal + "\\data"; configDir = appDataRoaming + "\\config"; @@ -263,7 +267,7 @@ public static ProjectDirectories fromPath(String path) { preferenceDir = configDir; break; default: - throw new UnsupportedOperatingSystemException("Project directories are not supported on " + operatingSystemName); + throw new UnsupportedOperatingSystemException("Project directories are not supported on " + Constants.operatingSystemName); } return new ProjectDirectories(path, cacheDir, configDir, dataDir, dataLocalDir, preferenceDir, runtimeDir); } @@ -289,32 +293,32 @@ public static ProjectDirectories fromPath(String path) { * {@code qualifier}, {@code organization} and {@code application} arguments. */ public static ProjectDirectories from(String qualifier, String organization, String application) { - if (isNullOrEmpty(organization) && isNullOrEmpty(application)) + if (Util.isNullOrEmpty(organization) && Util.isNullOrEmpty(application)) throw new UnsupportedOperationException("organization and application arguments cannot both be null/empty"); String path; - switch (operatingSystem) { - case LIN: - case BSD: - case SOLARIS: - case IBMI: - case AIX: - path = trimLowercaseReplaceWhitespace(application, "", true); + switch (Constants.operatingSystem) { + case Constants.LIN: + case Constants.BSD: + case Constants.SOLARIS: + case Constants.IBMI: + case Constants.AIX: + path = Util.trimLowercaseReplaceWhitespace(application, "", true); break; - case MAC: - path = macOSApplicationPath(qualifier, organization, application); + case Constants.MAC: + path = MacOs.applicationPath(qualifier, organization, application); break; - case WIN: - path = windowsApplicationPath(qualifier, organization, application); + case Constants.WIN: + path = Windows.applicationPath(qualifier, organization, application); break; default: - throw new UnsupportedOperatingSystemException("Project directories are not supported on " + operatingSystemName); + throw new UnsupportedOperatingSystemException("Project directories are not supported on " + Constants.operatingSystemName); } return fromPath(path); } @Override public String toString() { - return "ProjectDirectories (" + operatingSystemName + "):\n" + + return "ProjectDirectories (" + Constants.operatingSystemName + "):\n" + " projectPath = '" + projectPath + "'\n" + " cacheDir = '" + cacheDir + "'\n" + " configDir = '" + configDir + "'\n" + diff --git a/src/main/java/dev/dirs/UserDirectories.java b/src/main/java/dev/dirs/UserDirectories.java index fadf6d7..74430cd 100644 --- a/src/main/java/dev/dirs/UserDirectories.java +++ b/src/main/java/dev/dirs/UserDirectories.java @@ -1,6 +1,8 @@ package dev.dirs; -import static dev.dirs.Util.*; +import dev.dirs.impl.Linux; +import dev.dirs.impl.Util; +import dev.dirs.impl.Windows; /** {@code UserDirectories} provides paths of user-facing standard directories, following the conventions of the operating system the library is running on. * @@ -299,24 +301,24 @@ public static UserDirectories get() { } private UserDirectories() { - switch (operatingSystem) { - case LIN: - case BSD: - case SOLARIS: - case AIX: - String[] userDirs = getXDGUserDirs("MUSIC", "DESKTOP", "DOCUMENTS", "DOWNLOAD", "PICTURES", "PUBLICSHARE", "TEMPLATES", "VIDEOS"); + switch (Constants.operatingSystem) { + case Constants.LIN: + case Constants.BSD: + case Constants.SOLARIS: + case Constants.AIX: + String[] userDirs = Linux.getXDGUserDirs("MUSIC", "DESKTOP", "DOCUMENTS", "DOWNLOAD", "PICTURES", "PUBLICSHARE", "TEMPLATES", "VIDEOS"); homeDir = System.getProperty("user.home"); audioDir = userDirs[0]; desktopDir = userDirs[1]; documentDir = userDirs[2]; downloadDir = userDirs[3]; - fontDir = defaultIfNullOrEmptyExtended(System.getenv("XDG_DATA_HOME"), "/fonts", homeDir, "/.local/share/fonts"); + fontDir = Util.defaultIfNullOrEmptyExtended(System.getenv("XDG_DATA_HOME"), "/fonts", homeDir, "/.local/share/fonts"); pictureDir = userDirs[4]; publicDir = userDirs[5]; templateDir = userDirs[6]; videoDir = userDirs[7]; break; - case MAC: + case Constants.MAC: homeDir = System.getProperty("user.home"); audioDir = homeDir + "/Music"; desktopDir = homeDir + "/Desktop"; @@ -328,48 +330,38 @@ private UserDirectories() { templateDir = null; videoDir = homeDir + "/Movies"; break; - case IBMI: + case Constants.IBMI: homeDir = System.getProperty("user.home"); audioDir = homeDir + "/Music"; desktopDir = homeDir + "/Desktop"; documentDir = homeDir + "/Documents"; downloadDir = homeDir + "/Downloads"; - fontDir = defaultIfNullOrEmptyExtended(System.getenv("XDG_DATA_HOME"), "/fonts", homeDir, "/.local/share/fonts"); + fontDir = Util.defaultIfNullOrEmptyExtended(System.getenv("XDG_DATA_HOME"), "/fonts", homeDir, "/.local/share/fonts"); pictureDir = homeDir + "/Pictures"; publicDir = homeDir + "/Public"; templateDir = null; videoDir = homeDir + "/Movies"; break; - case WIN: - String[] winDirs = getWinDirs( - "5E6C858F-0E22-4760-9AFE-EA3317B67173", - "4BD8D571-6D19-48D3-BE97-422220080E43", - "B4BFCC3A-DB2C-424C-B029-7FE99A87C641", - "FDD39AD0-238F-46AF-ADB4-6C85480369C7", - "374DE290-123F-4565-9164-39C4925E467B", - "33E28130-4E1E-4676-835A-98395C3BC3BB", - "DFDF76A2-C82A-4D63-906A-5644AC457385", - "A63293E8-664E-48DB-A079-DF759E0509F7", - "18989B1D-99B5-455B-841C-AB7C74E4DDFC"); - homeDir = winDirs[0]; - audioDir = winDirs[1]; + case Constants.WIN: + homeDir = Windows.getProfileDir(); + audioDir = Windows.getMusicDir(); fontDir = null; - desktopDir = winDirs[2]; - documentDir = winDirs[3]; - downloadDir = winDirs[4]; - pictureDir = winDirs[5]; - publicDir = winDirs[6]; - templateDir = winDirs[7]; - videoDir = winDirs[8]; + desktopDir = Windows.getDesktopDir(); + documentDir = Windows.getDocumentsDir(); + downloadDir = Windows.getDownloadsDir(); + pictureDir = Windows.getPicturesDir(); + publicDir = Windows.getPublicDir(); + templateDir = Windows.getTemplatesDir(); + videoDir = Windows.getVideosDir(); break; default: - throw new UnsupportedOperatingSystemException("User directories are not supported on " + operatingSystemName); + throw new UnsupportedOperatingSystemException("User directories are not supported on " + Constants.operatingSystemName); } } @Override public String toString() { - return "UserDirectories (" + operatingSystemName + "):\n" + + return "UserDirectories (" + Constants.operatingSystemName + "):\n" + " homeDir = '" + homeDir + "'\n" + " audioDir = '" + audioDir + "'\n" + " fontDir = '" + fontDir + "'\n" + diff --git a/src/main/java/dev/dirs/Util.java b/src/main/java/dev/dirs/Util.java deleted file mode 100644 index 2314278..0000000 --- a/src/main/java/dev/dirs/Util.java +++ /dev/null @@ -1,360 +0,0 @@ -package dev.dirs; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.File; -import java.lang.reflect.Method; -import java.nio.charset.Charset; -import java.util.Locale; - -final class Util { - - private Util() { - throw new Error(); - } - - - static final String operatingSystemName = System.getProperty("os.name"); - static final char operatingSystem; - static final char LIN = 'l'; - static final char MAC = 'm'; - static final char WIN = 'w'; - static final char BSD = 'b'; - static final char SOLARIS = 's'; - static final char IBMI = 'i'; - static final char AIX = 'a'; - - static final String UTF8_BOM = "\ufeff"; - - static { - final String os = operatingSystemName.toLowerCase(Locale.ROOT); - if (os.contains("linux")) - operatingSystem = LIN; - else if (os.contains("mac")) - operatingSystem = MAC; - else if (os.contains("windows")) - operatingSystem = WIN; - else if (os.contains("bsd")) - operatingSystem = BSD; - else if (os.contains("sunos")) - operatingSystem = SOLARIS; - else if (os.contains("os/400") || os.contains("os400")) - operatingSystem = IBMI; - else if (os.contains("aix")) - operatingSystem = AIX; - else - throw new UnsupportedOperatingSystemException("directories are not supported on " + operatingSystemName); - } - - private static Object base64Encoder = null; - private static Method base64EncodeMethod = null; - // This string needs to end up being a multiple of 3 bytes after conversion to UTF-16. (It is currently 1200 bytes.) - // This is because Base64 converts 3 bytes to 4 letters; other numbers of bytes would introduce padding, which - // would make it harder to simply concatenate this precomputed string with whatever directories the user requests. - static final String SCRIPT_START_BASE64 = operatingSystem == 'w' ? toUTF16LEBase64("& {\n" + - "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n" + - "Add-Type @\"\n" + - "using System;\n" + - "using System.Runtime.InteropServices;\n" + - "public class Dir {\n" + - " [DllImport(\"shell32.dll\")]\n" + - " private static extern int SHGetKnownFolderPath([MarshalAs(UnmanagedType.LPStruct)] Guid rfid, uint dwFlags, IntPtr hToken, out IntPtr pszPath);\n" + - " public static string GetKnownFolderPath(string rfid) {\n" + - " IntPtr pszPath;\n" + - " if (SHGetKnownFolderPath(new Guid(rfid), 0, IntPtr.Zero, out pszPath) != 0) return \"\";\n" + - " string path = Marshal.PtrToStringUni(pszPath);\n" + - " Marshal.FreeCoTaskMem(pszPath);\n" + - " return path;\n" + - " }\n" + - "}\n" + - "\"@\n") : null; - - static void requireNonNull(Object value) { - if (value == null) - throw new NullPointerException(); - } - - static boolean isNullOrEmpty(String value) { - return value == null || value.isEmpty(); - } - - static String defaultIfNullOrEmpty(String value, String fallbackValue, String fallbackArg) { - requireNonNull(fallbackArg); - if (isNullOrEmpty(value)) - return ensureSingleSlash(fallbackValue, fallbackArg); - else - return value; - } - - static String defaultIfNullOrEmptyExtended(String value, String valueArg, String fallbackValue, String fallbackArg) { - requireNonNull(valueArg); - requireNonNull(fallbackValue); - requireNonNull(fallbackArg); - if (isNullOrEmpty(value)) - return ensureSingleSlash(fallbackValue, fallbackArg); - else - return ensureSingleSlash(value, valueArg); - } - - static String ensureSingleSlash(String arg1, String arg2) { - boolean arg1Slash = arg1.endsWith("/"); - boolean slashArg2 = arg2.startsWith("/"); - if (arg1Slash && slashArg2) { - StringBuilder buf = new StringBuilder(arg1.length() + arg2.length() - 1); - buf.append(arg1, 0, arg1.length() - 1).append(arg2); - return buf.toString(); - } else if (!arg1Slash && !slashArg2) { - return arg1 + '/' + arg2; - } else { - return arg1 + arg2; - } - } - - static String linuxRuntimeDir(String path) { - String runDir = System.getenv("XDG_RUNTIME_DIR"); - if (isNullOrEmpty(runDir)) - return null; - else if (path == null) - return runDir; - else - return runDir + '/' + path; - } - - static String linuxExecutableDir(String homeDir, String dataDir) { - String binDir = System.getenv("XDG_BIN_HOME"); - if (isNullOrEmpty(binDir)) - return defaultIfNullOrEmptyExtended(dataDir, "/../bin/", homeDir, "/.local/bin/"); - else - return binDir; - } - - static String[] getXDGUserDirs(String... dirs) { - int dirsLength = dirs.length; - StringBuilder buf = new StringBuilder(dirsLength * 22); - String[] commands = new String[3]; - commands[0] = "/bin/sh"; - commands[1] = "-c"; - for (int i = 0; i < dirsLength; i++) { - buf.append("xdg-user-dir "); - buf.append(dirs[i]); - buf.append(';'); - } - commands[2] = buf.toString(); - try { - return runCommands(dirsLength, Charset.defaultCharset(), commands); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - static String[] getWinDirs(String... guids) { - int guidsLength = guids.length; - StringBuilder buf = new StringBuilder(guidsLength * 68); - for (int i = 0; i < guidsLength; i++) { - buf.append("[Dir]::GetKnownFolderPath(\""); - buf.append(guids[i]); - buf.append("\")\n"); - } - - String encodedCommand = SCRIPT_START_BASE64 + toUTF16LEBase64(buf + "}"); - String path = System.getenv("Path"); - String[] dirs = path == null ? new String[0] : path.split(File.pathSeparator); - if (dirs.length == 0) { - return windowsFallback(guidsLength, encodedCommand); - } - try { - return runWinCommands(guidsLength, dirs, encodedCommand); - } catch (IOException e) { - return windowsFallback(guidsLength, encodedCommand); - } - } - - private static String toUTF16LEBase64(String script) { - byte[] scriptInUtf16LEBytes = script.getBytes(Charset.forName("UTF-16LE")); - if (base64EncodeMethod == null) { - initBase64Encoding(); - } - try { - return (String) base64EncodeMethod.invoke(base64Encoder, scriptInUtf16LEBytes); - } catch (Exception e) { - throw new RuntimeException("Base64 encoding failed!", e); - } - } - - private static void initBase64Encoding() { - try { - base64Encoder = Class.forName("java.util.Base64").getMethod("getEncoder").invoke(null); - base64EncodeMethod = base64Encoder.getClass().getMethod("encodeToString", byte[].class); - } catch (Exception e1) { - try { - base64EncodeMethod = Class.forName("sun.misc.BASE64Encoder").getMethod("encode", byte[].class); - } catch (Exception e2) { - throw new RuntimeException( - "Could not find any viable Base64 encoder! (java.util.Base64 failed with: " + e1.getMessage() + ")", e2); - } - } - } - - private static String[] runCommands(int expectedResultLines, Charset charset, String... commands) throws IOException { - final Process process = new ProcessBuilder(commands).start(); - - String[] results = new String[expectedResultLines]; - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), charset)); - try { - for (int i = 0; i < expectedResultLines; i++) { - String line = reader.readLine(); - if (i == 0 && operatingSystem == 'w' && line != null && line.startsWith(UTF8_BOM)) - line = line.substring(UTF8_BOM.length()); - results[i] = line; - } - return results; - } finally { - process.destroy(); - try { - reader.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } - } - - private static String[] runWinCommands(int guidsLength, String[] dirs, String encodedCommand) throws IOException { - // legacy powershell.exe seems to run faster than pwsh.exe so prefer it if available - String[] commands = { "powershell.exe", "pwsh.exe" }; - IOException firstException = null; - for (String dir : dirs) { - for (String command : commands) { - File commandFile = new File(dir, command); - if (commandFile.exists()) { - try { - String[] stdout; - // try to run using powershell V2 to bypass constrained language mode - // note that this has been deprecated in new version of Windows - // https://devblogs.microsoft.com/powershell/windows-powershell-2-0-deprecation/ - // for some set up, running this requires installation of extra dependency on Windows host - stdout = runCommands(guidsLength, Charset.forName("UTF-8"), - commandFile.toString(), - "-version", - "2", - "-NoProfile", - "-EncodedCommand", - encodedCommand - ); - if (stdout[0] != null) return stdout; - - // fall-forward to higher version of powershell - stdout = runCommands(guidsLength, Charset.forName("UTF-8"), - commandFile.toString(), - "-NoProfile", - "-EncodedCommand", - encodedCommand - ); - return stdout; - } catch (IOException e) { - firstException = firstException == null ? e : firstException; - } - } - } - } - if (firstException != null) { - throw firstException; - } - else throw new IOException("no directories"); - } - - private static String[] windowsFallback(int guidsLength, String encodedCommand) { - File powerShellBase = new File("C:\\Program Files\\Powershell"); - String[] powerShellDirs = powerShellBase.list(); - if (powerShellDirs == null) { - powerShellDirs = new String[0]; - } - String[] allPowerShellDirs = new String[powerShellDirs.length + 1]; - - // legacy powershell.exe seems to run faster than pwsh.exe so prefer it if available - String systemRoot = System.getenv("SystemRoot"); - if (systemRoot == null) { - systemRoot = "C:\\Windows"; - } - allPowerShellDirs[0] = systemRoot + "\\System32\\WindowsPowerShell\\v1.0\\"; - - for (int i = 0; i < powerShellDirs.length; ++i) { - allPowerShellDirs[i + 1] = new File(powerShellBase, powerShellDirs[i]).toString(); - } - try { - return runWinCommands(guidsLength, allPowerShellDirs, encodedCommand); - } catch (final IOException ex) { - throw new RuntimeException("Couldn't find pwsh.exe or powershell.exe on path or in default system locations", ex); - } - } - - static String macOSApplicationPath(String qualifier, String organization, String application) { - StringBuilder buf = new StringBuilder(Math.max(stringLength(qualifier) + stringLength(organization) + stringLength(application), 0)); - boolean qualPresent = !isNullOrEmpty(qualifier); - boolean orgPresent = !isNullOrEmpty(organization); - boolean appPresent = !isNullOrEmpty(application); - if (qualPresent) { - buf.append(trimLowercaseReplaceWhitespace(qualifier, "-", false)); - if (orgPresent || appPresent) - buf.append('.'); - } - if (orgPresent) { - buf.append(trimLowercaseReplaceWhitespace(organization, "-", false)); - if (appPresent) - buf.append('.'); - } - if (appPresent) - buf.append(trimLowercaseReplaceWhitespace(application, "-", false)); - return buf.toString(); - } - - static String windowsApplicationPath(String qualifier, String organization, String application) { - StringBuilder buf = new StringBuilder(Math.max(stringLength(organization) + stringLength(application), 0)); - boolean orgPresent = !isNullOrEmpty(organization); - boolean appPresent = !isNullOrEmpty(application); - if (orgPresent) { - buf.append(organization); - if (appPresent) - buf.append('\\'); - } - if (appPresent) - buf.append(application); - return buf.toString(); - } - - static String stripQualification(String value) { - int startingPosition = value.lastIndexOf('.') + 1; - return value.substring(startingPosition); - } - - static int stringLength(String value) { - if (value == null) - return -1; - else - return value.length(); - } - - static String trimLowercaseReplaceWhitespace(String value, String replacement, boolean lowerCase) { - StringBuilder buf = new StringBuilder(value.length()); - boolean charsBefore = false; - int codePointCount = value.codePointCount(0, value.length()); - boolean replace = !replacement.isEmpty(); - for (int index = 0; index < codePointCount; index += 1) { - int codepoint = value.codePointAt(index); - if (codepoint == ' ') { - if (charsBefore && replace && codePointExistsAndNotSpace(value, codePointCount, index + 1)) { - buf.append('-'); - charsBefore = false; - } - } else { - buf.appendCodePoint(lowerCase ? Character.toLowerCase(codepoint) : codepoint); - charsBefore = true; - } - } - return buf.toString(); - } - - private static boolean codePointExistsAndNotSpace(String value, int count, int index) { - return index < count && value.codePointAt(index) != ' '; - } -} diff --git a/src/main/java/dev/dirs/impl/Linux.java b/src/main/java/dev/dirs/impl/Linux.java new file mode 100644 index 0000000..61bd5de --- /dev/null +++ b/src/main/java/dev/dirs/impl/Linux.java @@ -0,0 +1,93 @@ +package dev.dirs.impl; + +import dev.dirs.Constants; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; + +import static dev.dirs.Constants.UTF8_BOM; + +public final class Linux { + + private Linux() { + throw new Error(); + } + + + static String ensureSingleSlash(String arg1, String arg2) { + boolean arg1Slash = arg1.endsWith("/"); + boolean slashArg2 = arg2.startsWith("/"); + if (arg1Slash && slashArg2) { + StringBuilder buf = new StringBuilder(arg1.length() + arg2.length() - 1); + buf.append(arg1, 0, arg1.length() - 1).append(arg2); + return buf.toString(); + } else if (!arg1Slash && !slashArg2) { + return arg1 + '/' + arg2; + } else { + return arg1 + arg2; + } + } + + public static String runtimeDir(String path) { + String runDir = System.getenv("XDG_RUNTIME_DIR"); + if (Util.isNullOrEmpty(runDir)) + return null; + else if (path == null) + return runDir; + else + return runDir + '/' + path; + } + + public static String executableDir(String homeDir, String dataDir) { + String binDir = System.getenv("XDG_BIN_HOME"); + if (Util.isNullOrEmpty(binDir)) + return Util.defaultIfNullOrEmptyExtended(dataDir, "/../bin/", homeDir, "/.local/bin/"); + else + return binDir; + } + + public static String[] getXDGUserDirs(String... dirs) { + int dirsLength = dirs.length; + StringBuilder buf = new StringBuilder(dirsLength * 22); + String[] commands = new String[3]; + commands[0] = "/bin/sh"; + commands[1] = "-c"; + for (int i = 0; i < dirsLength; i++) { + buf.append("xdg-user-dir "); + buf.append(dirs[i]); + buf.append(';'); + } + commands[2] = buf.toString(); + try { + return runCommands(dirsLength, Charset.defaultCharset(), commands); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static String[] runCommands(int expectedResultLines, Charset charset, String... commands) throws IOException { + final Process process = new ProcessBuilder(commands).start(); + + String[] results = new String[expectedResultLines]; + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), charset)); + try { + for (int i = 0; i < expectedResultLines; i++) { + String line = reader.readLine(); + if (i == 0 && Constants.operatingSystem == 'w' && line != null && line.startsWith(UTF8_BOM)) + line = line.substring(UTF8_BOM.length()); + results[i] = line; + } + return results; + } finally { + process.destroy(); + try { + reader.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + +} diff --git a/src/main/java/dev/dirs/impl/MacOs.java b/src/main/java/dev/dirs/impl/MacOs.java new file mode 100644 index 0000000..85a775f --- /dev/null +++ b/src/main/java/dev/dirs/impl/MacOs.java @@ -0,0 +1,26 @@ +package dev.dirs.impl; + +public final class MacOs { + + private MacOs() {} + + public static String applicationPath(String qualifier, String organization, String application) { + StringBuilder buf = new StringBuilder(Math.max(Util.stringLength(qualifier) + Util.stringLength(organization) + Util.stringLength(application), 0)); + boolean qualPresent = !Util.isNullOrEmpty(qualifier); + boolean orgPresent = !Util.isNullOrEmpty(organization); + boolean appPresent = !Util.isNullOrEmpty(application); + if (qualPresent) { + buf.append(Util.trimLowercaseReplaceWhitespace(qualifier, "-", false)); + if (orgPresent || appPresent) + buf.append('.'); + } + if (orgPresent) { + buf.append(Util.trimLowercaseReplaceWhitespace(organization, "-", false)); + if (appPresent) + buf.append('.'); + } + if (appPresent) + buf.append(Util.trimLowercaseReplaceWhitespace(application, "-", false)); + return buf.toString(); + } +} diff --git a/src/main/java/dev/dirs/impl/Util.java b/src/main/java/dev/dirs/impl/Util.java new file mode 100644 index 0000000..9dc946a --- /dev/null +++ b/src/main/java/dev/dirs/impl/Util.java @@ -0,0 +1,70 @@ +package dev.dirs.impl; + +import dev.dirs.UnsupportedOperatingSystemException; + +import java.util.Locale; + +import static java.util.Objects.requireNonNull; + +public final class Util { + + private Util() {} + + public static boolean isNullOrEmpty(String value) { + return value == null || value.isEmpty(); + } + + public static String defaultIfNullOrEmpty(String value, String fallbackValue, String fallbackArg) { + requireNonNull(fallbackArg); + if (isNullOrEmpty(value)) + return Linux.ensureSingleSlash(fallbackValue, fallbackArg); + else + return value; + } + + public static String defaultIfNullOrEmptyExtended(String value, String valueArg, String fallbackValue, String fallbackArg) { + requireNonNull(valueArg); + requireNonNull(fallbackValue); + requireNonNull(fallbackArg); + if (isNullOrEmpty(value)) + return Linux.ensureSingleSlash(fallbackValue, fallbackArg); + else + return Linux.ensureSingleSlash(value, valueArg); + } + + public static String stripQualification(String value) { + int startingPosition = value.lastIndexOf('.') + 1; + return value.substring(startingPosition); + } + + static int stringLength(String value) { + if (value == null) + return -1; + else + return value.length(); + } + + public static String trimLowercaseReplaceWhitespace(String value, String replacement, boolean lowerCase) { + StringBuilder buf = new StringBuilder(value.length()); + boolean charsBefore = false; + int codePointCount = value.codePointCount(0, value.length()); + boolean replace = !replacement.isEmpty(); + for (int index = 0; index < codePointCount; index += 1) { + int codepoint = value.codePointAt(index); + if (codepoint == ' ') { + if (charsBefore && replace && codePointExistsAndNotSpace(value, codePointCount, index + 1)) { + buf.append('-'); + charsBefore = false; + } + } else { + buf.appendCodePoint(lowerCase ? Character.toLowerCase(codepoint) : codepoint); + charsBefore = true; + } + } + return buf.toString(); + } + + private static boolean codePointExistsAndNotSpace(String value, int count, int index) { + return index < count && value.codePointAt(index) != ' '; + } +} diff --git a/src/main/java/dev/dirs/impl/Windows.java b/src/main/java/dev/dirs/impl/Windows.java new file mode 100644 index 0000000..8a7a4ef --- /dev/null +++ b/src/main/java/dev/dirs/impl/Windows.java @@ -0,0 +1,205 @@ +package dev.dirs.impl; + +import dev.dirs.Constants; + +import java.lang.foreign.AddressLayout; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.GroupLayout; +import java.lang.foreign.Linker; +import java.lang.foreign.MemoryLayout; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.foreign.ValueLayout; +import java.lang.invoke.MethodHandle; + +import static dev.dirs.impl.Util.isNullOrEmpty; +import static dev.dirs.impl.Util.stringLength; +import static java.lang.foreign.ValueLayout.JAVA_BYTE; +import static java.lang.foreign.ValueLayout.JAVA_CHAR; + +public final class Windows { + + private Windows() {} + + static { + if (Constants.operatingSystem == 'w') { + System.loadLibrary("ole32"); + System.loadLibrary("shell32"); + } + } + + private static final SymbolLookup SYMBOL_LOOKUP = SymbolLookup.loaderLookup().or(Linker.nativeLinker().defaultLookup()); + private static final ValueLayout.OfByte C_CHAR = ValueLayout.JAVA_BYTE; + private static final ValueLayout.OfShort C_SHORT = ValueLayout.JAVA_SHORT; + private static final AddressLayout C_POINTER = ValueLayout.ADDRESS + .withTargetLayout(MemoryLayout.sequenceLayout(java.lang.Long.MAX_VALUE, JAVA_BYTE)); + private static final ValueLayout.OfInt C_LONG = ValueLayout.JAVA_INT; + + public static String getProfileDir() { + return getDir("{5E6C858F-0E22-4760-9AFE-EA3317B67173}"); + } + + public static String getMusicDir() { + return Windows.getDir("{4BD8D571-6D19-48D3-BE97-422220080E43}"); + } + + public static String getDesktopDir() { + return Windows.getDir("{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}"); + } + + public static String getDocumentsDir() { + return Windows.getDir("{FDD39AD0-238F-46AF-ADB4-6C85480369C7}"); + } + + public static String getDownloadsDir() { + return Windows.getDir("{374DE290-123F-4565-9164-39C4925E467B}"); + } + + public static String getPicturesDir() { + return Windows.getDir("{33E28130-4E1E-4676-835A-98395C3BC3BB}"); + } + + public static String getPublicDir() { + return Windows.getDir("{DFDF76A2-C82A-4D63-906A-5644AC457385}"); + } + + public static String getTemplatesDir() { + return Windows.getDir("{A63293E8-664E-48DB-A079-DF759E0509F7}"); + } + + public static String getVideosDir() { + return Windows.getDir("{18989B1D-99B5-455B-841C-AB7C74E4DDFC}"); + } + + public static String getRoamingAppDataDir() { + return getDir("{3EB685DB-65F9-4CF6-A03A-E3EF65729F3D}"); + } + + public static String getLocalAppDataDir() { + return getDir("{F1B32785-6FBA-4FCF-9D55-7B8E7F157091}"); + } + + public static String getDir(String folderId) { + try (var arena = Arena.ofConfined()) { + MemorySegment guidSegment = arena.allocate(GUID_LAYOUT); + if (CLSIDFromString(createSegmentFromString(folderId, arena), guidSegment) != 0) { + System.exit(-1); + } + MemorySegment path = arena.allocate(C_POINTER); + SHGetKnownFolderPath(guidSegment, 0, MemorySegment.NULL, path); + return createStringFromSegment(path.get(C_POINTER, 0)); + } + } + + public static String applicationPath(String qualifier, String organization, String application) { + StringBuilder buf = new StringBuilder(Math.max(stringLength(organization) + stringLength(application), 0)); + boolean orgPresent = !isNullOrEmpty(organization); + boolean appPresent = !isNullOrEmpty(application); + if (orgPresent) { + buf.append(organization); + if (appPresent) + buf.append('\\'); + } + if (appPresent) + buf.append(application); + return buf.toString(); + } + + public static void main(String[] args) { + + + + } + + /** + * Creates a memory segment as a copy of a Java string. + *

+ * The memory segment contains a copy of the string (null-terminated, UTF-16/wide characters). + *

+ * + * @param str the string to copy + * @param arena the arena for the memory segment + * @return the resulting memory segment + */ + public static MemorySegment createSegmentFromString(String str, Arena arena) { + // allocate segment (including space for terminating null) + var segment = arena.allocate(JAVA_CHAR, str.length() + 1L); + // copy characters + segment.copyFrom(MemorySegment.ofArray(str.toCharArray())); + return segment; + } + + /** + * Creates a copy of the string in the memory segment. + *

+ * The string must be a null-terminated UTF-16 (wide character) string. + *

+ * + * @param segment the memory segment + * @return copied string + */ + public static String createStringFromSegment(MemorySegment segment) { + var len = 0; + while (segment.get(JAVA_CHAR, len) != 0) { + len += 2; + } + + return new String(segment.asSlice(0, len).toArray(JAVA_CHAR)); + } + + static MemorySegment findOrThrow(String symbol) { + return SYMBOL_LOOKUP.find(symbol) + .orElseThrow(() -> new UnsatisfiedLinkError("unresolved symbol: " + symbol)); + } + + private static final GroupLayout GUID_LAYOUT = MemoryLayout.structLayout( + C_LONG.withName("Data1"), + C_SHORT.withName("Data2"), + C_SHORT.withName("Data3"), + MemoryLayout.sequenceLayout(8, C_CHAR).withName("Data4")) + .withName("_GUID"); + + /** + * {@snippet lang=c : + * extern HRESULT CLSIDFromString(LPCOLESTR lpsz, LPCLSID pclsid) + * } + */ + public static int CLSIDFromString(MemorySegment lpsz, MemorySegment pclsid) { + var handle = CLSIDFromString.HANDLE; + try { + return (int) handle.invokeExact(lpsz, pclsid); + } catch (Throwable ex$) { + throw new AssertionError("should not reach here", ex$); + } + } + + private static class CLSIDFromString { + public static final FunctionDescriptor DESC = FunctionDescriptor.of(C_LONG, C_POINTER, C_POINTER); + + public static final MethodHandle HANDLE = Linker.nativeLinker() + .downcallHandle(findOrThrow("CLSIDFromString"), DESC); + } + + /** + * {@snippet lang=c : + * extern HRESULT SHGetKnownFolderPath(const KNOWNFOLDERID *const rfid, DWORD dwFlags, HANDLE hToken, PWSTR *ppszPath) + * } + */ + public static int SHGetKnownFolderPath(MemorySegment rfid, int dwFlags, MemorySegment hToken, MemorySegment ppszPath) { + var handle = SHGetKnownFolderPath.HANDLE; + try { + return (int) handle.invokeExact(rfid, dwFlags, hToken, ppszPath); + } catch (Throwable ex$) { + throw new AssertionError("should not reach here", ex$); + } + } + + private static class SHGetKnownFolderPath { + public static final FunctionDescriptor DESC = FunctionDescriptor.of(C_LONG, C_POINTER, C_LONG, C_POINTER, C_POINTER); + + public static final MethodHandle HANDLE = Linker.nativeLinker() + .downcallHandle(findOrThrow("SHGetKnownFolderPath"), DESC); + } + +} \ No newline at end of file diff --git a/src/test/java/dev/dirs/UtilTest.java b/src/test/java/dev/dirs/UtilTest.java index c3548ef..5948b5e 100644 --- a/src/test/java/dev/dirs/UtilTest.java +++ b/src/test/java/dev/dirs/UtilTest.java @@ -1,9 +1,10 @@ package dev.dirs; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; +import dev.dirs.impl.MacOs; +import dev.dirs.impl.Util; +import dev.dirs.impl.Windows; import org.junit.Test; public final class UtilTest { @@ -61,7 +62,7 @@ public void testMacOSApplicationPath01() { final String inputQual = ""; final String inputOrga = "Foo Bar"; final String inputProj = ""; - final String actual = Util.macOSApplicationPath(inputQual, inputOrga, inputProj); + final String actual = MacOs.applicationPath(inputQual, inputOrga, inputProj); assertEquals("Foo-Bar", actual); } @@ -70,7 +71,7 @@ public void testMacOSApplicationPath02() { final String inputQual = ""; final String inputOrga = ""; final String inputProj = "Baz Qux"; - final String actual = Util.macOSApplicationPath(inputQual, inputOrga, inputProj); + final String actual = MacOs.applicationPath(inputQual, inputOrga, inputProj); assertEquals("Baz-Qux", actual); } @@ -79,7 +80,7 @@ public void testMacOSApplicationPath03() { final String inputQual = ""; final String inputOrga = "Foo Bar"; final String inputProj = "Baz Qux"; - final String actual = Util.macOSApplicationPath(inputQual, inputOrga, inputProj); + final String actual = MacOs.applicationPath(inputQual, inputOrga, inputProj); assertEquals("Foo-Bar.Baz-Qux", actual); } @@ -88,7 +89,7 @@ public void testMacOSApplicationPath04() { final String inputQual = "uk.co"; final String inputOrga = "Foo Bar"; final String inputProj = ""; - final String actual = Util.macOSApplicationPath(inputQual, inputOrga, inputProj); + final String actual = MacOs.applicationPath(inputQual, inputOrga, inputProj); assertEquals("uk.co.Foo-Bar", actual); } @@ -97,7 +98,7 @@ public void testMacOSApplicationPath05() { final String inputQual = "uk.co"; final String inputOrga = ""; final String inputProj = "Baz Qux"; - final String actual = Util.macOSApplicationPath(inputQual, inputOrga, inputProj); + final String actual = MacOs.applicationPath(inputQual, inputOrga, inputProj); assertEquals("uk.co.Baz-Qux", actual); } @@ -106,7 +107,7 @@ public void testMacOSApplicationPath06() { final String inputQual = "uk.co"; final String inputOrga = "Foo Bar"; final String inputProj = "Baz Qux"; - final String actual = Util.macOSApplicationPath(inputQual, inputOrga, inputProj); + final String actual = MacOs.applicationPath(inputQual, inputOrga, inputProj); assertEquals("uk.co.Foo-Bar.Baz-Qux", actual); } @@ -115,7 +116,7 @@ public void testMacOSApplicationPath07() { final String inputQual = " uk.co "; final String inputOrga = " Foo Bar "; final String inputProj = " Baz Qux "; - final String actual = Util.macOSApplicationPath(inputQual, inputOrga, inputProj); + final String actual = MacOs.applicationPath(inputQual, inputOrga, inputProj); assertEquals("uk.co.Foo-Bar.Baz-Qux", actual); } @@ -124,7 +125,7 @@ public void testWindowsApplicationPath01() { final String inputQual = ""; final String inputOrga = "Foo Bar"; final String inputProj = ""; - final String actual = Util.windowsApplicationPath(inputQual, inputOrga, inputProj); + final String actual = Windows.applicationPath(inputQual, inputOrga, inputProj); assertEquals("Foo Bar", actual); } @@ -133,7 +134,7 @@ public void testWindowsApplicationPath02() { final String inputQual = ""; final String inputOrga = ""; final String inputProj = "Baz Qux"; - final String actual = Util.windowsApplicationPath(inputQual, inputOrga, inputProj); + final String actual = Windows.applicationPath(inputQual, inputOrga, inputProj); assertEquals("Baz Qux", actual); } @@ -142,7 +143,7 @@ public void testWindowsApplicationPath03() { final String inputQual = ""; final String inputOrga = "Foo Bar"; final String inputProj = "Baz Qux"; - final String actual = Util.windowsApplicationPath(inputQual, inputOrga, inputProj); + final String actual = Windows.applicationPath(inputQual, inputOrga, inputProj); assertEquals("Foo Bar\\Baz Qux", actual); } @@ -151,7 +152,7 @@ public void testWindowsApplicationPath04() { final String inputQual = "uk.co"; final String inputOrga = "Foo Bar"; final String inputProj = ""; - final String actual = Util.windowsApplicationPath(inputQual, inputOrga, inputProj); + final String actual = Windows.applicationPath(inputQual, inputOrga, inputProj); assertEquals("Foo Bar", actual); } @@ -160,7 +161,7 @@ public void testWindowsApplicationPath05() { final String inputQual = "uk.co"; final String inputOrga = ""; final String inputProj = "Baz Qux"; - final String actual = Util.windowsApplicationPath(inputQual, inputOrga, inputProj); + final String actual = Windows.applicationPath(inputQual, inputOrga, inputProj); assertEquals("Baz Qux", actual); } @@ -169,25 +170,8 @@ public void testWindowsApplicationPath06() { final String inputQual = "uk.co"; final String inputOrga = "Foo Bar"; final String inputProj = "Baz Qux"; - final String actual = Util.windowsApplicationPath(inputQual, inputOrga, inputProj); + final String actual = Windows.applicationPath(inputQual, inputOrga, inputProj); assertEquals("Foo Bar\\Baz Qux", actual); } - @Test - public void testPowershellBase64StringIsNotPadded() { - if (Util.operatingSystem == 'w') { - assertFalse(Util.SCRIPT_START_BASE64.endsWith("=")); - } - } - - @Test - public void testPowershell() { - if (Util.operatingSystem == 'w') { - String[] winDirs = Util.getWinDirs("3EB685DB-65F9-4CF6-A03A-E3EF65729F3D", "F1B32785-6FBA-4FCF-9D55-7B8E7F157091"); - for (String winDir : winDirs) { - assertNotNull(winDir); - } - } - } - }