diff --git a/README.md b/README.md
index d073a94..3712991 100644
--- a/README.md
+++ b/README.md
@@ -65,7 +65,7 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ
|---|---|
| 上段 | 起動中ウィンドウ一覧(チェックして保存対象を選択) |
| 中段 | プロファイル名入力 → 「チェックを保存」 |
-| 下段 | 保存済みプロファイル一覧(チェック=連動 ON/OFF) |
+| 下段 | 保存済みプロファイル一覧(チェック=連動 ON/OFF、名前ダブルクリックでリネーム) |
| ボタン | 適用(配置のみ)/ 一括起動+配置 / 削除 |
| 設定 | 連動機能全体 ON/OFF、起動時 GUI 表示、profiles.json 保存先変更 |
@@ -91,6 +91,7 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ
},
"profiles": [
{
+ "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"name": "作業用レイアウト",
"syncMinMax": 0,
"createdAt": "2026-01-01T20:20:20",
@@ -117,6 +118,8 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ
}
```
+> `id` はプロファイルの内部識別子(UUID)です。旧バージョンで作成された `profiles.json` に `id` がない場合、起動時に自動で付与されます。
+
## ブラウザ URL 取得
| ブラウザ | 方式 | 備考 |
diff --git a/src/WindowController.App/MainWindow.xaml b/src/WindowController.App/MainWindow.xaml
index a57b05b..0b70112 100644
--- a/src/WindowController.App/MainWindow.xaml
+++ b/src/WindowController.App/MainWindow.xaml
@@ -179,7 +179,7 @@
-
+
diff --git a/src/WindowController.App/SyncManager.cs b/src/WindowController.App/SyncManager.cs
index 3f94009..726407a 100644
--- a/src/WindowController.App/SyncManager.cs
+++ b/src/WindowController.App/SyncManager.cs
@@ -15,8 +15,10 @@ public class SyncManager : IDisposable
private readonly WinEventHookManager _hookManager;
private readonly ILogger _log;
- // profileName -> set of hwnds
+ // profileId -> set of hwnds
private Dictionary> _syncGroups = new();
+ // profileId -> display name (for logging)
+ private Dictionary _profileNames = new();
private Dictionary _lastMinMaxByHwnd = new();
private Dictionary _lastForegroundByProfile = new();
private Dictionary _lastForegroundTickByProfile = new();
@@ -52,6 +54,8 @@ public void RebuildGroups()
var candidates = GetCandidatesLightweight();
var newGroups = new Dictionary>();
+ var newNames = new Dictionary();
+
foreach (var profile in _store.Data.Profiles)
{
if (profile.SyncMinMax == 0) continue;
@@ -64,10 +68,14 @@ public void RebuildGroups()
group.Add(match.Hwnd);
}
if (group.Count > 0)
- newGroups[profile.Name] = group;
+ {
+ newGroups[profile.Id] = group;
+ newNames[profile.Id] = profile.Name;
+ }
}
_syncGroups = newGroups;
+ _profileNames = newNames;
_lastRebuildTick = Environment.TickCount64;
}
@@ -110,6 +118,7 @@ public void UpdateHooksIfNeeded(bool skipRebuild = false)
{
_hookManager.Uninstall();
_syncGroups.Clear();
+ _profileNames.Clear();
_lastMinMaxByHwnd.Clear();
}
}
@@ -158,8 +167,8 @@ private void OnWinEvent(uint eventType, nint hwnd)
_isPropagating = true;
try
{
- foreach (var (name, group) in groups)
- PropagateMinMax(name, group, hwnd, mm);
+ foreach (var (id, group) in groups)
+ PropagateMinMax(id, group, hwnd, mm);
}
finally
{
@@ -191,8 +200,8 @@ private void OnForegroundEvent(nint hwnd)
_isPropagating = true;
try
{
- foreach (var (name, group) in groups)
- PropagateForeground(name, group, hwnd);
+ foreach (var (id, group) in groups)
+ PropagateForeground(id, group, hwnd);
}
finally
{
@@ -206,7 +215,7 @@ private void OnForegroundEvent(nint hwnd)
}
}
- private void PropagateMinMax(string profileName, HashSet group, nint sourceHwnd, int mm)
+ private void PropagateMinMax(string profileId, HashSet group, nint sourceHwnd, int mm)
{
int count = 0;
foreach (var target in group)
@@ -236,20 +245,23 @@ private void PropagateMinMax(string profileName, HashSet group, nint sourc
catch (Exception ex) { _log.Debug(ex, "PropagateMinMax failed for target {Target}", target); }
}
if (count > 0)
- _log.Information("Sync propagated within profile '{Name}' to {Count} window(s)", profileName, count);
+ {
+ var name = _profileNames.GetValueOrDefault(profileId, profileId);
+ _log.Information("Sync propagated within profile '{Name}' to {Count} window(s)", name, count);
+ }
}
- private void PropagateForeground(string profileName, HashSet group, nint sourceHwnd)
+ private void PropagateForeground(string profileId, HashSet group, nint sourceHwnd)
{
var now = Environment.TickCount64;
- if (_lastForegroundTickByProfile.TryGetValue(profileName, out var lastTick) &&
+ if (_lastForegroundTickByProfile.TryGetValue(profileId, out var lastTick) &&
now - lastTick < 250 &&
- _lastForegroundByProfile.TryGetValue(profileName, out var lastHwnd) &&
+ _lastForegroundByProfile.TryGetValue(profileId, out var lastHwnd) &&
lastHwnd == sourceHwnd)
return;
- _lastForegroundTickByProfile[profileName] = now;
- _lastForegroundByProfile[profileName] = sourceHwnd;
+ _lastForegroundTickByProfile[profileId] = now;
+ _lastForegroundByProfile[profileId] = sourceHwnd;
int count = 0;
foreach (var target in group)
@@ -267,16 +279,19 @@ private void PropagateForeground(string profileName, HashSet group, nint s
catch (Exception ex) { _log.Debug(ex, "PropagateForeground failed for target {Target}", target); }
}
if (count > 0)
- _log.Information("Foreground sync within profile '{Name}' to {Count} window(s)", profileName, count);
+ {
+ var name = _profileNames.GetValueOrDefault(profileId, profileId);
+ _log.Information("Foreground sync within profile '{Name}' to {Count} window(s)", name, count);
+ }
}
- private List<(string Name, HashSet Group)> GetGroupsContainingHwnd(nint hwnd)
+ private List<(string Id, HashSet Group)> GetGroupsContainingHwnd(nint hwnd)
{
var result = new List<(string, HashSet)>();
- foreach (var (name, group) in _syncGroups)
+ foreach (var (id, group) in _syncGroups)
{
if (group.Contains(hwnd))
- result.Add((name, group));
+ result.Add((id, group));
}
return result;
}
diff --git a/src/WindowController.App/ViewModels/MainViewModel.cs b/src/WindowController.App/ViewModels/MainViewModel.cs
index 71469d9..cea74d8 100644
--- a/src/WindowController.App/ViewModels/MainViewModel.cs
+++ b/src/WindowController.App/ViewModels/MainViewModel.cs
@@ -31,7 +31,8 @@ public partial class WindowItem : ObservableObject
public partial class ProfileItem : ObservableObject
{
[ObservableProperty] private bool _syncMinMax;
- public string Name { get; init; } = "";
+ [ObservableProperty] private string _name = "";
+ public string Id { get; init; } = "";
public int WindowCount { get; init; }
}
@@ -44,6 +45,7 @@ public partial class MainViewModel : ObservableObject
private readonly SyncManager _syncManager;
private readonly AppSettingsStore _appSettings;
private readonly ILogger _log;
+ private bool _isUpdatingProfileName;
[ObservableProperty] private string _statusText = "";
[ObservableProperty] private string _profileName = "";
@@ -143,6 +145,7 @@ private void SaveProfile()
var profile = existing ?? new Profile
{
+ Id = Guid.NewGuid().ToString("D"),
Name = name,
CreatedAt = now,
SyncMinMax = 0
@@ -239,7 +242,7 @@ private async Task ApplyProfile()
StatusText = "プロファイルを選択してください。";
return;
}
- await DoApplyAsync(SelectedProfile.Name, false);
+ await DoApplyAsync(SelectedProfile.Id, false);
}
[RelayCommand]
@@ -250,20 +253,22 @@ private async Task LaunchAndApplyProfile()
StatusText = "プロファイルを選択してください。";
return;
}
- await DoApplyAsync(SelectedProfile.Name, true);
+ await DoApplyAsync(SelectedProfile.Id, true);
}
- private async Task DoApplyAsync(string profileName, bool launchMissing)
+ private async Task DoApplyAsync(string profileId, bool launchMissing)
{
try
{
- var profile = _store.FindByName(profileName);
+ var profile = _store.FindById(profileId);
if (profile == null)
{
- StatusText = $"プロファイルが見つかりません: {profileName}";
+ StatusText = $"プロファイルが見つかりません";
return;
}
+ var profileName = profile.Name;
+
var candidates = GetCandidates();
int applied = 0;
var failures = new List();
@@ -405,7 +410,7 @@ private void DeleteProfile()
return;
}
- if (_store.DeleteProfile(name))
+ if (_store.DeleteProfileById(SelectedProfile.Id))
{
ReloadProfiles();
_syncManager.ScheduleRebuild();
@@ -424,29 +429,94 @@ public void ReloadProfiles()
{
var item = new ProfileItem
{
+ Id = p.Id,
Name = p.Name,
SyncMinMax = p.SyncMinMax != 0,
WindowCount = p.Windows.Count
};
item.PropertyChanged += (s, e) =>
{
- if (e.PropertyName == nameof(ProfileItem.SyncMinMax) && s is ProfileItem pi)
+ if (s is not ProfileItem pi) return;
+ if (e.PropertyName == nameof(ProfileItem.SyncMinMax))
OnProfileSyncChanged(pi);
+ else if (e.PropertyName == nameof(ProfileItem.Name))
+ OnProfileNameChanged(pi);
};
Profiles.Add(item);
}
}
+ private void OnProfileNameChanged(ProfileItem pi)
+ {
+ // Prevent re-entrancy when we programmatically update pi.Name
+ if (_isUpdatingProfileName)
+ return;
+
+ // Get the current profile from the store
+ var current = _store.FindById(pi.Id);
+ if (current == null)
+ {
+ StatusText = "プロファイルが見つかりません。";
+ return;
+ }
+
+ // If the name has not actually changed compared to the store, ignore.
+ if (string.Equals(current.Name, pi.Name, StringComparison.Ordinal))
+ return;
+
+
+ var newName = pi.Name?.Trim();
+ if (string.IsNullOrEmpty(newName))
+ {
+ // Revert to current stored name
+ _isUpdatingProfileName = true;
+ try
+ {
+ pi.Name = current.Name;
+ }
+ finally
+ {
+ _isUpdatingProfileName = false;
+ }
+ StatusText = "プロファイル名を空にすることはできません。";
+ return;
+ }
+
+ var finalName = _store.RenameProfile(pi.Id, newName);
+ if (finalName == null)
+ {
+ StatusText = "プロファイルが見つかりません。";
+ return;
+ }
+
+ // If the name was adjusted due to conflict, update the UI
+ if (finalName != newName)
+ {
+ _isUpdatingProfileName = true;
+ try
+ {
+ pi.Name = finalName;
+ }
+ finally
+ {
+ _isUpdatingProfileName = false;
+ }
+ }
+
+ _syncManager.ScheduleRebuild();
+ StatusText = $"名前を変更しました: {finalName}";
+ }
+
private void OnProfileSyncChanged(ProfileItem pi)
{
- var profile = _store.FindByName(pi.Name);
+ var profile = _store.FindById(pi.Id);
if (profile != null)
{
profile.SyncMinMax = pi.SyncMinMax ? 1 : 0;
_store.SaveProfile(profile);
// UpdateHooksIfNeeded already schedules a rebuild internally
_syncManager.UpdateHooksIfNeeded();
- StatusText = $"連動設定({pi.Name}): {(pi.SyncMinMax ? "ON" : "OFF")}";
+ StatusText = $"連動設定({profile.Name}): {(pi.SyncMinMax ? "ON" : "OFF")}";
}
}
diff --git a/src/WindowController.Core.Tests/ProfileStoreTests.cs b/src/WindowController.Core.Tests/ProfileStoreTests.cs
new file mode 100644
index 0000000..0076f21
--- /dev/null
+++ b/src/WindowController.Core.Tests/ProfileStoreTests.cs
@@ -0,0 +1,259 @@
+using WindowController.Core;
+using WindowController.Core.Models;
+using Serilog;
+
+namespace WindowController.Core.Tests;
+
+public class ProfileStoreTests : IDisposable
+{
+ private readonly string _tempDir;
+ private readonly ILogger _log = new LoggerConfiguration().CreateLogger();
+
+ public ProfileStoreTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "WC_Tests_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(_tempDir, true);
+ }
+ catch (Exception ex)
+ {
+ _log.Warning(ex, "Failed to delete temporary test directory {TempDir}", _tempDir);
+ }
+ }
+
+ private string TempFile() => Path.Combine(_tempDir, "profiles.json");
+
+ private ProfileStore CreateStore(string? json = null)
+ {
+ var path = TempFile();
+ if (json != null)
+ File.WriteAllText(path, json);
+ var store = new ProfileStore(path, _log);
+ store.Load();
+ return store;
+ }
+
+ // ── ID migration ──
+
+ [Fact]
+ public void Load_AssignsId_WhenMissing()
+ {
+ var json = """
+ {
+ "version": 1,
+ "settings": { "syncMinMax": 0, "showGuiOnStartup": 1 },
+ "profiles": [
+ { "name": "A", "syncMinMax": 0, "createdAt": "", "updatedAt": "", "windows": [] },
+ { "name": "B", "syncMinMax": 0, "createdAt": "", "updatedAt": "", "windows": [] }
+ ]
+ }
+ """;
+ var store = CreateStore(json);
+
+ Assert.All(store.Data.Profiles, p => Assert.False(string.IsNullOrEmpty(p.Id)));
+ // Each Id should be unique
+ var ids = store.Data.Profiles.Select(p => p.Id).ToList();
+ Assert.Equal(ids.Count, ids.Distinct().Count());
+ }
+
+ [Fact]
+ public void Load_ReassignsDuplicateIds()
+ {
+ var json = """
+ {
+ "version": 1,
+ "settings": { "syncMinMax": 0, "showGuiOnStartup": 1 },
+ "profiles": [
+ { "id": "same-id", "name": "A", "syncMinMax": 0, "createdAt": "", "updatedAt": "", "windows": [] },
+ { "id": "same-id", "name": "B", "syncMinMax": 0, "createdAt": "", "updatedAt": "", "windows": [] }
+ ]
+ }
+ """;
+ var store = CreateStore(json);
+
+ var ids = store.Data.Profiles.Select(p => p.Id).ToList();
+ Assert.Equal(2, ids.Distinct().Count());
+ }
+
+ [Fact]
+ public void Load_PreservesExistingValidIds()
+ {
+ var existingId = Guid.NewGuid().ToString("D");
+ var json = $$"""
+ {
+ "version": 1,
+ "settings": { "syncMinMax": 0, "showGuiOnStartup": 1 },
+ "profiles": [
+ { "id": "{{existingId}}", "name": "A", "syncMinMax": 0, "createdAt": "", "updatedAt": "", "windows": [] }
+ ]
+ }
+ """;
+ var store = CreateStore(json);
+
+ Assert.Equal(existingId, store.Data.Profiles[0].Id);
+ }
+
+ // ── FindById ──
+
+ [Fact]
+ public void FindById_ReturnsProfile()
+ {
+ var store = CreateStore();
+ var profile = new Profile { Id = "test-id", Name = "Test", CreatedAt = "", UpdatedAt = "" };
+ store.SaveProfile(profile);
+
+ var found = store.FindById("test-id");
+ Assert.NotNull(found);
+ Assert.Equal("Test", found.Name);
+ }
+
+ [Fact]
+ public void FindById_ReturnsNull_WhenNotFound()
+ {
+ var store = CreateStore();
+ Assert.Null(store.FindById("nonexistent"));
+ }
+
+ // ── SaveProfile (Id-based) ──
+
+ [Fact]
+ public void SaveProfile_UpdatesById_WhenIdMatches()
+ {
+ var store = CreateStore();
+ var profile = new Profile { Id = "id-1", Name = "Original", CreatedAt = "", UpdatedAt = "" };
+ store.SaveProfile(profile);
+
+ var updated = new Profile { Id = "id-1", Name = "Renamed", CreatedAt = "", UpdatedAt = "" };
+ store.SaveProfile(updated);
+
+ Assert.Single(store.Data.Profiles);
+ Assert.Equal("Renamed", store.Data.Profiles[0].Name);
+ }
+
+ // ── DeleteProfileById ──
+
+ [Fact]
+ public void DeleteProfileById_RemovesCorrectProfile()
+ {
+ var store = CreateStore();
+ store.SaveProfile(new Profile { Id = "id-1", Name = "A", CreatedAt = "", UpdatedAt = "" });
+ store.SaveProfile(new Profile { Id = "id-2", Name = "B", CreatedAt = "", UpdatedAt = "" });
+
+ Assert.True(store.DeleteProfileById("id-1"));
+ Assert.Single(store.Data.Profiles);
+ Assert.Equal("B", store.Data.Profiles[0].Name);
+ }
+
+ [Fact]
+ public void DeleteProfileById_ReturnsFalse_WhenNotFound()
+ {
+ var store = CreateStore();
+ Assert.False(store.DeleteProfileById("nonexistent"));
+ }
+
+ // ── RenameProfile ──
+
+ [Fact]
+ public void RenameProfile_ChangesName()
+ {
+ var store = CreateStore();
+ store.SaveProfile(new Profile { Id = "id-1", Name = "Old", CreatedAt = "", UpdatedAt = "" });
+
+ var result = store.RenameProfile("id-1", "New");
+
+ Assert.Equal("New", result);
+ Assert.Equal("New", store.FindById("id-1")!.Name);
+ }
+
+ [Fact]
+ public void RenameProfile_ReturnsNull_WhenNotFound()
+ {
+ var store = CreateStore();
+ Assert.Null(store.RenameProfile("nonexistent", "Name"));
+ }
+
+ [Fact]
+ public void RenameProfile_AutoSuffix_WhenNameConflicts()
+ {
+ var store = CreateStore();
+ store.SaveProfile(new Profile { Id = "id-1", Name = "Layout", CreatedAt = "", UpdatedAt = "" });
+ store.SaveProfile(new Profile { Id = "id-2", Name = "Other", CreatedAt = "", UpdatedAt = "" });
+
+ var result = store.RenameProfile("id-2", "Layout");
+
+ Assert.Equal("Layout (2)", result);
+ Assert.Equal("Layout (2)", store.FindById("id-2")!.Name);
+ // Original is untouched
+ Assert.Equal("Layout", store.FindById("id-1")!.Name);
+ }
+
+ [Fact]
+ public void RenameProfile_AutoSuffix_IncrementsWhenMultipleConflicts()
+ {
+ var store = CreateStore();
+ store.SaveProfile(new Profile { Id = "id-1", Name = "Layout", CreatedAt = "", UpdatedAt = "" });
+ store.SaveProfile(new Profile { Id = "id-2", Name = "Layout (2)", CreatedAt = "", UpdatedAt = "" });
+ store.SaveProfile(new Profile { Id = "id-3", Name = "Other", CreatedAt = "", UpdatedAt = "" });
+
+ var result = store.RenameProfile("id-3", "Layout");
+
+ Assert.Equal("Layout (3)", result);
+ }
+
+ [Fact]
+ public void RenameProfile_NoSuffix_WhenSameProfileKeepsSameName()
+ {
+ var store = CreateStore();
+ store.SaveProfile(new Profile { Id = "id-1", Name = "Layout", CreatedAt = "", UpdatedAt = "" });
+
+ // Renaming to the same name should not add suffix
+ var result = store.RenameProfile("id-1", "Layout");
+
+ Assert.Equal("Layout", result);
+ }
+
+ [Fact]
+ public void RenameProfile_UpdatesTimestamp()
+ {
+ var store = CreateStore();
+ store.SaveProfile(new Profile { Id = "id-1", Name = "Old", CreatedAt = "2020-01-01T00:00:00", UpdatedAt = "2020-01-01T00:00:00" });
+
+ store.RenameProfile("id-1", "New");
+
+ var profile = store.FindById("id-1")!;
+ Assert.NotEqual("2020-01-01T00:00:00", profile.UpdatedAt);
+ }
+
+ // ── ResolveUniqueName ──
+
+ [Fact]
+ public void ResolveUniqueName_ReturnsOriginal_WhenNoConflict()
+ {
+ var store = CreateStore();
+ Assert.Equal("Unique", store.ResolveUniqueName("Unique"));
+ }
+
+ [Fact]
+ public void ResolveUniqueName_AddsSuffix_WhenConflicts()
+ {
+ var store = CreateStore();
+ store.SaveProfile(new Profile { Id = "id-1", Name = "Name", CreatedAt = "", UpdatedAt = "" });
+
+ Assert.Equal("Name (2)", store.ResolveUniqueName("Name"));
+ }
+
+ [Fact]
+ public void ResolveUniqueName_ExcludesOwnId()
+ {
+ var store = CreateStore();
+ store.SaveProfile(new Profile { Id = "id-1", Name = "Name", CreatedAt = "", UpdatedAt = "" });
+
+ Assert.Equal("Name", store.ResolveUniqueName("Name", "id-1"));
+ }
+}
diff --git a/src/WindowController.Core/Models/Profile.cs b/src/WindowController.Core/Models/Profile.cs
index 0dde5ab..5c4d767 100644
--- a/src/WindowController.Core/Models/Profile.cs
+++ b/src/WindowController.Core/Models/Profile.cs
@@ -4,6 +4,9 @@ namespace WindowController.Core.Models;
public class Profile
{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
[JsonPropertyName("name")]
public string Name { get; set; } = "";
diff --git a/src/WindowController.Core/ProfileStore.cs b/src/WindowController.Core/ProfileStore.cs
index 0840502..05224e9 100644
--- a/src/WindowController.Core/ProfileStore.cs
+++ b/src/WindowController.Core/ProfileStore.cs
@@ -62,7 +62,17 @@ public void Load()
{
var json = File.ReadAllText(_filePath);
var parsed = JsonSerializer.Deserialize(json, JsonOptions);
- Data = NormalizeData(parsed ?? new ProfilesRoot());
+
+ // Detect migration need before normalization
+ bool needsMigration = parsed != null && parsed.Profiles.Any(p =>
+ string.IsNullOrEmpty(p.Id) ||
+ parsed.Profiles.Count(x => x.Id == p.Id) > 1);
+
+ var normalized = NormalizeData(parsed ?? new ProfilesRoot());
+ Data = normalized;
+
+ if (needsMigration)
+ Save();
}
catch (Exception ex)
{
@@ -101,9 +111,15 @@ public void Save()
public Profile? FindByName(string name)
=> Data.Profiles.FirstOrDefault(p => p.Name == name);
+ public Profile? FindById(string id)
+ => Data.Profiles.FirstOrDefault(p => p.Id == id);
+
public void SaveProfile(Profile profile)
{
- var idx = Data.Profiles.FindIndex(p => p.Name == profile.Name);
+ // Prefer Id-based lookup when the profile has a valid Id
+ var idx = !string.IsNullOrEmpty(profile.Id)
+ ? Data.Profiles.FindIndex(p => p.Id == profile.Id)
+ : Data.Profiles.FindIndex(p => p.Name == profile.Name);
if (idx >= 0)
Data.Profiles[idx] = profile;
else
@@ -119,6 +135,53 @@ public bool DeleteProfile(string name)
return removed;
}
+ public bool DeleteProfileById(string id)
+ {
+ var removed = Data.Profiles.RemoveAll(p => p.Id == id) > 0;
+ if (removed)
+ Save();
+ return removed;
+ }
+
+ ///
+ /// Rename a profile identified by .
+ /// If conflicts with another profile,
+ /// a numeric suffix like "(2)" is appended automatically.
+ /// Returns the final (possibly adjusted) name, or null when the profile was not found.
+ ///
+ public string? RenameProfile(string profileId, string desiredName)
+ {
+ var profile = FindById(profileId);
+ if (profile == null) return null;
+
+ var trimmed = desiredName.Trim();
+ var finalName = ResolveUniqueName(trimmed, profileId);
+
+ // Skip save if the name hasn't actually changed
+ if (finalName == profile.Name)
+ return finalName;
+
+ profile.Name = finalName;
+ profile.UpdatedAt = DateTime.Now.ToString("yyyy-MM-dd'T'HH:mm:ss");
+ Save();
+ return finalName;
+ }
+
+ ///
+ /// Returns a name that does not collide with existing profiles (excluding the one with ).
+ ///
+ internal string ResolveUniqueName(string desiredName, string? excludeId = null)
+ {
+ var candidate = desiredName;
+ int suffix = 2;
+ while (Data.Profiles.Any(p => p.Name == candidate && p.Id != excludeId))
+ {
+ candidate = $"{desiredName} ({suffix})";
+ suffix++;
+ }
+ return candidate;
+ }
+
private static ProfilesRoot CreateDefault() => new()
{
Version = 1,
@@ -130,6 +193,18 @@ private static ProfilesRoot NormalizeData(ProfilesRoot root)
{
root.Version = root.Version == 0 ? 1 : root.Version;
+ // Assign stable Ids to profiles that lack one (migration from older schema)
+ var seenIds = new HashSet();
+ foreach (var p in root.Profiles)
+ {
+ if (string.IsNullOrEmpty(p.Id) || !seenIds.Add(p.Id))
+ {
+ // Generate a new unique Id (duplicate Ids are also re-assigned)
+ p.Id = Guid.NewGuid().ToString("D");
+ seenIds.Add(p.Id);
+ }
+ }
+
foreach (var p in root.Profiles)
{
foreach (var w in p.Windows)
diff --git a/src/WindowController.Core/WindowController.Core.csproj b/src/WindowController.Core/WindowController.Core.csproj
index 34840bf..74aa857 100644
--- a/src/WindowController.Core/WindowController.Core.csproj
+++ b/src/WindowController.Core/WindowController.Core.csproj
@@ -8,6 +8,10 @@
embedded
+
+
+
+