diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index afe220d..b327f97 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,10 +18,10 @@ jobs: 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 aa43ec5..2322c15 100644 --- a/src/main/java/dev/dirs/BaseDirectories.java +++ b/src/main/java/dev/dirs/BaseDirectories.java @@ -275,10 +275,9 @@ private BaseDirectories() { runtimeDir = null; break; case Constants.WIN: - String[] winDirs = Windows.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]; + homeDir = Windows.getProfileDir(); + dataDir = Windows.getRoamingAppDataDir(); + dataLocalDir = Windows.getLocalAppDataDir(); configDir = dataDir; cacheDir = dataLocalDir; executableDir = null; diff --git a/src/main/java/dev/dirs/ProjectDirectories.java b/src/main/java/dev/dirs/ProjectDirectories.java index 670af08..2bec318 100644 --- a/src/main/java/dev/dirs/ProjectDirectories.java +++ b/src/main/java/dev/dirs/ProjectDirectories.java @@ -258,9 +258,8 @@ public static ProjectDirectories fromPath(String path) { preferenceDir = homeDir + "/Library/Preferences/" + path; break; case Constants.WIN: - String[] winDirs = Windows.getWinDirs("3EB685DB-65F9-4CF6-A03A-E3EF65729F3D", "F1B32785-6FBA-4FCF-9D55-7B8E7F157091"); - String appDataRoaming = winDirs[0] + '\\' + path; - String appDataLocal = winDirs[1] + '\\' + path; + String appDataRoaming = Windows.getRoamingAppDataDir() + '\\' + path; + String appDataLocal = Windows.getLocalAppDataDir() + '\\' + path; dataDir = appDataRoaming + "\\data"; dataLocalDir = appDataLocal + "\\data"; configDir = appDataRoaming + "\\config"; diff --git a/src/main/java/dev/dirs/UserDirectories.java b/src/main/java/dev/dirs/UserDirectories.java index f8f9302..74430cd 100644 --- a/src/main/java/dev/dirs/UserDirectories.java +++ b/src/main/java/dev/dirs/UserDirectories.java @@ -343,26 +343,16 @@ private UserDirectories() { videoDir = homeDir + "/Movies"; break; case Constants.WIN: - String[] winDirs = Windows.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]; + 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 " + Constants.operatingSystemName); diff --git a/src/main/java/dev/dirs/impl/Windows.java b/src/main/java/dev/dirs/impl/Windows.java index 575cef4..afd936f 100644 --- a/src/main/java/dev/dirs/impl/Windows.java +++ b/src/main/java/dev/dirs/impl/Windows.java @@ -2,192 +2,198 @@ import dev.dirs.Constants; -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.InputStreamReader; -import java.lang.reflect.Method; -import java.nio.charset.Charset; - -public class Windows { - - private Windows() {} - - private static final String UTF8_BOM = "\ufeff"; - 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 = Constants.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; - - public 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 && 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(); - } - } - } - - 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); - } - } - - public static String applicationPath(String qualifier, String organization, String application) { - StringBuilder buf = new StringBuilder(Math.max(Util.stringLength(organization) + Util.stringLength(application), 0)); - boolean orgPresent = !Util.isNullOrEmpty(organization); - boolean appPresent = !Util.isNullOrEmpty(application); - if (orgPresent) { - buf.append(organization); - if (appPresent) - buf.append('\\'); - } - if (appPresent) - buf.append(application); - return buf.toString(); - } +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 getDir("{4BD8D571-6D19-48D3-BE97-422220080E43}"); + } + + public static String getDesktopDir() { + return getDir("{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}"); + } + + public static String getDocumentsDir() { + return getDir("{FDD39AD0-238F-46AF-ADB4-6C85480369C7}"); + } + + public static String getDownloadsDir() { + return getDir("{374DE290-123F-4565-9164-39C4925E467B}"); + } + + public static String getPicturesDir() { + return getDir("{33E28130-4E1E-4676-835A-98395C3BC3BB}"); + } + + public static String getPublicDir() { + return getDir("{DFDF76A2-C82A-4D63-906A-5644AC457385}"); + } + + public static String getTemplatesDir() { + return getDir("{A63293E8-664E-48DB-A079-DF759E0509F7}"); + } + + public static String getVideosDir() { + return 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 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(); + } + + private static String getDir(String folderId) { + try (var arena = Arena.ofConfined()) { + MemorySegment guidSegment = arena.allocate(GUID_LAYOUT); + if (CLSIDFromString(createSegmentFromString(folderId, arena), guidSegment) != 0) { + throw new AssertionError("failed converting string " + folderId + " to KnownFolderId"); + } + MemorySegment path = arena.allocate(C_POINTER); + SHGetKnownFolderPath(guidSegment, 0, MemorySegment.NULL, path); + return createStringFromSegment(path.get(C_POINTER, 0)); + } + } + + /** + * 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 + */ + private 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 + */ + private 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)); + } + + private 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) + * } + */ + private static int CLSIDFromString(MemorySegment lpsz, MemorySegment pclsid) { + var handle = CLSIDFromString.HANDLE; + try { + return (int) handle.invokeExact(lpsz, pclsid); + } catch (Throwable throwable) { + throw new AssertionError("failed to invoke `CLSIDFromString`", throwable); + } + } + + 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) + * } + */ + private 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 throwable) { + throw new AssertionError("failed to invoke `SHGetKnownFolderPath`", throwable); + } + } + + 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); + } + } diff --git a/src/test/java/dev/dirs/impl/UtilTest.java b/src/test/java/dev/dirs/impl/UtilTest.java index 2fa9311..e4988df 100644 --- a/src/test/java/dev/dirs/impl/UtilTest.java +++ b/src/test/java/dev/dirs/impl/UtilTest.java @@ -174,21 +174,4 @@ public void testWindowsApplicationPath06() { assertEquals("Foo Bar\\Baz Qux", actual); } - @Test - public void testPowershellBase64StringIsNotPadded() { - if (Constants.operatingSystem == 'w') { - assertFalse(Windows.SCRIPT_START_BASE64.endsWith("=")); - } - } - - @Test - public void testPowershell() { - if (Constants.operatingSystem == 'w') { - String[] winDirs = Windows.getWinDirs("3EB685DB-65F9-4CF6-A03A-E3EF65729F3D", "F1B32785-6FBA-4FCF-9D55-7B8E7F157091"); - for (String winDir : winDirs) { - assertNotNull(winDir); - } - } - } - }