diff --git a/Main.cs b/Main.cs index 916f9dd..5fcd4f6 100644 --- a/Main.cs +++ b/Main.cs @@ -133,15 +133,12 @@ public void Init(PluginInitContext context) { _pluginContext = context; - var sshDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".ssh"); - - _databasePath = Path.Combine(sshDir, "profiles.json"); _dataDir = Path.Combine(context.CurrentPluginMetadata.PluginDirectory, "data"); try { + _databasePath = ProfileStorage.PrepareProfilesPath( + context.CurrentPluginMetadata.PluginSettingsDirectoryPath); _profileManager = new ProfileManager(_databasePath); } catch @@ -2844,4 +2841,4 @@ public static string GetTranslation(string key) #endregion } -} \ No newline at end of file +} diff --git a/ProfileStorage.cs b/ProfileStorage.cs new file mode 100644 index 0000000..ab74624 --- /dev/null +++ b/ProfileStorage.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; + +namespace Flow.Launcher.Plugin.QuickSSH +{ + internal static class ProfileStorage + { + internal const string ProfilesFileName = "profiles.json"; + + internal static string GetDefaultLegacyProfilesPath() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".ssh", + ProfilesFileName); + } + + internal static string PrepareProfilesPath(string pluginSettingsDirectoryPath) + { + return PrepareProfilesPath( + pluginSettingsDirectoryPath, + GetDefaultLegacyProfilesPath()); + } + + internal static string PrepareProfilesPath( + string pluginSettingsDirectoryPath, + string legacyProfilesPath) + { + if (string.IsNullOrWhiteSpace(pluginSettingsDirectoryPath)) + throw new ArgumentException("Plugin settings directory path is required.", nameof(pluginSettingsDirectoryPath)); + + var profilesPath = Path.Combine(pluginSettingsDirectoryPath, ProfilesFileName); + CopyLegacyProfilesIfNeeded(legacyProfilesPath, profilesPath); + return profilesPath; + } + + private static void CopyLegacyProfilesIfNeeded(string legacyProfilesPath, string profilesPath) + { + if (string.IsNullOrWhiteSpace(legacyProfilesPath)) + return; + + if (PathsEqual(legacyProfilesPath, profilesPath)) + return; + + if (File.Exists(profilesPath) || !File.Exists(legacyProfilesPath)) + return; + + var profilesDir = Path.GetDirectoryName(profilesPath); + if (!string.IsNullOrEmpty(profilesDir) && !Directory.Exists(profilesDir)) + Directory.CreateDirectory(profilesDir); + + var tmp = profilesPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.Copy(legacyProfilesPath, tmp); + try + { + File.Move(tmp, profilesPath, overwrite: false); + } + catch (IOException) when (File.Exists(profilesPath)) + { + // Another startup path created the target first; keep that file. + } + } + finally + { + if (File.Exists(tmp)) + try { File.Delete(tmp); } catch { /* best effort cleanup */ } + } + } + + private static bool PathsEqual(string left, string right) + { + try + { + left = Path.GetFullPath(left); + right = Path.GetFullPath(right); + } + catch + { + // Fall back to the original strings when a path cannot be normalised. + } + + return string.Equals(left, right, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/README.md b/README.md index 63d35b0..6f8a3cd 100644 --- a/README.md +++ b/README.md @@ -508,9 +508,21 @@ Click any shell in the list to **select** it. All SSH connections will then laun ## Data Storage +QuickSSH stores its main database in Flow Launcher's per-plugin settings directory. +In portable Flow Launcher builds this stays inside the portable `UserData` tree: + +``` +\UserData\Settings\Plugins\Flow.Launcher.Plugin.QuickSSH\profiles.json +``` + +On first startup after upgrading, an existing legacy `~/.ssh/profiles.json` file is copied +to the new location only when the new database does not already exist. The legacy file is +left untouched. + | File | Purpose | |------|---------| -| `~/.ssh/profiles.json` | Main profile, shell, and key database (v2 structured JSON) | +| `\profiles.json` | Main profile, shell, and key database (v2 structured JSON) | +| `~/.ssh/profiles.json` | Legacy database path, copied once during upgrade when needed | | `%APPDATA%\FlowLauncher\Plugins\QuickSSH\data\*.sshconfig` | Human-readable export/import files | | `%APPDATA%\FlowLauncher\Plugins\QuickSSH\data\*.json` | Legacy import files (v1, still readable) | diff --git a/Tests/ProfileStorageTests.cs b/Tests/ProfileStorageTests.cs new file mode 100644 index 0000000..5335b4e --- /dev/null +++ b/Tests/ProfileStorageTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class ProfileStorageTests : IDisposable + { + private readonly string _tmpDir; + + public ProfileStorageTests() + { + _tmpDir = Path.Combine(Path.GetTempPath(), $"quickssh_storage_tests_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tmpDir); + } + + public void Dispose() + { + if (Directory.Exists(_tmpDir)) + Directory.Delete(_tmpDir, recursive: true); + } + + private string GetSettingsDir() + => Path.Combine( + _tmpDir, + "FlowPortable", + "UserData", + "Settings", + "Plugins", + "Flow.Launcher.Plugin.QuickSSH"); + + private string GetLegacyProfilesPath() + => Path.Combine(_tmpDir, "UserProfile", ".ssh", "profiles.json"); + + [Fact] + public void PrepareProfilesPath_UsesPluginSettingsDirectory() + { + var settingsDir = GetSettingsDir(); + var legacyPath = GetLegacyProfilesPath(); + + var profilesPath = ProfileStorage.PrepareProfilesPath(settingsDir, legacyPath); + + Assert.Equal(Path.Combine(settingsDir, "profiles.json"), profilesPath); + Assert.False(File.Exists(profilesPath)); + } + + [Fact] + public void PrepareProfilesPath_CopiesLegacyFileWhenTargetIsMissing() + { + var settingsDir = GetSettingsDir(); + var legacyPath = GetLegacyProfilesPath(); + Directory.CreateDirectory(Path.GetDirectoryName(legacyPath)!); + File.WriteAllText(legacyPath, """{"PluginVersion":"2.0","ProfilesLists":{}}"""); + + var profilesPath = ProfileStorage.PrepareProfilesPath(settingsDir, legacyPath); + + Assert.True(File.Exists(profilesPath)); + Assert.Equal(File.ReadAllText(legacyPath), File.ReadAllText(profilesPath)); + Assert.True(File.Exists(legacyPath), "Legacy profiles.json must be preserved."); + Assert.Empty(Directory.GetFiles(settingsDir, "*.tmp")); + } + + [Fact] + public void PrepareProfilesPath_DoesNotOverwriteExistingTarget() + { + var settingsDir = GetSettingsDir(); + var profilesPath = Path.Combine(settingsDir, "profiles.json"); + Directory.CreateDirectory(settingsDir); + File.WriteAllText(profilesPath, """{"PluginVersion":"2.0","ProfilesLists":{"current":{}}}"""); + + var legacyPath = GetLegacyProfilesPath(); + Directory.CreateDirectory(Path.GetDirectoryName(legacyPath)!); + File.WriteAllText(legacyPath, """{"PluginVersion":"2.0","ProfilesLists":{"legacy":{}}}"""); + + var resolvedPath = ProfileStorage.PrepareProfilesPath(settingsDir, legacyPath); + + Assert.Equal(profilesPath, resolvedPath); + Assert.Contains("current", File.ReadAllText(profilesPath)); + Assert.DoesNotContain("legacy", File.ReadAllText(profilesPath)); + Assert.Contains("legacy", File.ReadAllText(legacyPath)); + } + + [Fact] + public void PrepareProfilesPath_CopiedLegacyV1IsMigratedByProfileManager() + { + var settingsDir = GetSettingsDir(); + var legacyPath = GetLegacyProfilesPath(); + Directory.CreateDirectory(Path.GetDirectoryName(legacyPath)!); + + var legacyJson = JsonConvert.SerializeObject(new + { + PluginVersion = "1.0", + EntriesLists = new Dictionary + { + ["srv"] = "ssh root@10.0.0.1" + }, + CustomShellLists = new Dictionary() + }, Formatting.Indented); + File.WriteAllText(legacyPath, legacyJson); + + var profilesPath = ProfileStorage.PrepareProfilesPath(settingsDir, legacyPath); + var pm = new ProfileManager(profilesPath); + + Assert.True(pm.UserData.Profiles.ContainsKey("srv")); + Assert.Equal("root", pm.UserData.Profiles["srv"].User); + Assert.Equal("10.0.0.1", pm.UserData.Profiles["srv"].HostName); + + var savedJson = File.ReadAllText(profilesPath); + Assert.DoesNotContain("EntriesLists", savedJson); + Assert.Contains("ProfilesLists", savedJson); + + Assert.Contains("EntriesLists", File.ReadAllText(legacyPath)); + } + } +} diff --git a/plugin.json b/plugin.json index 10cae7e..006cf44 100644 --- a/plugin.json +++ b/plugin.json @@ -4,7 +4,7 @@ "Name": "QuickSSH", "Description": "SSH/SCP plugin with structured profiles, SSH-config-like import/export, direct SSH, custom shell management, and query autocomplete via suggestions", "Author": "Vaso73", - "Version": "3.5.1", + "Version": "3.5.2", "Language": "csharp", "Website": "https://github.com/Vaso73/Flow.Launcher.Plugin.QuickSSH", "IcoPath": "Images\\app.png",