Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions Main.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2844,4 +2841,4 @@ public static string GetTranslation(string key)

#endregion
}
}
}
87 changes: 87 additions & 0 deletions ProfileStorage.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
<FlowLauncherPortable>\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) |
| `<Flow Launcher plugin settings>\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) |

Expand Down
117 changes: 117 additions & 0 deletions Tests/ProfileStorageTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string>
{
["srv"] = "ssh root@10.0.0.1"
},
CustomShellLists = new Dictionary<string, string>()
}, 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));
}
}
}
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading