using System.Text.Json; namespace MaksIT.PostClient.Shared.App; /// /// Windows: %LocalAppData%\MaksIT\Postclient for config and data. /// Linux: XDG under a MaksIT parent. macOS: Application Support under MaksIT. /// public static class AppPaths { public const string ProductName = "Postclient"; public const string ProductId = "postclient"; public const string BrandFolder = "MaksIT"; public const string ConfigEnv = "POSTCLIENT_CONFIG"; public const string DataEnv = "POSTCLIENT_DATA_DIR"; public static string ConfigDirectory() { var fromEnv = Environment.GetEnvironmentVariable(ConfigEnv); if (!string.IsNullOrWhiteSpace(fromEnv)) return Path.GetFullPath(fromEnv.Trim()); var portable = PortableRoot(); if (portable is not null) return portable; if (OperatingSystem.IsWindows()) return WindowsProductDirectory(); if (OperatingSystem.IsMacOS()) { return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", BrandFolder, ProductName); } var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); if (string.IsNullOrWhiteSpace(xdg)) xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); return Path.Combine(xdg, BrandFolder, ProductId); } public static string DataDirectory() { var fromEnv = Environment.GetEnvironmentVariable(DataEnv); if (!string.IsNullOrWhiteSpace(fromEnv)) return Path.GetFullPath(fromEnv.Trim()); var portable = PortableRoot(); if (portable is not null) return Path.Combine(portable, "data"); if (OperatingSystem.IsWindows()) return WindowsProductDirectory(); if (OperatingSystem.IsMacOS()) return ConfigDirectory(); var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); if (string.IsNullOrWhiteSpace(xdg)) xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share"); return Path.Combine(xdg, BrandFolder, ProductId); } public static string SettingsFile() => Path.Combine(ConfigDirectory(), "settings.json"); public static string SecretsFile() => Path.Combine(ConfigDirectory(), "secrets.bin"); public static string ReceiptsFile() => Path.Combine(ConfigDirectory(), "receipts.json"); public static string ObjectsDirectory() => Path.Combine(DataDirectory(), "objects"); public static string StoresDirectory() => Path.Combine(DataDirectory(), "stores"); public static string AccountsDirectory() => Path.Combine(DataDirectory(), "accounts"); public static string ArchiveDatabase() => Path.Combine(DataDirectory(), "mail.db"); public static string AccountDatabase(string mailboxId) => Path.Combine(AccountsDirectory(), mailboxId, "mail.db"); public static string WorkerPipeName() => ProductId + "-worker-" + Environment.ProcessId; public static string ProposedStoreDirectory(string? sourcePath) { var stem = string.IsNullOrWhiteSpace(sourcePath) ? "Mail" : Path.GetFileNameWithoutExtension(sourcePath.Trim()); if (string.IsNullOrWhiteSpace(stem)) stem = "Mail"; foreach (var c in Path.GetInvalidFileNameChars()) stem = stem.Replace(c, '_'); return UniqueStoreDirectory(stem); } public static string UniqueStoreDirectory(string stem) { Directory.CreateDirectory(StoresDirectory()); var root = Path.Combine(StoresDirectory(), stem); if (!Directory.Exists(root) && !File.Exists(root)) return root; if (Directory.Exists(root) && LocalStoreSidecar.TryRead(root) is not null) return root; for (var i = 2; i < 10_000; i++) { var next = Path.Combine(StoresDirectory(), stem + "-" + i); if (!Directory.Exists(next) && !File.Exists(next)) return next; if (Directory.Exists(next) && LocalStoreSidecar.TryRead(next) is not null) return next; } return Path.Combine(StoresDirectory(), stem + "-" + Guid.NewGuid().ToString("N")[..8]); } public static string ModelsDirectory() => Path.Combine(DataDirectory(), "models"); public static string WebViewDirectory() => Path.Combine(DataDirectory(), "webview"); public static bool IsWebViewFile(Uri? uri) { if (uri is null || !uri.IsFile) return false; try { var full = Path.GetFullPath(uri.LocalPath); var root = Path.GetFullPath(WebViewDirectory()); var prefix = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; return full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); } catch { return false; } } public static void ClearStaleReadingDocuments() { try { var dir = WebViewDirectory(); if (!Directory.Exists(dir)) return; foreach (var file in Directory.GetFiles(dir, "reading-*.html")) { try { File.Delete(file); } catch { } } } catch { } } public static string LogsDirectory() { if (OperatingSystem.IsMacOS()) { return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Logs", BrandFolder, ProductName); } return Path.Combine(DataDirectory(), "logs"); } public static void EnsureDirectories() { MigrateLegacyLayout(); Directory.CreateDirectory(ConfigDirectory()); Directory.CreateDirectory(DataDirectory()); Directory.CreateDirectory(ObjectsDirectory()); Directory.CreateDirectory(StoresDirectory()); Directory.CreateDirectory(AccountsDirectory()); Directory.CreateDirectory(ModelsDirectory()); Directory.CreateDirectory(WebViewDirectory()); Directory.CreateDirectory(LogsDirectory()); Directory.CreateDirectory(Path.GetDirectoryName(ArchiveDatabase())!); } internal static string? PortableRoot() { var marker = Path.Combine(AppContext.BaseDirectory, "settings.json"); if (!File.Exists(marker)) return null; try { using var document = System.Text.Json.JsonDocument.Parse(File.ReadAllText(marker)); if (!document.RootElement.TryGetProperty("Configuration", out var configuration)) return null; if (!configuration.TryGetProperty("installType", out var type)) return null; if (!string.Equals(type.GetString(), "portable", StringComparison.OrdinalIgnoreCase)) return null; return AppContext.BaseDirectory; } catch { return null; } } private static string WindowsProductDirectory() => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), BrandFolder, ProductName); private static bool UsesDefaultUserLayout() { if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(ConfigEnv))) return false; if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(DataEnv))) return false; return PortableRoot() is null; } private static void MigrateLegacyLayout() { if (!UsesDefaultUserLayout()) return; try { if (OperatingSystem.IsWindows()) MigrateWindowsLayout(); } catch { } } private static void MigrateWindowsLayout() { var dest = WindowsProductDirectory(); var oldLocal = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ProductName); var oldRoam = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ProductName); if (!Directory.Exists(dest) && Directory.Exists(oldLocal)) { try { Directory.Move(oldLocal, dest); } catch { } } Directory.CreateDirectory(dest); AdoptSettingsIfEmpty(Path.Combine(oldRoam, "settings.json"), Path.Combine(dest, "settings.json")); AdoptSettingsIfEmpty(Path.Combine(oldLocal, "settings.json"), Path.Combine(dest, "settings.json")); FileSecretStore.MergeMissingKeys(Path.Combine(oldRoam, "secrets.bin"), Path.Combine(dest, "secrets.bin")); FileSecretStore.MergeMissingKeys(Path.Combine(oldLocal, "secrets.bin"), Path.Combine(dest, "secrets.bin")); CopyIfMissing(Path.Combine(oldRoam, "receipts.json"), Path.Combine(dest, "receipts.json")); MergeMissingDirectory(oldLocal, dest); MergeMissingDirectory(Path.Combine(oldLocal, "webview"), Path.Combine(dest, "webview")); } internal static void AdoptSettingsIfEmpty(string source, string dest) { if (!File.Exists(source)) return; if (!File.Exists(dest)) { CopyIfMissing(source, dest); return; } if (MailboxCount(dest) > 0 || MailboxCount(source) == 0) return; File.Copy(source, dest, overwrite: true); } internal static void MergeMissingDirectory(string source, string dest) { if (!Directory.Exists(source)) return; Directory.CreateDirectory(dest); foreach (var file in Directory.GetFiles(source)) { var name = Path.GetFileName(file); if (name.Equals("settings.json", StringComparison.OrdinalIgnoreCase) || name.Equals("secrets.bin", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".migrated", StringComparison.OrdinalIgnoreCase)) continue; CopyIfMissing(file, Path.Combine(dest, name)); } foreach (var dir in Directory.GetDirectories(source)) MergeMissingDirectory(dir, Path.Combine(dest, Path.GetFileName(dir))); } internal static int MailboxCount(string settingsPath) { try { using var document = JsonDocument.Parse(File.ReadAllText(settingsPath)); if (!document.RootElement.TryGetProperty("Configuration", out var configuration)) return 0; if (!configuration.TryGetProperty("Mailboxes", out var boxes) && !configuration.TryGetProperty("mailboxes", out boxes)) return 0; return boxes.ValueKind == JsonValueKind.Array ? boxes.GetArrayLength() : 0; } catch { return 0; } } private static void CopyIfMissing(string source, string dest) { if (!File.Exists(source) || File.Exists(dest)) return; var dir = Path.GetDirectoryName(dest); if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir); File.Copy(source, dest); } }