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
100 changes: 72 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
2. 任意のフォルダに展開
3. `WindowController.exe` を実行

> .NET ランタイムは同梱済み自己完結型のため、別途インストール不要です。
> .NET ランタイムは同梱済み(自己完結型)のため、別途インストール不要です。
> 二重起動は自動でブロックされます。

## 動作環境
Expand All @@ -22,24 +22,24 @@

GUI で起動中のウィンドウを一覧表示し、チェックしたウィンドウの配置を「プロファイル」として保存します。

- 各ウィンドウの位置・サイズ・状態最小化/最大化/通常を記録
- スナップ位置左半分、右半分などを自動検出 → モニタ構成が変わっても再計算
- ブラウザの URL を UI Automation で取得クリップボードやフォーカスに一切触れません
- 各ウィンドウの位置・サイズ・状態(最小化/最大化/通常)を記録
- スナップ位置(左半分、右半分など)を自動検出 → モニタ構成が変わっても再計算
- ブラウザの URL を UI Automation で取得(クリップボードやフォーカスに一切触れません)

### プロファイル適用配置のみ
### プロファイル適用(配置のみ)

保存済みプロファイルを選んで「適用」すると、対応ウィンドウを検索して配置を復元します。

### 一括起動 + 配置

見つからないウィンドウはアプリを起動した上で配置します最大 12 秒待機
見つからないウィンドウはアプリを起動した上で配置します(最大 12 秒待機)

### 最小化 / 最大化の連動

プロファイル内のウィンドウを「グループ」として扱い、1 つを最小化・復元すると他も追従させます。

- 全体 ON/OFF + プロファイル個別 ON/OFF
- 曖昧なマッチは連動対象から自動除外誤爆防止
- 曖昧なマッチは連動対象から自動除外(誤爆防止)

## 使い方

Expand All @@ -49,45 +49,72 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ

| メニュー | 動作 |
|---|---|
| **GUI を開く** | 設定画面を表示 |
| **プロファイルを適用 (配置のみ)** | GUI を表示 |
| **GUI を開く** | メイン画面を表示 |
| **設定…** | 設定画面を表示 |
| **既定のプロファイルを適用** | 既定のプロファイルを適用(設定で指定したプロファイルを順に適用) |
| **終了** | アプリを終了 |
Comment on lines +52 to 55

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README のトレイメニュー説明が実装と不一致です。実装では「既定のプロファイルを適用」ですが、README では「プロファイルを適用(配置のみ)」になっています。実際のメニュー名と動作(既定プロファイルを適用する)に合わせて更新してください。

Copilot uses AI. Check for mistakes.

### ホットキー

| キー | 動作 |
|---|---|
| **Ctrl + Alt + W** | GUI を開く |
| **Ctrl + Alt + W** | メイン画面を表示(既定。設定で変更/無効化可) |
| (任意) | プロファイル適用ホットキー(プロファイルごとに設定。配置のみ適用) |

### GUI

| 領域 | 説明 |
|---|---|
| 上段 | 起動中ウィンドウ一覧(チェックして保存対象を選択) |
| 中段 | プロファイル名入力 → 「チェックを保存」 |
| 下段 | 保存済みプロファイル一覧(チェック=連動 ON/OFF、名前ダブルクリックでリネーム) |
| ボタン | 適用(配置のみ)/ 一括起動+配置 / 削除 |
| 設定 | 連動機能全体 ON/OFF、起動時 GUI 表示、profiles.json 保存先変更 |
| 起動中ウィンドウ | チェックしたウィンドウをプロファイルに保存(Title/Exe/Class/URL/Browser Profile を表示) |
| プロファイル保存 | プロファイル名入力 → 「プロファイル保存」 |
| 保存済みプロファイル | チェック=最小化/最大化を常時連動、名前編集(競合時は自動で連番付与) |
| ボタン | 適用(配置のみ)/ 一括起動+配置 / 削除 |
| 右クリック | 「モニターを選択して配置」(配置前に警告が出ることがあります) |
| 設定画面 | 連動機能全体 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 下部の「変更…」ボタンで任意のフォルダに変更できます。
> 「既定に戻す」で上記デフォルトに戻ります。

### appsettings.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": {
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx": { "key": "1", "ctrl": true, "alt": false, "shift": false, "win": false }
}
}
}
```
Comment on lines +86 to +104

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

appsettings.json の例に今回追加した defaultProfileIds が含まれていないため、既定プロファイル機能の設定方法が読み手に伝わりません。README の JSON 例に defaultProfileIds(配列)を追記し、適用順序の扱いも併記してください。

Copilot uses AI. Check for mistakes.

### profiles.json スキーマ

```json
{
"version": 1,
"settings": {
"syncMinMax": 0,
"showGuiOnStartup": 1
"showGuiOnStartup": 1,
"aspectRatioWarnThreshold": 0.02,
"warnOnResolutionMismatch": true,
"warnOnMonitorMismatch": true,
"allowCrossDesktopApply": true
},
"profiles": [
{
Expand All @@ -96,38 +123,55 @@ 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"
}
]
}
]
}
```

> `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 取得

| ブラウザ | 方式 | 備考 |
|---|---|---|
| Chrome / Edge / Brave / Vivaldi | UI Automation (ValuePattern) | 非侵襲 |
| Firefox / Floorp | UI Automation | 取得不可時は空誤マッチ防止 |
| Firefox / Floorp | UI Automation | 取得不可時は空(誤マッチ防止) |

## ビルド開発者向け
## ビルド(開発者向け)

```bash
# ビルド
Expand All @@ -137,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<version>-win-x64.zip`
- publish出力確認用: `dist/publish/WindowController.exe`
- publish出力(確認用): `dist/publish/WindowController.exe`

### 必要環境

Expand All @@ -166,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)
```

### 技術スタック
Expand Down
71 changes: 68 additions & 3 deletions src/WindowController.App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -241,6 +240,72 @@ private void ShowMainWindow()
_mainWindow.Activate();
}

private async Task ApplyDefaultProfilesAsync()
{
try
{
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<string>();
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);
}
catch (Exception ex)
{
_log?.Error(ex, "Failed to apply default profiles");
if (_viewModel != null)
_viewModel.StatusText = "既定のプロファイルの適用に失敗しました";
}
}

private void ShowSettingsWindow()
{
if (_settingsWindow == null) return;
Expand Down
59 changes: 59 additions & 0 deletions src/WindowController.App/DefaultProfilesMissingDialog.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<ui:FluentWindow x:Class="WindowController.App.DefaultProfilesMissingDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="既定のプロファイル"
Width="520" SizeToContent="Height"
MinHeight="0"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize"
Topmost="True"
ShowInTaskbar="False"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>

<ui:TitleBar Grid.Row="0" Title="既定のプロファイル"
ShowMaximize="False" ShowMinimize="False"/>

<StackPanel Grid.Row="1" Margin="24,0,24,24">
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<ui:SymbolIcon Symbol="Info24" FontSize="22" Margin="0,0,8,0"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
<TextBlock Text="既定のプロファイルが設定されていません"
Style="{StaticResource SectionHeader}" Margin="0"/>
</StackPanel>

<TextBlock Text="設定画面の「既定のプロファイル」から適用するプロファイルを選択してください。"
Style="{StaticResource SectionDescription}"
TextWrapping="Wrap"
Margin="0,0,0,16"/>

<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
<ui:Button Click="OpenSettings_Click" Appearance="Info"
Margin="0,0,8,0" Padding="16,8">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="Settings24"/>
</ui:Button.Icon>
<ui:Button.Content>
<TextBlock Text="設定を開く"/>
</ui:Button.Content>
</ui:Button>

<ui:Button Click="Close_Click" Appearance="Secondary"
Padding="16,8">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="Dismiss24"/>
</ui:Button.Icon>
<ui:Button.Content>
<TextBlock Text="閉じる"/>
</ui:Button.Content>
</ui:Button>
</StackPanel>
</StackPanel>
</Grid>
</ui:FluentWindow>
26 changes: 26 additions & 0 deletions src/WindowController.App/DefaultProfilesMissingDialog.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Windows;
using Wpf.Ui.Controls;

namespace WindowController.App;

/// <summary>
/// Dialog shown when no default profiles are configured.
/// Returns <c>true</c> via DialogResult when the user chooses to open settings.
/// </summary>
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;
}
}
Loading