From d8bfc4690356512d22ffb48248495c91f418cf40 Mon Sep 17 00:00:00 2001 From: hu-ja-ja Date: Sun, 15 Feb 2026 00:29:10 +0900 Subject: [PATCH 1/3] README --- README.md | 66 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3712991..a9fb240 100644 --- a/README.md +++ b/README.md @@ -49,25 +49,28 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ | メニュー | 動作 | |---|---| -| **GUI を開く** | 設定画面を表示 | -| **プロファイルを適用 (配置のみ)** | GUI を表示 | +| **GUI を開く** | メイン画面を表示 | +| **設定…** | 設定画面を表示 | +| **プロファイルを適用(配置のみ)** | メイン画面を表示(適用は GUI でプロファイルを選択) | | **終了** | アプリを終了 | ### ホットキー | キー | 動作 | |---|---| -| **Ctrl + Alt + W** | GUI を開く | +| **Ctrl + Alt + W** | メイン画面を表示(既定。設定で変更/無効化可) | +| (任意) | プロファイル適用ホットキー(プロファイルごとに設定。配置のみ適用) | ### GUI | 領域 | 説明 | |---|---| -| 上段 | 起動中ウィンドウ一覧(チェックして保存対象を選択) | -| 中段 | プロファイル名入力 → 「チェックを保存」 | -| 下段 | 保存済みプロファイル一覧(チェック=連動 ON/OFF、名前ダブルクリックでリネーム) | +| 起動中ウィンドウ | チェックしたウィンドウをプロファイルに保存(Title/Exe/Class/URL/Browser Profile を表示) | +| プロファイル保存 | プロファイル名入力 → 「プロファイル保存」 | +| 保存済みプロファイル | チェック=最小化/最大化を常時連動、名前編集(競合時は自動で連番付与) | | ボタン | 適用(配置のみ)/ 一括起動+配置 / 削除 | -| 設定 | 連動機能全体 ON/OFF、起動時 GUI 表示、profiles.json 保存先変更 | +| 右クリック | 「モニターを選択して配置」(配置前に警告が出ることがあります) | +| 設定画面 | 連動機能全体 ON/OFF、起動時 GUI 表示、profiles.json 保存先、ホットキー(GUI表示/プロファイル適用) | ## データの保存場所 @@ -80,6 +83,22 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ > `profiles.json` の保存先は GUI 下部の「変更…」ボタンで任意のフォルダに変更できます。 > 「既定に戻す」で上記デフォルトに戻ります。 +### appsettings.json について + +`appsettings.json` はアプリ全体の設定(`profiles.json` の保存先・ホットキー設定など)を保持します。 + +```json +{ + "profilesPath": "C:\\Path\\To\\profiles.json", + "hotkeys": { + "showGui": { "key": "W", "ctrl": true, "alt": true, "shift": false, "win": false }, + "profiles": { + "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx": { "key": "1", "ctrl": true, "alt": false, "shift": false, "win": false } + } + } +} +``` + ### profiles.json スキーマ ```json @@ -87,7 +106,11 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ "version": 1, "settings": { "syncMinMax": 0, - "showGuiOnStartup": 1 + "showGuiOnStartup": 1, + "aspectRatioWarnThreshold": 0.02, + "warnOnResolutionMismatch": true, + "warnOnMonitorMismatch": true, + "allowCrossDesktopApply": true }, "profiles": [ { @@ -96,21 +119,34 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ "syncMinMax": 0, "createdAt": "2026-01-01T20:20:20", "updatedAt": "2026-01-01T20:20:20", + "targetDesktopId": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", "windows": [ { "match": { "exe": "Code - Insiders.exe", "class": "Chrome_WidgetWin_1", "title": "Window Title", - "url": "", - "urlKey": "", - "browser": null + "url": "https://example.com/", + "urlKey": "https://example.com/", + "browser": { + "kind": "chromium", + "userDataDir": "C:\\Users\\you\\AppData\\Local\\Google\\Chrome\\User Data", + "profileDirectory": "Default" + } }, "path": "C:\\Users\\you\\AppData\\Local\\Programs\\...\\Code - Insiders.exe", "rect": { "x": 0, "y": 0, "w": 960, "h": 1040 }, + "rectNormalized": { "xN": 0.0, "yN": 0.0, "wN": 0.5, "hN": 1.0 }, "minMax": 0, "snap": { "type": "left" }, - "monitor": { "index": 1, "name": "\\\\.\\DISPLAY1" } + "monitor": { + "index": 1, + "name": "\\\\.\\DISPLAY1", + "devicePath": "\\\\?\\DISPLAY#...", + "pixelWidth": 3840, + "pixelHeight": 2160 + }, + "desktopId": "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" } ] } @@ -118,7 +154,11 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ } ``` -> `id` はプロファイルの内部識別子(UUID)です。旧バージョンで作成された `profiles.json` に `id` がない場合、起動時に自動で付与されます。 +> `id` はプロファイルの内部識別子(UUID)です。旧バージョンで作成された `profiles.json` に `id` がない場合や、`id` が重複している場合は起動時に自動で再付与されます。 + +> JSON は `null` の項目を出力しません(例: `browser` / `snap` / `monitor` / `rectNormalized` / `desktopId` / `targetDesktopId` は状況により省略されます)。また `monitor.pixelWidth` / `monitor.pixelHeight` は 0 の場合は省略されます。 + +> `desktopId` / `targetDesktopId` / `allowCrossDesktopApply` など仮想デスクトップ関連は将来拡張向けのフィールドで、GUI 上に「※整備中」と表示される機能は現状動作しない場合があります。 ## ブラウザ URL 取得 From 94cd51b2ef1f884393774b1de5d69a40775e6309 Mon Sep 17 00:00:00 2001 From: hu-ja-ja Date: Mon, 16 Feb 2026 16:44:38 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=E6=97=A2=E5=AE=9A=E3=81=AE=E3=83=97?= =?UTF-8?q?=E3=83=AD=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92=E9=81=A9?= =?UTF-8?q?=E7=94=A8=E3=81=99=E3=82=8B=E6=A9=9F=E8=83=BD=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/WindowController.App/App.xaml.cs | 62 ++++++++++++++++- .../DefaultProfilesMissingDialog.xaml | 59 ++++++++++++++++ .../DefaultProfilesMissingDialog.xaml.cs | 26 +++++++ src/WindowController.App/SettingsWindow.xaml | 68 +++++++++++++++++- .../ViewModels/SettingsViewModel.cs | 69 +++++++++++++++++++ .../Models/AppSettings.cs | 7 ++ 6 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 src/WindowController.App/DefaultProfilesMissingDialog.xaml create mode 100644 src/WindowController.App/DefaultProfilesMissingDialog.xaml.cs diff --git a/src/WindowController.App/App.xaml.cs b/src/WindowController.App/App.xaml.cs index 4356dce..f494fa3 100644 --- a/src/WindowController.App/App.xaml.cs +++ b/src/WindowController.App/App.xaml.cs @@ -217,9 +217,8 @@ private void SetupTrayIcon() contextMenu.Items.Add(new System.Windows.Controls.Separator()); - var menuApply = new System.Windows.Controls.MenuItem { Header = "プロファイルを適用(配置のみ)" }; - // TODO: Add submenu for profile selection if needed - menuApply.Click += (_, _) => ShowMainWindow(); + var menuApply = new System.Windows.Controls.MenuItem { Header = "既定のプロファイルを適用" }; + menuApply.Click += async (_, _) => await ApplyDefaultProfilesAsync(); contextMenu.Items.Add(menuApply); contextMenu.Items.Add(new System.Windows.Controls.Separator()); @@ -241,6 +240,63 @@ private void ShowMainWindow() _mainWindow.Activate(); } + private async Task ApplyDefaultProfilesAsync() + { + if (_profileApplier == null || _appSettingsStore == null || _profileStore == null) + return; + + var defaultIds = _appSettingsStore.Data.DefaultProfileIds; + if (defaultIds.Count == 0) + { + var shouldPopup = _mainWindow == null || !_mainWindow.IsVisible || _mainWindow.WindowState == WindowState.Minimized; + + if (shouldPopup) + { + var dlg = new DefaultProfilesMissingDialog(); + + // WPF throws if Owner is set to a Window that has never been shown. + if (_mainWindow is { IsVisible: true }) + { + dlg.Owner = _mainWindow; + dlg.WindowStartupLocation = WindowStartupLocation.CenterOwner; + } + else + { + dlg.WindowStartupLocation = WindowStartupLocation.CenterScreen; + } + + var openSettings = dlg.ShowDialog() == true; + if (openSettings) + ShowSettingsWindow(); + } + else if (_viewModel != null) + { + _viewModel.StatusText = "既定のプロファイルが設定されていません"; + } + _log?.Information("No default profiles configured"); + return; + } + + nint appHwnd = 0; + if (_mainWindow != null) + { + var helper = new WindowInteropHelper(_mainWindow); + appHwnd = helper.Handle; + } + + var messages = new List(); + foreach (var profileId in defaultIds) + { + var result = await _profileApplier.ApplyByIdAsync(profileId, false, appHwnd); + var profile = _profileStore.FindById(profileId); + var name = profile?.Name ?? profileId; + messages.Add(result.ToStatusMessage(name)); + } + + if (_viewModel != null) + _viewModel.StatusText = string.Join(" / ", messages); + } + private void ShowSettingsWindow() { if (_settingsWindow == null) return; diff --git a/src/WindowController.App/DefaultProfilesMissingDialog.xaml b/src/WindowController.App/DefaultProfilesMissingDialog.xaml new file mode 100644 index 0000000..6bfb154 --- /dev/null +++ b/src/WindowController.App/DefaultProfilesMissingDialog.xaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WindowController.App/DefaultProfilesMissingDialog.xaml.cs b/src/WindowController.App/DefaultProfilesMissingDialog.xaml.cs new file mode 100644 index 0000000..73f49a7 --- /dev/null +++ b/src/WindowController.App/DefaultProfilesMissingDialog.xaml.cs @@ -0,0 +1,26 @@ +using System.Windows; +using Wpf.Ui.Controls; + +namespace WindowController.App; + +/// +/// Dialog shown when no default profiles are configured. +/// Returns true via DialogResult when the user chooses to open settings. +/// +public partial class DefaultProfilesMissingDialog : FluentWindow +{ + public DefaultProfilesMissingDialog() + { + InitializeComponent(); + } + + private void OpenSettings_Click(object sender, RoutedEventArgs e) + { + DialogResult = true; + } + + private void Close_Click(object sender, RoutedEventArgs e) + { + DialogResult = false; + } +} diff --git a/src/WindowController.App/SettingsWindow.xaml b/src/WindowController.App/SettingsWindow.xaml index eb44b9c..5621a2e 100644 --- a/src/WindowController.App/SettingsWindow.xaml +++ b/src/WindowController.App/SettingsWindow.xaml @@ -169,7 +169,73 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WindowController.App/ViewModels/SettingsViewModel.cs b/src/WindowController.App/ViewModels/SettingsViewModel.cs index 4978dee..d5f0828 100644 --- a/src/WindowController.App/ViewModels/SettingsViewModel.cs +++ b/src/WindowController.App/ViewModels/SettingsViewModel.cs @@ -27,6 +27,16 @@ public void UpdateDisplay() } } +/// +/// Represents a profile item for the default profiles list in settings UI. +/// +public partial class DefaultProfileItem : ObservableObject +{ + [ObservableProperty] private string _profileId = ""; + [ObservableProperty] private string _profileName = ""; + [ObservableProperty] private bool _isDefault; +} + /// /// ViewModel for the SettingsWindow. /// Manages both profiles.json settings (via ProfileStore) and appsettings.json (via AppSettingsStore). @@ -59,6 +69,9 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private ProfileHotkeyItem? _selectedProfileHotkey; [ObservableProperty] private bool _isCapturingProfileHotkey; + // ========== Default profiles settings ========== + public ObservableCollection DefaultProfiles { get; } = new(); + [ObservableProperty] private string _statusText = ""; public SettingsViewModel( @@ -94,6 +107,9 @@ private void LoadSettings() // Load profile hotkeys LoadProfileHotkeys(); + + // Load default profiles + LoadDefaultProfiles(); } // ========== Reset-to-default (per section) ========== @@ -164,6 +180,58 @@ private void LoadProfileHotkeys() } } + private void LoadDefaultProfiles() + { + DefaultProfiles.Clear(); + var defaultIds = _appSettingsStore.Data.DefaultProfileIds; + foreach (var profile in _profileStore.Data.Profiles) + { + var item = new DefaultProfileItem + { + ProfileId = profile.Id, + ProfileName = profile.Name, + IsDefault = defaultIds.Contains(profile.Id) + }; + item.PropertyChanged += DefaultProfileItem_PropertyChanged; + DefaultProfiles.Add(item); + } + } + + private void DefaultProfileItem_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(DefaultProfileItem.IsDefault)) return; + if (sender is not DefaultProfileItem item) return; + + var ids = _appSettingsStore.Data.DefaultProfileIds; + if (item.IsDefault) + { + if (!ids.Contains(item.ProfileId)) + ids.Add(item.ProfileId); + } + else + { + ids.Remove(item.ProfileId); + } + _appSettingsStore.Save(); + StatusText = item.IsDefault + ? $"「{item.ProfileName}」を既定プロファイルに追加しました" + : $"「{item.ProfileName}」を既定プロファイルから解除しました"; + } + + [RelayCommand] + private void ResetDefaultProfiles() + { + _appSettingsStore.Data.DefaultProfileIds.Clear(); + _appSettingsStore.Save(); + foreach (var item in DefaultProfiles) + { + item.PropertyChanged -= DefaultProfileItem_PropertyChanged; + item.IsDefault = false; + item.PropertyChanged += DefaultProfileItem_PropertyChanged; + } + StatusText = "既定プロファイルをクリアしました"; + } + private void UpdateGuiHotkeyDisplay() { GuiHotkeyDisplay = GuiHotkeyBinding.IsEmpty ? "なし" : GuiHotkeyBinding.ToString(); @@ -458,5 +526,6 @@ private static HotkeyBinding CreateBindingFromKeyEvent(KeyEventArgs e) public void RefreshProfiles() { LoadProfileHotkeys(); + LoadDefaultProfiles(); } } diff --git a/src/WindowController.Core/Models/AppSettings.cs b/src/WindowController.Core/Models/AppSettings.cs index d1fbdd4..ec59538 100644 --- a/src/WindowController.Core/Models/AppSettings.cs +++ b/src/WindowController.Core/Models/AppSettings.cs @@ -22,4 +22,11 @@ public class AppSettings /// [JsonPropertyName("hotkeys")] public HotkeySettings Hotkeys { get; set; } = new(); + + /// + /// Profile Ids to apply when "既定のプロファイルを適用" is executed. + /// Multiple profiles can be set and all will be applied in order. + /// + [JsonPropertyName("defaultProfileIds")] + public List DefaultProfileIds { get; set; } = new(); } From 92b41e357f18af72d0636f47577f236001d57cb9 Mon Sep 17 00:00:00 2001 From: hu-ja-ja Date: Mon, 16 Feb 2026 17:24:18 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 54 ++++++------ src/WindowController.App/App.xaml.cs | 83 ++++++++++--------- .../ViewModels/SettingsViewModel.cs | 43 +++++++--- .../AppSettingsTests.cs | 18 ++++ 4 files changed, 125 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index a9fb240..cac4c2e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ 2. 任意のフォルダに展開 3. `WindowController.exe` を実行 -> .NET ランタイムは同梱済み(自己完結型)のため、別途インストール不要です。 +> .NET ランタイムは同梱済み(自己完結型)のため、別途インストール不要です。 > 二重起動は自動でブロックされます。 ## 動作環境 @@ -22,24 +22,24 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィンドウの配置を「プロファイル」として保存します。 -- 各ウィンドウの位置・サイズ・状態(最小化/最大化/通常)を記録 -- スナップ位置(左半分、右半分など)を自動検出 → モニタ構成が変わっても再計算 -- ブラウザの URL を UI Automation で取得(クリップボードやフォーカスに一切触れません) +- 各ウィンドウの位置・サイズ・状態(最小化/最大化/通常)を記録 +- スナップ位置(左半分、右半分など)を自動検出 → モニタ構成が変わっても再計算 +- ブラウザの URL を UI Automation で取得(クリップボードやフォーカスに一切触れません) -### プロファイル適用(配置のみ) +### プロファイル適用(配置のみ) 保存済みプロファイルを選んで「適用」すると、対応ウィンドウを検索して配置を復元します。 ### 一括起動 + 配置 -見つからないウィンドウはアプリを起動した上で配置します(最大 12 秒待機)。 +見つからないウィンドウはアプリを起動した上で配置します(最大 12 秒待機)。 ### 最小化 / 最大化の連動 プロファイル内のウィンドウを「グループ」として扱い、1 つを最小化・復元すると他も追従させます。 - 全体 ON/OFF + プロファイル個別 ON/OFF -- 曖昧なマッチは連動対象から自動除外(誤爆防止) +- 曖昧なマッチは連動対象から自動除外(誤爆防止) ## 使い方 @@ -51,33 +51,33 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ |---|---| | **GUI を開く** | メイン画面を表示 | | **設定…** | 設定画面を表示 | -| **プロファイルを適用(配置のみ)** | メイン画面を表示(適用は GUI でプロファイルを選択) | +| **既定のプロファイルを適用** | 既定のプロファイルを適用(設定で指定したプロファイルを順に適用) | | **終了** | アプリを終了 | ### ホットキー | キー | 動作 | |---|---| -| **Ctrl + Alt + W** | メイン画面を表示(既定。設定で変更/無効化可) | -| (任意) | プロファイル適用ホットキー(プロファイルごとに設定。配置のみ適用) | +| **Ctrl + Alt + W** | メイン画面を表示(既定。設定で変更/無効化可) | +| (任意) | プロファイル適用ホットキー(プロファイルごとに設定。配置のみ適用) | ### GUI | 領域 | 説明 | |---|---| -| 起動中ウィンドウ | チェックしたウィンドウをプロファイルに保存(Title/Exe/Class/URL/Browser Profile を表示) | +| 起動中ウィンドウ | チェックしたウィンドウをプロファイルに保存(Title/Exe/Class/URL/Browser Profile を表示) | | プロファイル保存 | プロファイル名入力 → 「プロファイル保存」 | -| 保存済みプロファイル | チェック=最小化/最大化を常時連動、名前編集(競合時は自動で連番付与) | -| ボタン | 適用(配置のみ)/ 一括起動+配置 / 削除 | -| 右クリック | 「モニターを選択して配置」(配置前に警告が出ることがあります) | -| 設定画面 | 連動機能全体 ON/OFF、起動時 GUI 表示、profiles.json 保存先、ホットキー(GUI表示/プロファイル適用) | +| 保存済みプロファイル | チェック=最小化/最大化を常時連動、名前編集(競合時は自動で連番付与) | +| ボタン | 適用(配置のみ)/ 一括起動+配置 / 削除 | +| 右クリック | 「モニターを選択して配置」(配置前に警告が出ることがあります) | +| 設定画面 | 連動機能全体 ON/OFF、起動時 GUI 表示、profiles.json 保存先、ホットキー(GUI表示/プロファイル適用) | ## データの保存場所 | ファイル | パス | |---|---| | 設定 | `%LOCALAPPDATA%\WindowController\appsettings.json` | -| プロファイル | `%LOCALAPPDATA%\WindowController\profiles.json` (GUI で変更可能) | +| プロファイル | `%LOCALAPPDATA%\WindowController\profiles.json` (GUI で変更可能) | | ログ | `%LOCALAPPDATA%\WindowController\window-controller.log` | > `profiles.json` の保存先は GUI 下部の「変更…」ボタンで任意のフォルダに変更できます。 @@ -85,11 +85,15 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ ### appsettings.json について -`appsettings.json` はアプリ全体の設定(`profiles.json` の保存先・ホットキー設定など)を保持します。 +`appsettings.json` はアプリ全体の設定(`profiles.json` の保存先・ホットキー設定など)を保持します。 ```json { "profilesPath": "C:\\Path\\To\\profiles.json", + "defaultProfileIds": [ + "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" + ], "hotkeys": { "showGui": { "key": "W", "ctrl": true, "alt": true, "shift": false, "win": false }, "profiles": { @@ -154,9 +158,9 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ } ``` -> `id` はプロファイルの内部識別子(UUID)です。旧バージョンで作成された `profiles.json` に `id` がない場合や、`id` が重複している場合は起動時に自動で再付与されます。 +> `id` はプロファイルの内部識別子(UUID)です。旧バージョンで作成された `profiles.json` に `id` がない場合や、`id` が重複している場合は起動時に自動で再付与されます。 -> JSON は `null` の項目を出力しません(例: `browser` / `snap` / `monitor` / `rectNormalized` / `desktopId` / `targetDesktopId` は状況により省略されます)。また `monitor.pixelWidth` / `monitor.pixelHeight` は 0 の場合は省略されます。 +> JSON は `null` の項目を出力しません(例: `browser` / `snap` / `monitor` / `rectNormalized` / `desktopId` / `targetDesktopId` は状況により省略されます)。また `monitor.pixelWidth` / `monitor.pixelHeight` は 0 の場合は省略されます。 > `desktopId` / `targetDesktopId` / `allowCrossDesktopApply` など仮想デスクトップ関連は将来拡張向けのフィールドで、GUI 上に「※整備中」と表示される機能は現状動作しない場合があります。 @@ -165,9 +169,9 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ | ブラウザ | 方式 | 備考 | |---|---|---| | Chrome / Edge / Brave / Vivaldi | UI Automation (ValuePattern) | 非侵襲 | -| Firefox / Floorp | UI Automation | 取得不可時は空(誤マッチ防止) | +| Firefox / Floorp | UI Automation | 取得不可時は空(誤マッチ防止) | -## ビルド(開発者向け) +## ビルド(開発者向け) ```bash # ビルド @@ -177,21 +181,21 @@ dotnet build # 実行 dotnet run --project WindowController.App -# 公開用ビルド(単一ファイル・自己完結型) +# 公開用ビルド(単一ファイル・自己完結型) dotnet publish WindowController.App -c Release # 出力先: src/WindowController.App/bin/Release/net8.0-windows/win-x64/publish/ ``` ## 配布用ZIPの生成 -配布用のZIP(中身は `WindowController.exe` のみ)をリポジトリ直下の `dist/` に出力します。 +配布用のZIP(中身は `WindowController.exe` のみ)をリポジトリ直下の `dist/` に出力します。 ```powershell pwsh scripts/package.ps1 ``` - 出力: `dist/WindowController-v-win-x64.zip` -- publish出力(確認用): `dist/publish/WindowController.exe` +- publish出力(確認用): `dist/publish/WindowController.exe` ### 必要環境 @@ -206,7 +210,7 @@ src/ WindowController.Core/ モデル、URL正規化、マッチングロジック WindowController.Win32/ P/Invoke、ウィンドウ列挙/配置、WinEventHook WindowController.Browser/ FlaUI.UIA3 による URL 取得 - WindowController.App/ WPF アプリ(トレイ、GUI、ViewModel) + WindowController.App/ WPF アプリ(トレイ、GUI、ViewModel) ``` ### 技術スタック diff --git a/src/WindowController.App/App.xaml.cs b/src/WindowController.App/App.xaml.cs index f494fa3..cc0c99f 100644 --- a/src/WindowController.App/App.xaml.cs +++ b/src/WindowController.App/App.xaml.cs @@ -242,59 +242,68 @@ private void ShowMainWindow() private async Task ApplyDefaultProfilesAsync() { - if (_profileApplier == null || _appSettingsStore == null || _profileStore == null) - return; - - var defaultIds = _appSettingsStore.Data.DefaultProfileIds; - if (defaultIds.Count == 0) + try { - var shouldPopup = _mainWindow == null || !_mainWindow.IsVisible || _mainWindow.WindowState == WindowState.Minimized; + if (_profileApplier == null || _appSettingsStore == null || _profileStore == null) + return; - if (shouldPopup) + var defaultIds = _appSettingsStore.Data.DefaultProfileIds; + if (defaultIds.Count == 0) { - var dlg = new DefaultProfilesMissingDialog(); + var shouldPopup = _mainWindow == null || !_mainWindow.IsVisible || _mainWindow.WindowState == WindowState.Minimized; - // WPF throws if Owner is set to a Window that has never been shown. - if (_mainWindow is { IsVisible: true }) + if (shouldPopup) { - dlg.Owner = _mainWindow; - dlg.WindowStartupLocation = WindowStartupLocation.CenterOwner; + var dlg = new DefaultProfilesMissingDialog(); + + // WPF throws if Owner is set to a Window that has never been shown. + if (_mainWindow is { IsVisible: true }) + { + dlg.Owner = _mainWindow; + dlg.WindowStartupLocation = WindowStartupLocation.CenterOwner; + } + else + { + dlg.WindowStartupLocation = WindowStartupLocation.CenterScreen; + } + + var openSettings = dlg.ShowDialog() == true; + if (openSettings) + ShowSettingsWindow(); } - else + else if (_viewModel != null) { - dlg.WindowStartupLocation = WindowStartupLocation.CenterScreen; + _viewModel.StatusText = "既定のプロファイルが設定されていません"; } + _log?.Information("No default profiles configured"); + return; + } - var openSettings = dlg.ShowDialog() == true; - if (openSettings) - ShowSettingsWindow(); + nint appHwnd = 0; + if (_mainWindow != null) + { + var helper = new WindowInteropHelper(_mainWindow); + appHwnd = helper.Handle; } - else if (_viewModel != null) + + var messages = new List(); + foreach (var profileId in defaultIds) { - _viewModel.StatusText = "既定のプロファイルが設定されていません"; + var result = await _profileApplier.ApplyByIdAsync(profileId, false, appHwnd); + var profile = _profileStore.FindById(profileId); + var name = profile?.Name ?? profileId; + messages.Add(result.ToStatusMessage(name)); } - _log?.Information("No default profiles configured"); - return; - } - nint appHwnd = 0; - if (_mainWindow != null) - { - var helper = new WindowInteropHelper(_mainWindow); - appHwnd = helper.Handle; + if (_viewModel != null) + _viewModel.StatusText = string.Join(" / ", messages); } - - var messages = new List(); - foreach (var profileId in defaultIds) + catch (Exception ex) { - var result = await _profileApplier.ApplyByIdAsync(profileId, false, appHwnd); - var profile = _profileStore.FindById(profileId); - var name = profile?.Name ?? profileId; - messages.Add(result.ToStatusMessage(name)); + _log?.Error(ex, "Failed to apply default profiles"); + if (_viewModel != null) + _viewModel.StatusText = "既定のプロファイルの適用に失敗しました"; } - - if (_viewModel != null) - _viewModel.StatusText = string.Join(" / ", messages); } private void ShowSettingsWindow() diff --git a/src/WindowController.App/ViewModels/SettingsViewModel.cs b/src/WindowController.App/ViewModels/SettingsViewModel.cs index d5f0828..4095459 100644 --- a/src/WindowController.App/ViewModels/SettingsViewModel.cs +++ b/src/WindowController.App/ViewModels/SettingsViewModel.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.IO; +using System.Linq; using System.Windows; using System.Windows.Input; using CommunityToolkit.Mvvm.ComponentModel; @@ -183,7 +184,20 @@ private void LoadProfileHotkeys() private void LoadDefaultProfiles() { DefaultProfiles.Clear(); + var defaultIds = _appSettingsStore.Data.DefaultProfileIds; + + // Remove IDs that no longer exist in profiles.json to avoid leaving junk in appsettings.json. + var existingIds = _profileStore.Data.Profiles.Select(p => p.Id).ToHashSet(StringComparer.Ordinal); + var missingIds = defaultIds.Where(id => !existingIds.Contains(id)).ToList(); + if (missingIds.Count > 0) + { + foreach (var id in missingIds) + defaultIds.Remove(id); + _appSettingsStore.Save(); + _log.Information("Removed {Count} missing default profile ids from appsettings.json", missingIds.Count); + } + foreach (var profile in _profileStore.Data.Profiles) { var item = new DefaultProfileItem @@ -195,6 +209,22 @@ private void LoadDefaultProfiles() item.PropertyChanged += DefaultProfileItem_PropertyChanged; DefaultProfiles.Add(item); } + + // Normalize apply order to match the UI display order. + PersistDefaultProfileIdsFromUiOrderIfChanged(); + } + + private void PersistDefaultProfileIdsFromUiOrderIfChanged() + { + var ids = _appSettingsStore.Data.DefaultProfileIds; + var normalized = DefaultProfiles.Where(p => p.IsDefault).Select(p => p.ProfileId).ToList(); + + if (ids.SequenceEqual(normalized, StringComparer.Ordinal)) + return; + + ids.Clear(); + ids.AddRange(normalized); + _appSettingsStore.Save(); } private void DefaultProfileItem_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) @@ -202,17 +232,8 @@ private void DefaultProfileItem_PropertyChanged(object? sender, System.Component if (e.PropertyName != nameof(DefaultProfileItem.IsDefault)) return; if (sender is not DefaultProfileItem item) return; - var ids = _appSettingsStore.Data.DefaultProfileIds; - if (item.IsDefault) - { - if (!ids.Contains(item.ProfileId)) - ids.Add(item.ProfileId); - } - else - { - ids.Remove(item.ProfileId); - } - _appSettingsStore.Save(); + // Rebuild IDs from the current UI order to keep apply order stable. + PersistDefaultProfileIdsFromUiOrderIfChanged(); StatusText = item.IsDefault ? $"「{item.ProfileName}」を既定プロファイルに追加しました" : $"「{item.ProfileName}」を既定プロファイルから解除しました"; diff --git a/src/WindowController.Core.Tests/AppSettingsTests.cs b/src/WindowController.Core.Tests/AppSettingsTests.cs index 2e08403..34c25ef 100644 --- a/src/WindowController.Core.Tests/AppSettingsTests.cs +++ b/src/WindowController.Core.Tests/AppSettingsTests.cs @@ -12,6 +12,8 @@ public void AppSettings_DefaultValues_AreCorrect() Assert.Equal("", settings.ProfilesPath); Assert.NotNull(settings.Hotkeys); + Assert.NotNull(settings.DefaultProfileIds); + Assert.Empty(settings.DefaultProfileIds); } [Fact] @@ -90,6 +92,19 @@ public void AppSettings_DeserializesWithMissingHotkeys_UsesDefaults() Assert.NotNull(settings.Hotkeys); // Default hotkey should be applied Assert.Equal("W", settings.Hotkeys.ShowGui.Key); + Assert.NotNull(settings.DefaultProfileIds); + Assert.Empty(settings.DefaultProfileIds); + } + + [Fact] + public void AppSettings_DeserializesWithMissingDefaultProfileIds_UsesEmptyList() + { + var json = """{ "profilesPath": "/some/path", "hotkeys": {} }"""; + var settings = JsonSerializer.Deserialize(json); + + Assert.NotNull(settings); + Assert.NotNull(settings!.DefaultProfileIds); + Assert.Empty(settings.DefaultProfileIds); } [Fact] @@ -108,6 +123,7 @@ public void AppSettings_SerializesAndDeserializesCorrectly() var original = new AppSettings { ProfilesPath = "/custom/path/profiles.json", + DefaultProfileIds = new List { "profile-a", "profile-b" }, Hotkeys = new HotkeySettings { ShowGui = new HotkeyBinding { Key = "G", Ctrl = true, Shift = true }, @@ -124,6 +140,8 @@ public void AppSettings_SerializesAndDeserializesCorrectly() Assert.NotNull(deserialized); Assert.Equal(original.ProfilesPath, deserialized!.ProfilesPath); + Assert.NotNull(deserialized.DefaultProfileIds); + Assert.Equal(original.DefaultProfileIds, deserialized.DefaultProfileIds); Assert.Equal("G", deserialized.Hotkeys.ShowGui.Key); Assert.True(deserialized.Hotkeys.ShowGui.Ctrl); Assert.True(deserialized.Hotkeys.ShowGui.Shift);