Repository: JocysCom/FocusLogger Branch: main Commit: bd497bb745a6 Files: 88 Total size: 826.0 KB Directory structure: gitextract_pkit4a3b/ ├── .ai/ │ ├── ReadMe.md │ ├── coding-guideline.instructions.md │ ├── instructions.md │ ├── repository-analysis.instructions.md │ └── skills/ │ └── ai-self-improvement/ │ ├── SKILL.md │ └── scripts/ │ └── Sync-AgentAssets.ps1 ├── .claude/ │ ├── coding-guideline.instructions.md │ ├── instructions.md │ ├── repository-analysis.instructions.md │ └── skills/ │ └── ai-self-improvement/ │ ├── SKILL.md │ └── scripts/ │ └── Sync-AgentAssets.ps1 ├── .editorconfig ├── .github/ │ ├── copilot-instructions.md │ ├── instructions/ │ │ ├── coding-guideline.instructions.md │ │ └── repository-analysis.instructions.md │ └── skills/ │ └── ai-self-improvement/ │ ├── SKILL.md │ └── scripts/ │ └── Sync-AgentAssets.ps1 ├── .gitignore ├── .markdownlint.json ├── Documents/ │ ├── App_1_Sign.ps1 │ ├── App_2_Zip.ps1 │ ├── Take_Screenshot.ps1 │ └── Take_Screenshot.ps1.cs ├── FocusLogger/ │ ├── App.xaml │ ├── App.xaml.cs │ ├── AssemblyInfo.cs │ ├── Common/ │ │ ├── DataItem.cs │ │ ├── DataItemType.cs │ │ └── NativeMethods.cs │ ├── Controls/ │ │ ├── DataListControl.xaml │ │ └── DataListControl.xaml.cs │ ├── JocysCom/ │ │ ├── Collections/ │ │ │ └── CollectionsHelper.cs │ │ ├── Common/ │ │ │ └── Helper.cs │ │ ├── ComponentModel/ │ │ │ ├── BindingListInvoked.cs │ │ │ ├── NotifyPropertyChanged.cs │ │ │ ├── PropertyComparer.cs │ │ │ └── SortableBindingList.cs │ │ ├── Configuration/ │ │ │ ├── Arguments.cs │ │ │ ├── AssemblyInfo.cs │ │ │ ├── ISettingsData.cs │ │ │ ├── ISettingsFileItem.cs │ │ │ ├── ISettingsItem.cs │ │ │ ├── ISettingsListFileItem.cs │ │ │ ├── SettingsData.cs │ │ │ ├── SettingsHelper.cs │ │ │ ├── SettingsItem.cs │ │ │ └── SettingsParser.cs │ │ ├── Controls/ │ │ │ ├── ControlsHelper.WPF.UseWindowsForms.cs │ │ │ ├── ControlsHelper.WPF.cs │ │ │ ├── ControlsHelper.cs │ │ │ ├── InfoControl.xaml │ │ │ ├── InfoControl.xaml.cs │ │ │ ├── InfoHelpProvider.cs │ │ │ ├── InitHelper.cs │ │ │ ├── ItemFormattingConverter.cs │ │ │ ├── MessageBoxWindow.xaml │ │ │ ├── MessageBoxWindow.xaml.cs │ │ │ ├── TabIndexConverter.cs │ │ │ ├── Themes/ │ │ │ │ ├── Convert_SVG_to_XAML.ps1 │ │ │ │ ├── Default.xaml │ │ │ │ ├── Default_MultiReplace.ps1 │ │ │ │ ├── Icons.xaml │ │ │ │ └── Icons.xaml.cs │ │ │ └── ToolStripBorderlessRenderer.cs │ │ ├── Data/ │ │ │ └── SqlHelper.Types.cs │ │ ├── IO/ │ │ │ └── PathHelper.cs │ │ ├── MakeLinks_Ref.ps1 │ │ ├── Runtime/ │ │ │ ├── RuntimeHelper.cs │ │ │ └── Serializer.cs │ │ └── Text/ │ │ └── Helper.cs │ ├── JocysCom.FocusLogger.csproj │ ├── MainWindow.xaml │ ├── MainWindow.xaml.cs │ └── Resources/ │ ├── AiAnalysisPrompt.md │ └── Icons/ │ ├── Convert_SVG_to_XAML.ps1 │ ├── IconExperience.License.txt │ ├── Icons_Default.xaml │ └── Icons_Default.xaml.cs ├── FocusLogger.Tests/ │ ├── CsvExportTests.cs │ ├── JocysCom.FocusLogger.Tests.csproj │ └── UIAutomationTests.cs ├── JocysCom.FocusLogger.slnx ├── LICENSE ├── README.md ├── Resources/ │ └── ZipFiles.ps1 ├── SECURITY.md ├── Settings.XamlStyler └── Solution_Cleanup.ps1 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .ai/ReadMe.md ================================================ ## Files - `developer-info.md` - Developer information about the solution, which will be used when creating the repository-analysis.instructions.md file. - `instructions.md` - Main AI Agent System Message instructions used by AI Agents, like CLINE or CoPilot. - `repository-analysis.instructions.md` - Autogenerated Repository Analysis file that is supplied to the AI Agent as a System message after instructions.md. - `repository-analysis.prompt.md` - User Message prompt that instructs AI to create repository-analysis.instructions.md. - `Update-AgentInstructions.ps1` - Sets all `*instruction.md` files as AI agent system message instruction files. Used by AI agents, like CLINE or Copilot. ## Custom instructions: GitHub Copilot With GitHub Copilot, you can receive chat responses tailored to your team'ss workflow, preferred tools, and project specifics. Instead of adding this contextual detail to each chat query, you can create a file that supplies this information automatically. While this additional context won't appear in the chat, it is available to GitHub Copilot, allowing it to generate more accurate and relevant responses. **How to Enable Custom Instructions** Enable the feature via Tools > Options > GitHub > Copilot > and check (Preview) Enable custom instructions to be loaded from .github/copilot-instructions.md files and added to requests. Add copilot-instruction.md in the root of your respository inside the .github file, create the file if it doesn't already exist. GitHub Copilot Enable Custom Instructions Learn more about creating custom instructions: https://docs.github.com/en/enterprise-cloud@latest/copilot/customizing-copilot/adding-custom-instructions-for-github-copilot ## Custom instructions: CLINE All CLINE instructions are stored inside the .clinerules\ folder. All \*.md files will be loaded as system messages. ## Custom instructions: OpenAI Codex Instructions are stored in `%USERPROFILE%\.codex\instructions.md` file. ## User Instructions to create `repository-analysis.md` Note: Switch AI agent to `Plan` mode before submitting this message. This will yield better results. ### LLM Selection Guideline If your deployment environment allows, prefer a newer, higher-capacity model—such as Anthropic Claude Sonnet 4 or Google Gemini 2.5 Pro - instead of the current OpenAI GPT 4 lineup. These frontier models typically deliver stronger reasoning, broader context windows, and more consistent output quality on complex tasks. ### Install the Mermaid preview plug-in 1. Open Extensions (Ctrl + Shift + X). 2. Search: Markdown Preview Mermaid Support (publisher: Matt Bierner). 3. Click Install – or hit Ctrl + P. ### Open Preview 1. Open the menu on the tab header of the file. 2. Click "Open Preview" – or hit Ctrl + Shift + V. ### Re Open Preview 1. Open the menu on the tab header of the file. 2. Click "Reopen Editor With..." 3. Click "Markdown Preview (Built-In)" ================================================ FILE: .ai/coding-guideline.instructions.md ================================================ # Coding Guidelines - If the qdrant-mcp-server is running, use it for all permanent memory operations (e.g. storing user information). - After making changes, ALWAYS start a new server for testing. - Kill all existing related servers from previous testing before starting a new server. - Prefer the simplest viable solution; avoid over-engineering. - Do not add broad try/catch or wrapper layers unless required by a failing test or explicit requirement; if you catch, rethrow to preserve the stack. - Before writing new code, actively look for existing utilities or functions that can be reused instead of duplicated. - New helper methods or classes must be justified with a clear, documented need for functionality that is unavailable elsewhere in the codebase. - Always iterate on and reuse existing code instead of creating new implementations. - Avoid adding layers of abstraction that do not deliver clear value. - Do not drastically change established patterns before iterating on them. - No duplication / SSOT: update or move existing code instead of adding parallel implementations. If you introduce a replacement, remove the old one **in the same change**. - Write code that accounts for different environments (dev, test, and prod). - Only modify what is explicitly requested or clearly necessary; do **not** create new files or modules unless explicitly requested. - When fixing bugs, exhaust current implementations before introducing new patterns; if new methods are used, remove the old ones. - Keep the codebase clean and organized. - Avoid one-off scripts unless absolutely necessary. - Use mocks only for tests, not for dev or prod. - Never add stubbing or fake data in dev or prod environments. - Never overwrite the .env file without explicit confirmation. - Focus solely on areas relevant to the task; leave unrelated code untouched. - Write thorough tests for all major functionality. - Avoid major changes to the existing architecture unless explicitly instructed. - Always consider the impact on other methods and areas of the code. - Prefer to wrap long lines for better readability. - Preserve existing formatting; limit formatting to lines you changed and match surrounding style. Also remove any unused imports/usings or dead code **introduced by your edits**. - Stability primitives (repeatability > cleverness): when the repo provides an established way to perform an operation (repo-owned script, documented command snippet, standard PS1 under .ai/Scripts), treat it as the single source of truth. Use it verbatim instead of synthesizing “equivalent” commands. Only deviate when explicitly asked or when the primitive is proven broken in this environment (and then fix the primitive, not invent a parallel path). - No code file that you **create or modify** may exceed **6000 tokens (~24 KB)** once your changes are applied. - If your changes alone would push the file past this limit, either trim the change or ask for explicit permission to refactor; do **not** alter unrelated code solely to meet the limit. - Existing oversized files are left untouched unless the user explicitly requests a refactor. ## Source Control Conventions Naming conventions for issues, branches, and pull requests. ### Categories | Category | Use for | |----------|---------| | `FEAT` | New features | | `FIX` | Bug fixes | | `TECH` | Infrastructure, dependencies, refactoring | | `DOCS` | Documentation | ### Naming Patterns | Item | Pattern | Example | |------------|---------|---------| | **Issue** | `{CATEGORY}: {Description}` | `FEAT: Download logs to CSV` | | **Branch** | `{CATEGORY}-{issue#}-{lowercase-dashed-name}` | `FEAT-21-download-logs-to-csv` | | **PR** | `PR: #{issue#}: {CATEGORY}: {Description}` | `PR: #21: FEAT: Download logs to CSV` | - Branch names: lowercase, words separated by dashes, non-ASCII replaced with a single dash, derived from issue title but may be shortened. - PR body must reference the issue with `Closes #{issue#}`. - Merge via PR only — no direct pushes to `main` or `master`. - Feature branches are always created from `main` or `master`. Never merge one feature branch into another — only merge `main` if you need to catch up. - Always create branches from latest origin/main or origin/main, never merge sub-branches, confirm before risky git ops. - Do not add Co-Authored-By {AI model}/{AI company} lines to commits. - Never close issues before a release is published with the fix. Use the following guidelines: 1. Doc Comment Enhancement for IntelliSense - Replace or augment simple comments with relevant doc comment syntax that is supported by IntelliSense as needed. - Preserve the original intent and wording of existing comments wherever possible. 2. Code Layout for Clarity - Place the most important or user-editable sections at the top if logically appropriate. - Insert headings or separators within the code to clearly delineate where customizations or key logic sections can be adjusted. 3. No Extraneous Code Comments - Do not include "one-off" or user-directed commentary in the code. - Confine all clarifications or additional suggestions to explanations outside of the code snippet. 4. Avoid Outdated or Deprecated Methods - Refrain from introducing or relying on obsolete or deprecated methods and libraries. - If the current code relies on potentially deprecated approaches, ask for clarification or provide viable, modern alternatives that align with best practices. 5. Testing and Validation - Suggest running unit tests or simulations on the modified segments to confirm that the changes fix the issue without impacting overall functionality. - Ensure that any proposed improvements, including doc comment upgrades, integrate seamlessly with the existing codebase. - After all code modifications, navigate to the affected project directory and build C# then Angular to confirm the application compiles without errors: cd {PROJECT} && dotnet build {PROJECT}.csproj cd {PROJECT}/ClientApp && ng build - Run relevant unit tests if code changes affect core logic. - If the developer certificate is not trusted, then execute: dotnet dev-certs https --trust - To launch project use: dotnet watch run --project {PROJECT}/{PROJECT}.csproj --launch-profile "{PROJECT} (NG Build)" 6. Rationale and Explanation - For every change (including comment conversions), provide a concise explanation detailing how the modification resolves the identified issue while preserving the original design and context. - Clearly highlight only the modifications made, ensuring that no previously validated progress is altered. - NOTE: Summarize reasoning for the user, but do NOT expose full chain-of-thought. Keep internal deliberations internal; surface only the concise rationale needed to justify each change. 7. Contextual Analysis - Use all available context—such as code history, inline documentation, style guidelines—to understand the intended functionality. - When inspecting an existing file for understanding, prefer reading the whole file in a single `read_file` call when it comfortably fits in context; switch to targeted slices only when the file is too large, the tool truncates it, or a specific anchor line is already known. - If the role or intent behind a code segment is ambiguous, ask for clarification rather than making assumptions. 8. Targeted, Incremental Changes - Identify and isolate only the problematic code segments (including places where IntelliSense doc comments can replace simple comments). - Provide minimal code snippets that address the issue without rewriting larger sections. - For each suggested code change, explicitly indicate the exact location in the code (e.g., by specifying the function name, class name, line number, or section heading) where the modification should be implemented. 9. Preservation of Context - Maintain all developer comments, annotations, and workarounds exactly as they appear, transforming them to doc comment format only when it improves IntelliSense support. - Do not modify or remove any non-code context unless explicitly instructed. - Avoid introducing new, irrelevant comments in the code. 10. Launching {PROJECT} Correctly: - Navigate to the {PROJECT} project folder. - Run the following command to launch the project with live reload and proper debugging configuration: dotnet watch run --launch-profile "{PROJECT} (NG Build)" --project {PROJECT}/{PROJECT}.csproj - This command will start the {PROJECT} project on the designated debugging session URL. - Ensure that any previous {PROJECT} instances are terminated before running this command. ================================================ FILE: .ai/instructions.md ================================================ ## Role Your role is to analyze and improve code by making only localized, targeted changes. You must preserve all validated code, comments, and documented workarounds exactly as they appear. Your suggestions should strictly address only the specific issues identified—such as upgrading simple comments to doc comments for IntelliSense—without altering any surrounding context. Additionally, ensure that no obsolete or deprecated methods are introduced during the improvement process, and do not add extraneous comments that do not directly contribute to the code’s logic. Furthermore, ensure code snippets are clearly structured for readability, placing important or user-editable sections at the top when logical, and using clear separators or headings to highlight customization points. Wherever beneficial, convert simple comments into recognized documentation comment syntax (e.g., JSDoc for JavaScript, XML comments for C#, JavaDoc for Java) that can be parsed by code intelligence tools like IntelliSense. Maintain the original meaning of these comments, but structure them in a way that provides maximum benefit for automated tools and refactoring methods. Apply chain-of-thought reasoning to identify code segments best served by doc comments, analyze the existing context of each comment, and then make precise, incremental modifications that enhance IntelliSense compatibility while preserving existing functionality. ## Output Wrap any and all code—including regular code snippets, inline code segments, outputs, pseudocode, or any text that represents code—in Markdown code blocks with a language identifier (e.g., ```typescript, ```powershell). ================================================ FILE: .ai/repository-analysis.instructions.md ================================================ # Repository Analysis ## 1. Repository Overview This document provides a factual reference for the Jocys.com FocusLogger repository, aimed at developers and AI coding agents working on the codebase. **FocusLogger** is a Windows desktop utility that monitors and logs which process or program takes window focus. It targets users (especially gamers and power users) who experience unexpected focus stealing — where a background process briefly grabs foreground focus, interrupting gameplay or work. The tool logs every focus change with timestamps, process details, window class names, and focus-state flags, allowing users to identify the culprit. - **Repository:** https://github.com/JocysCom/FocusLogger - **License:** GNU General Public License v3.0 - **Current version:** 1.2.6 - **Target platform:** Windows 10+ with .NET 8.0 - **Primary audiences:** Gamers, power users, IT support personnel diagnosing focus-stealing issues. ## 2. Top-Level Structure This section maps every top-level directory and file to help navigate the repository quickly. | Path | Purpose | |------|---------| | `FocusLogger/` | Main application project (WPF, .NET 8.0). Contains all app source code, shared library, and resources. | | `FocusLogger.Tests/` | MSTest test project. Unit tests for CSV export and UI automation tests. | | `Documents/` | Release engineering: signing scripts, zip packaging scripts, screenshot tooling, and pre-built release files. | | `Resources/` | Solution-level shared scripts (currently `ZipFiles.ps1` for checksum-aware zip packaging). | | `.ai/` | AI agent instructions, coding guidelines, repository analysis, and skills. | | `JocysCom.FocusLogger.slnx` | Solution file (XML-based `.slnx` format) referencing the two projects. | | `README.md` | Project overview, download link, system requirements, screenshot. | | `LICENSE` | GPLv3 license text. | | `SECURITY.md` | Security vulnerability reporting policy (support@jocys.com). | | `Settings.XamlStyler` | XamlStyler configuration for consistent XAML formatting. | | `Solution_Cleanup.ps1` | PowerShell script for cleaning build artifacts. | ## 3. Technology Stack & Key Dependencies This section lists verified technologies and versions drawn from project files. | Technology | Version / Detail | Evidence | |------------|-----------------|----------| | .NET | 8.0 (`net8.0-windows`) | `JocysCom.FocusLogger.csproj` TargetFramework | | C# | Implicit (SDK default for .NET 8) | SDK-style project | | WPF | `true` | csproj | | Windows Forms interop | `true` | csproj — used for P/Invoke helpers and DPI awareness | | MSTest | v3.x (`MSTest.TestFramework 3.*`, `MSTest.TestAdapter 3.*`) | Test csproj PackageReference | | Microsoft.NET.Test.Sdk | 17.x | Test csproj PackageReference | | Windows API (user32.dll) | P/Invoke | `NativeMethods.cs` | | PowerShell | Scripts for build, sign, zip, cleanup | `Documents/`, `Resources/`, root | | XamlStyler | Config present | `Settings.XamlStyler` | **No NuGet package dependencies** in the main application project — all functionality comes from .NET SDK and the embedded `JocysCom.ClassLibrary`. ## 4. Architecture & Runtime Model This section describes how the application is structured and how it operates at runtime. FocusLogger is a **single-executable WPF desktop application** that polls Windows API functions to detect focus changes. It does not persist log data between sessions (in-memory only) but provides CSV export for offline analysis. ### Architectural layers ```mermaid graph TD subgraph UI["UI Layer (WPF)"] App["App.xaml.cs
Entry point, DPI aware"] MW["MainWindow.xaml.cs
Main window frame"] DLC["DataListControl.xaml.cs
Core logging UI + logic"] end subgraph Core["Core Logic"] DI["DataItem.cs
Log entry model"] DIT["DataItemType.cs
Entry type enum"] NM["NativeMethods.cs
P/Invoke declarations"] CSV["CSV Export
BuildCsvContent, CsvEscape"] end subgraph Shared["JocysCom.ClassLibrary (embedded)"] Config["Configuration
SettingsData, SettingsItem, AssemblyInfo"] CompModel["ComponentModel
SortableBindingList, BindingListInvoked"] Controls["Controls
ControlsHelper, ItemFormattingConverter,
InfoControl, MessageBoxWindow"] Other["Collections, IO, Text, Runtime, Data"] end subgraph WinAPI["Windows OS"] User32["user32.dll"] end App --> MW --> DLC DLC --> DI DLC --> NM DLC --> CSV NM --> User32 DI --> Config DLC --> CompModel DLC --> Controls MW --> Controls ``` ### Key architectural decisions - **Polling via timer:** A `System.Timers.Timer` with 1ms interval (non-auto-reset) continuously polls `GetActiveWindow()` and `GetForegroundWindow()`. Duplicate events are suppressed via `DataItem.IsSame()`. - **Thread safety:** Timer fires on a thread-pool thread; UI updates are marshalled via `ControlsHelper.BeginInvoke()`. A `lock(AddLock)` synchronizes the polling logic. - **Embedded shared library:** `JocysCom.ClassLibrary` files are included directly in `FocusLogger/JocysCom/` rather than as a compiled DLL or NuGet package. - **No MVVM framework:** Code-behind pattern with data binding. `DataListControl.xaml.cs` contains both view-model-like logic and model interaction. ## 5. Project Inventory This section lists each project in the solution with its key metadata. ### 5.1 JocysCom.FocusLogger (main application) | Property | Value | |----------|-------| | Path | `FocusLogger/JocysCom.FocusLogger.csproj` | | Output type | `WinExe` | | Target framework | `net8.0-windows` | | Assembly name | `JocysCom.FocusLogger` | | Description | Find out which process or program is taking the window focus. In game, mouse and keyboard could temporarily stop responding if another program takes the focus. This tool could help diagnose which program is stealing the focus. | | Version | 1.2.6 | | NuGet dependencies | None | | Embedded resources | `Resources/BuildDate.txt` (auto-generated), `Resources/AiAnalysisPrompt.md` | **Source structure:** | Directory | Contents | |-----------|----------| | `FocusLogger/` (root) | `App.xaml(.cs)`, `MainWindow.xaml(.cs)`, `AssemblyInfo.cs`, `App.ico` | | `FocusLogger/Common/` | `DataItem.cs`, `DataItemType.cs`, `NativeMethods.cs` | | `FocusLogger/Controls/` | `DataListControl.xaml(.cs)` — core logging control | | `FocusLogger/JocysCom/` | Embedded `JocysCom.ClassLibrary` (~30 files across Collections, Common, ComponentModel, Configuration, Controls, Data, IO, Runtime, Text) | | `FocusLogger/Resources/` | `AiAnalysisPrompt.md`, `BuildDate.txt`, `Icons/` (SVG sources, XAML icons, conversion scripts) | | `FocusLogger/Properties/` | Publish profiles | ### 5.2 JocysCom.FocusLogger.Tests (test project) | Property | Value | |----------|-------| | Path | `FocusLogger.Tests/JocysCom.FocusLogger.Tests.csproj` | | Target framework | `net8.0-windows` | | Test framework | MSTest v3.x | | Project reference | `FocusLogger/JocysCom.FocusLogger.csproj` | **Test files:** | File | Purpose | |------|---------| | `CsvExportTests.cs` | Unit tests for `CsvEscape` and `BuildCsvContent` methods | | `UIAutomationTests.cs` | UI automation tests using `System.Windows.Automation` — launches the built app and interacts with controls by AutomationId | Run tests with: `dotnet test FocusLogger.Tests/JocysCom.FocusLogger.Tests.csproj` ## 6. Dependency & Data Flow This section explains how the projects and components relate to each other and how data moves through the system. ### Project dependency graph ```mermaid graph LR Tests["FocusLogger.Tests
(MSTest v3)"] -->|ProjectReference| App["JocysCom.FocusLogger
(WPF App)"] App -->|embedded source| Shared["JocysCom.ClassLibrary
(in FocusLogger/JocysCom/)"] App -->|P/Invoke| WinAPI["user32.dll"] ``` ### Runtime data flow 1. `System.Timers.Timer` fires (1ms interval, non-auto-reset). 2. `DataListControl.UpdateInfo()` acquires `AddLock`. 3. Calls `NativeMethods.GetActiveWindow()` and `NativeMethods.GetForegroundWindow()`. 4. For each handle, `GetItemFromHandle()` creates a `DataItem` with timestamp, focus flags (mouse/keyboard/caret), window title, and window class name. 5. `IsSame()` checks if the event differs from the previous one; if not, it is skipped. 6. `UpdateFromProcess()` enriches the `DataItem` with process name and path (with error handling for restricted processes). 7. The item is inserted at position 0 of `SortableBindingList` via `ControlsHelper.BeginInvoke()` (UI thread dispatch). 8. The WPF `DataGrid` updates via data binding. `ItemFormattingConverter` translates boolean flags to icons. ### CSV export flow 1. User clicks "Save CSV" button. 2. `SaveFileDialog` prompts for file location. 3. `BuildCsvContent()` iterates all `DataItem` entries, writing CSV with headers: Date, PID, Process Name, Active, Mouse, Keyboard, Caret, Window Title, Window Class, Path. 4. File is written as UTF-8. 5. "Explore" button opens the saved file location in Explorer. 6. "AI Prompt Example" button shows the embedded `AiAnalysisPrompt.md` in a `MessageBoxWindow` for users to copy and paste into an AI assistant along with their CSV. ## 7. Build, Test, CI/CD & Operational Workflows This section documents how the project is built, tested, and released based on repository evidence. ### Build ```bash dotnet build JocysCom.FocusLogger.slnx ``` - **Pre-build event:** Generates `Resources/BuildDate.txt` with the current ISO 8601 timestamp via PowerShell. - **Output:** Single `JocysCom.FocusLogger.exe` in `bin/{Configuration}/net8.0-windows/`. - **Debug configuration:** Embedded PDB symbols (`DebugType: embedded`). ### Test ```bash dotnet test FocusLogger.Tests/JocysCom.FocusLogger.Tests.csproj ``` - **Framework:** MSTest v3.x with Microsoft.NET.Test.Sdk 17.x. - **Unit tests:** `CsvExportTests` — validates CSV escaping and content generation. - **UI automation tests:** `UIAutomationTests` — launches the built application and interacts via `System.Windows.Automation`. Requires a prior build of the main project. ### Release / packaging scripts | Script | Purpose | |--------|---------| | `Documents/App_1_Sign.ps1` | Code-signs the application executable. | | `Documents/App_2_Zip.ps1` | Packages the signed executable into a release ZIP. | | `Resources/ZipFiles.ps1` | Shared utility for checksum-aware ZIP creation (compares source/dest checksums before rebuilding). | | `Documents/Take_Screenshot.ps1` | Captures application screenshot for documentation. | | `Documents/Take_Screenshot.ps1.cs` | C# helper compiled by the screenshot script. | | `Solution_Cleanup.ps1` | Cleans `bin/`, `obj/`, and other build artifacts. | ### Icon workflow SVG icon sources are stored in `FocusLogger/Resources/Icons/Icons_Default/`. The script `Convert_SVG_to_XAML.ps1` converts them to XAML resource dictionaries (`Icons_Default.xaml`). ### CI/CD No CI/CD workflow files were found under `.github/workflows/`. Builds and releases appear to be performed locally. ## 8. Documentation Map This section identifies where documentation lives in the repository. | Location | Audience | Content | |----------|----------|---------| | `README.md` | End users, contributors | Project overview, download link, system requirements, screenshot | | `SECURITY.md` | Security researchers | Vulnerability reporting policy | | `LICENSE` | All | GPLv3 full text | | `.ai/ReadMe.md` | AI agents, developers | Explains the purpose of each file in the `.ai/` directory and how custom instructions work for Copilot, CLINE, and Codex | | `.ai/instructions.md` | AI agents | Role definition and output formatting rules for AI-assisted edits | | `.ai/coding-guideline.instructions.md` | AI agents | Detailed coding guidelines, source control conventions (branch/PR naming), testing workflow, and constraints for AI agents | | `.ai/repository-analysis.instructions.md` | AI agents, developers | This file — comprehensive repository reference | | `.ai/skills/` | AI agents | Skill definitions (e.g., `ai-self-improvement`) for agent-assisted workflows | | `FocusLogger/Resources/AiAnalysisPrompt.md` | End users | Prompt template for users to paste into AI assistants alongside exported CSV logs | | `Documents/Images/` | README, users | Application screenshot | | `Settings.XamlStyler` | Developers | XamlStyler formatting configuration | ### Documentation taxonomy ```mermaid graph TD subgraph EndUsers["End-User Documentation"] README["README.md
Overview, download, requirements"] SECURITY["SECURITY.md
Vulnerability reporting"] LICENSE["LICENSE
GPLv3"] AiPrompt["FocusLogger/Resources/
AiAnalysisPrompt.md
Prompt template for CSV analysis"] Screenshot["Documents/Images/
Application screenshot"] end subgraph AIAgents["AI Agent Instructions"] AiReadMe[".ai/ReadMe.md
Directory guide"] AiInstr[".ai/instructions.md
Role & output rules"] AiCoding[".ai/coding-guideline
.instructions.md
Coding & SCM conventions"] AiRepo[".ai/repository-analysis
.instructions.md
This file"] AiSkills[".ai/skills/
Agent skill definitions"] end subgraph DevConfig["Developer Configuration"] XamlStyler["Settings.XamlStyler
XAML formatting"] end ``` ## 9. AI-Agent-Relevant Conventions and Constraints This section captures rules and patterns that materially affect automated edits. The full set of coding and source-control conventions is defined in `.ai/coding-guideline.instructions.md`; the highlights below are the items most likely to cause mistakes if overlooked. 1. **Coding style:** Follow Microsoft C# conventions. PascalCase for public members, camelCase for locals. Some private fields use `_PascalCase` (e.g., `_Date`). Preserve existing naming patterns in each file. Prefer the simplest viable solution; avoid over-engineering. 2. **Doc comments:** Per `.ai/instructions.md`, convert simple comments to XML documentation comments where beneficial for IntelliSense. Do not alter surrounding code when doing so. 3. **Shared library files (`FocusLogger/JocysCom/`):** These are embedded from a shared `JocysCom.ClassLibrary`. Exercise caution when editing — changes here may diverge from the upstream library. 4. **XAML formatting:** The repository uses XamlStyler (see `Settings.XamlStyler`). XAML edits should conform to the configured style. 5. **No NuGet packages in the main app:** All dependencies are framework-provided or embedded source. Do not introduce NuGet package dependencies without explicit approval. 6. **Test project uses MSTest v3:** New tests should follow MSTest v3 patterns (`[TestClass]`, `[TestMethod]`, `Assert.*`). 7. **UI automation tests depend on a built executable:** `UIAutomationTests` locate the app at a relative path from the test output. Building the main project before running these tests is required. 8. **Pre-build event:** The csproj generates `Resources/BuildDate.txt` via PowerShell. This file should not be manually edited or committed. 9. **Solution format:** Uses `.slnx` (XML-based solution format), not the older `.sln` text format. 10. **No CI/CD pipelines:** All build and release steps are manual/local. Scripts in `Documents/` handle signing and packaging. 11. **File size limit:** No code file that is created or modified may exceed 6000 tokens (~24 KB). 12. **Source control conventions:** Issues use `{CATEGORY}: {Description}` (FEAT, FIX, TECH, DOCS). Branches use `{CATEGORY}-{issue#}-{lowercase-dashed-name}`. PRs use `PR: #{issue#}: {CATEGORY}: {Description}` and must reference the issue with `Closes #{issue#}`. Merge via PR only — no direct pushes to `main`. 13. **No Co-Authored-By lines:** Do not add `Co-Authored-By` AI model/company lines to commits. 14. **Issue closure:** Never close issues before a release is published with the fix. 15. **No duplication / SSOT:** Update or move existing code instead of adding parallel implementations. If introducing a replacement, remove the old one in the same change. 16. **Stability primitives:** When the repo provides an established script or command, use it verbatim. Only deviate when it is proven broken, and then fix the primitive rather than inventing a parallel path. ================================================ FILE: .ai/skills/ai-self-improvement/SKILL.md ================================================ --- name: ai-self-improvement description: Update, create, improve, and synchronise this repository's AI agent instructions and related assets (including skills). Use when the user asks to create or edit a skill/SKILL.md, modify the agent's own instructions/processes, restructure instruction governance, migrate instruction content into skills, or run/adjust the sync pipeline that publishes `.ai/` sources into agent-specific folders. Load this skill before writing any SKILL.md, .instructions.md, or touching any skills/ folder (.ai/, .claude/, .roo/, .github/). It tells you the correct location (.ai/) and the sync step, so files end up in the right place. --- # AI Self-Improvement (Instructions + Skills) ## Critical: `.ai/` is the Primary Source for ALL Agents The `.ai/` folder is the **single source of truth** for all AI agent configurations in this repository. This applies to: - **CLINE / Roo Code** — synced to `.roo/rules/` and `.roo/skills/` - **GitHub Copilot** — synced to `.github/copilot-instructions.md` - **OpenAI Codex / AGENTS.md** — synced to `AGENTS.md` at repo root - **Claude Code** — synced to `.claude/*.instructions.md` and `.claude/skills/` **IMPORTANT:** When asked to modify skills, instructions, or perform any AI self-improvement task, you MUST: 1. Locate the source file under `.ai/` (not the agent-specific output) 2. Make changes to the `.ai/` source 3. Run the sync script to propagate changes to all agents ## Path Mapping Reference When you encounter a path in an agent-specific folder, map it to `.ai/`: | Agent-Specific Path | Source Path (Edit Here) | |---------------------|------------------------| | `.roo/rules/*.md` | `.ai/*.instructions.md` | | `.roo/skills//SKILL.md` | `.ai/skills//SKILL.md` | | `.github/copilot-instructions.md` | `.ai/instructions.md` (generated) | | `AGENTS.md` | `.ai/instructions.md` (generated) | | `.claude/*.instructions.md` | `.ai/*.instructions.md` | | `.claude/skills//SKILL.md` | `.ai/skills//SKILL.md` | **Example:** If asked to update `.roo/skills/ai-self-improvement/SKILL.md`, you must edit `.ai/skills/ai-self-improvement/SKILL.md` instead. ## Editable instruction files (sources of truth) You can update your own instruction files under `.ai/`: - `.ai/instructions.md` — the main system instructions file - `.ai/*instructions.md` — additional instruction files (auto-included) - `.ai/*instructions-detail.md` — detailed instruction files (read only when needed) - `.ai/skills//SKILL.md` — skill definition files ## Workflow 1. Treat `.ai/` as the **single source of truth** for agent instructions **and skills**. 2. When creating or migrating a skill, create/update it under `.ai/skills/`. 3. Make instruction changes in `.ai/instructions.md` and related `*.instructions.md` / `*.instructions-detail.md` files. 4. Do **not** edit generated outputs directly (they are produced by the sync script): - `.roo/rules/` - `.roo/skills/` - `.github/copilot-instructions.md` - `AGENTS.md` - `.claude/` 5. **Test changes before syncing** — verify scripts execute correctly and changes work as expected. 6. After testing, run the sync script to apply to all agents. ## Testing Before Sync Before running the sync script, always verify your changes work correctly: - **For script changes**: Execute the modified script and verify output is correct - **For instruction changes**: Review the markdown renders properly and instructions are clear - **For skill changes**: Test any bundled tools or scripts included in the skill **Example**: If you modify a PowerShell script in a skill, run it directly from `.ai/skills//scripts/` to confirm it works before syncing. ## Activation process After editing instruction files (or master skills), run from repository root: ```powershell .\.ai\skills\ai-self-improvement\scripts\Sync-AgentAssets.ps1 AUTO ``` This script synchronizes changes from `.ai/` to all agent-specific folders. ## Single source of truth **Never embed template content in instructions — reference template files instead.** Example: - ✅ "Template maintained in `pr/checklist.template.md`" - ❌ Pasting template content into instructions ## Bundled scripts - Sync entrypoint (instructions + skills): `.ai/skills/ai-self-improvement/scripts/Sync-AgentAssets.ps1` ================================================ FILE: .ai/skills/ai-self-improvement/scripts/Sync-AgentAssets.ps1 ================================================ # Script: Sync-AgentAssets.ps1 # Location: .ai/skills/ai-self-improvement/scripts/Sync-AgentAssets.ps1 # Description: # Synchronises AI agent instruction files and skills from master sources under `.ai/`. # - Instructions: copies `*.instructions.md` from `.ai/` into agent-specific outputs. # - Skills: mirrors `.ai/skills/*` into agent skill folders (e.g. `.roo/skills/*`). # # Options for Mode: # ALL - update all known agent outputs # AUTO - update only agents that exist in this repository (default usage) # Or a specific agent name: CLINE, ROO CODE, GitHub CoPilot, OpenAI Codex, Claude Code param( [Parameter(Position = 0)] [string]$Mode, [switch]$NoClear ) # Combine remaining args so Windows PowerShell (-File) invocations like: # Sync-AgentAssets.ps1 GitHub CoPilot # work the same as: # Sync-AgentAssets.ps1 "GitHub CoPilot" if ($args.Count -gt 0) { $ModeFromArgs = ($args -join ' ') if (-not $Mode -or $Mode -eq '') { $Mode = $ModeFromArgs } } # Allow calling via the old filename (if invoked through a copied/renamed script). # This only affects displayed script name in prompts/logs. $scriptName = [System.IO.Path]::GetFileName($MyInvocation.MyCommand.Path) # Strict mode Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Ensure-Directory { param( [Parameter(Mandatory = $true)] [string]$Path ) if (-not (Test-Path -Path $Path -PathType Container)) { New-Item -ItemType Directory -Force -Path $Path | Out-Null } } # Function to check if instruction files exist in a directory function Test-HasInstructionFiles { param( [Parameter(Mandatory = $true)] [string]$Path, [string]$Filter = '*instructions.md' ) if (Test-Path $Path -PathType Container) { $files = @(Get-ChildItem $Path -Filter $Filter -File -ErrorAction SilentlyContinue) return ($files.Length -gt 0) } return $false } # Function to pause at the end (unless -NoWait is specified) function Invoke-Pause { Write-Host "Pausing for 2 seconds..." Start-Sleep -Seconds 2 } function Copy-FileIfDifferent { param( [Parameter(Mandatory = $true)] [string]$SourcePath, [Parameter(Mandatory = $true)] [string]$TargetPath ) $targetDir = Split-Path -Path $TargetPath -Parent Ensure-Directory -Path $targetDir if (-not (Test-Path -Path $TargetPath -PathType Leaf)) { Copy-Item -LiteralPath $SourcePath -Destination $TargetPath -Force $relative = $TargetPath.Substring($repoRoot.Length + 1) Write-Host "Created: $relative" return } $srcBytes = [System.IO.File]::ReadAllBytes($SourcePath) $dstBytes = [System.IO.File]::ReadAllBytes($TargetPath) if ($srcBytes.Length -eq $dstBytes.Length) { $same = $true for ($i = 0; $i -lt $srcBytes.Length; $i++) { if ($srcBytes[$i] -ne $dstBytes[$i]) { $same = $false; break } } if ($same) { $relative = $TargetPath.Substring($repoRoot.Length + 1) Write-Host "Up-to-date: $relative" return } } Copy-Item -LiteralPath $SourcePath -Destination $TargetPath -Force $relative = $TargetPath.Substring($repoRoot.Length + 1) Write-Host "Updated: $relative" } function Get-TextAuto { param( [Parameter(Mandatory = $true)] [string]$Path ) # .NET StreamReader detects BOM for UTF-8/UTF-16/UTF-32 automatically. $sr = New-Object System.IO.StreamReader($Path, $true) try { return $sr.ReadToEnd() } finally { $sr.Dispose() } } function Write-Utf8NoBom { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [string]$Content ) $dir = Split-Path -Path $Path -Parent Ensure-Directory -Path $dir $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) } function Assert-InstructionSync { param( [Parameter(Mandatory = $true)] [string]$SourceDirectory, [Parameter(Mandatory = $true)] [string]$TargetDirectory, [Parameter(Mandatory = $true)] [System.IO.FileSystemInfo[]]$SourceFiles ) $srcDir = Join-Path $repoRoot $SourceDirectory $dstDir = Join-Path $repoRoot $TargetDirectory foreach ($sourceFile in $SourceFiles) { $srcPath = Join-Path $srcDir $sourceFile.Name $dstPath = Join-Path $dstDir $sourceFile.Name if (-not (Test-Path $dstPath -PathType Leaf)) { throw "Binary comparison failed. Destination file missing: $dstPath" } $srcBytes = [System.IO.File]::ReadAllBytes($srcPath) $dstBytes = [System.IO.File]::ReadAllBytes($dstPath) if ($srcBytes.Length -ne $dstBytes.Length) { throw "Binary comparison failed. Source and target size mismatch in binary: Source: $srcPath Target: $dstPath" } for ($i = 0; $i -lt $srcBytes.Length; $i++) { if ($srcBytes[$i] -ne $dstBytes[$i]) { throw "Binary comparison failed. Source and target content mismatch in binary: Source: $srcPath Target: $dstPath" } } } } # Function to update agents that use multiple separate instruction files function Update-MultipleFileAgent { param( [Parameter(Mandatory = $true)] [string]$AgentName, [Parameter(Mandatory = $true)] [string]$TargetDirectory, [Parameter(Mandatory = $true)] [System.IO.FileSystemInfo[]]$SourceFiles, [Parameter(Mandatory = $true)] [string]$RepoRoot ) Write-Host "`r`n--- Updating $AgentName Instructions ---" $targetDir = Join-Path $RepoRoot $TargetDirectory foreach ($sourceFile in $SourceFiles) { $targetFile = Join-Path $targetDir $sourceFile.Name Copy-FileIfDifferent -SourcePath $sourceFile.FullName -TargetPath $targetFile } Assert-InstructionSync -SourceDirectory ".ai" -TargetDirectory $TargetDirectory -SourceFiles $SourceFiles } # Function to update agents that use a single combined instruction file function Update-SingleFileAgent { param( [Parameter(Mandatory = $true)] [string]$AgentName, [Parameter(Mandatory = $true)] [string]$TargetFilePath, [Parameter(Mandatory = $true)] [System.IO.FileSystemInfo[]]$SourceFiles, [Parameter(Mandatory = $true)] [string]$RepoRoot ) Write-Host "`r`n--- Updating $AgentName Instructions ---" $targetFile = Join-Path $RepoRoot $TargetFilePath $relativeTarget = $targetFile.Substring($repoRoot.Length + 1) $allInstructionsContent = New-Object System.Text.StringBuilder $firstFile = $true foreach ($sourceFile in $SourceFiles) { $sourceContent = Get-TextAuto -Path $sourceFile.FullName if ([string]::IsNullOrWhiteSpace($sourceContent)) { Write-Warning "Skipping empty file: $($sourceFile.Name)" continue } if (-not $firstFile) { [void]$allInstructionsContent.AppendLine("") } [void]$allInstructionsContent.AppendLine("==== START OF INSTRUCTIONS FROM: $($sourceFile.Name) ====") [void]$allInstructionsContent.AppendLine("") [void]$allInstructionsContent.AppendLine("# Instructions from: $($sourceFile.Name)") [void]$allInstructionsContent.AppendLine("") [void]$allInstructionsContent.AppendLine($sourceContent.Trim()) [void]$allInstructionsContent.AppendLine("") [void]$allInstructionsContent.AppendLine("==== END OF INSTRUCTIONS FROM: $($sourceFile.Name) ====") $firstFile = $false } $finalContent = $allInstructionsContent.ToString() $existing = if (Test-Path -Path $targetFile -PathType Leaf) { Get-TextAuto -Path $targetFile } else { $null } if ($null -ne $existing -and $existing -eq $finalContent) { Write-Host "Up-to-date: $relativeTarget" return } Write-Utf8NoBom -Path $targetFile -Content $finalContent Write-Host "Updated: $relativeTarget" } function Invoke-RoboCopyMirror { param( [Parameter(Mandatory = $true)] [string]$SourceDirectory, [Parameter(Mandatory = $true)] [string]$DestinationDirectory, [Parameter(Mandatory = $true)] [string]$Label ) if (-not (Test-Path $SourceDirectory -PathType Container)) { Write-Host "No skills folder found at: $SourceDirectory" return } Ensure-Directory -Path $DestinationDirectory Write-Host "`r`n--- Mirroring skills to $Label ---" Write-Host "Source: $SourceDirectory" Write-Host "Destination: $DestinationDirectory" # /MIR = mirror (copy + delete removed) # /FFT = tolerate 2s timestamp granularity # /R:1 /W:1 = retry quickly # /NFL/NDL = no file/dir listing (keep output compact) # /NJH/NJS = no job header/summary # /NP = no progress # /XD = exclude version control/build dirs $excludedDirs = @('.git', '.vs', 'bin', 'obj') $args = @( $SourceDirectory, $DestinationDirectory, '/MIR', '/FFT', '/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP' ) foreach ($d in $excludedDirs) { $args += '/XD' $args += $d } $exe = 'robocopy' # Do not echo the full robocopy command; it is noisy and can wrap in some terminals. Write-Host "robocopy /MIR /NFL /NDL /NJH /NJS /NP ..." & $exe @args | Out-Null $exitCode = $LASTEXITCODE # Robocopy uses bitmask exit codes. # 0-7 are success with various flags; >= 8 indicates failure. if ($exitCode -ge 8) { throw "Robocopy failed with exit code $exitCode. Command: $cmd" } # IMPORTANT: robocopy returns 1+ for successful copies. # Ensure PowerShell script does not propagate a non-zero exit code for success cases. $global:LASTEXITCODE = 0 Write-Host "Mirrored skills to $Label (robocopy exit code $exitCode)." } function Sync-SkillsToRoo { param( [Parameter(Mandatory = $true)] [string]$RepoRoot ) $srcSkillsRoot = Join-Path $RepoRoot ".ai\skills" $rooSkillsRoot = Join-Path $RepoRoot ".roo\skills" Invoke-RoboCopyMirror -SourceDirectory $srcSkillsRoot -DestinationDirectory $rooSkillsRoot -Label "Roo (.roo\\skills)" } function Sync-SkillsToGitHub { param( [Parameter(Mandatory = $true)] [string]$RepoRoot ) $srcSkillsRoot = Join-Path $RepoRoot ".ai\skills" $githubSkillsRoot = Join-Path $RepoRoot ".github\skills" Invoke-RoboCopyMirror -SourceDirectory $srcSkillsRoot -DestinationDirectory $githubSkillsRoot -Label "GitHub (.github\\skills)" } function Sync-SkillsToClaude { param( [Parameter(Mandatory = $true)] [string]$RepoRoot ) $srcSkillsRoot = Join-Path $RepoRoot ".ai\skills" $claudeSkillsRoot = Join-Path $RepoRoot ".claude\skills" Invoke-RoboCopyMirror -SourceDirectory $srcSkillsRoot -DestinationDirectory $claudeSkillsRoot -Label "Claude Code (.claude\\skills)" } # --- Main Script --- if (-not $NoClear) { Clear-Host } # We are located under `.ai/skills//tools`. Find repo root by going up 4 levels. $scriptDir = $PSScriptRoot $repoRoot = (Join-Path -Path $scriptDir -ChildPath "..\..\..\.." | Resolve-Path).Path # `.ai` folder path $aiDir = Join-Path $repoRoot ".ai" # Discover source files matching *instructions.md in the .ai folder [System.IO.FileSystemInfo[]]$sourceInstructionFiles = Get-ChildItem -Path $aiDir -Filter "*instructions.md" -File | Sort-Object Name if ($null -eq $sourceInstructionFiles -or $sourceInstructionFiles.Length -eq 0) { Write-Warning "No '*instructions.md' files found in '$aiDir'. Nothing to process." exit 0 } Write-Host "Found the following source instruction files in '$aiDir':" $sourceInstructionFiles | ForEach-Object { Write-Host "- $($_.Name)" } # Mode parameter handling: if 'ALL' or 'AUTO', skip interactive prompt if ($Mode -eq 'ALL') { Write-Host "Selected: ALL (parameter mode)" $updateCline = $true $updateCopilot = $true $updateRooCode = $true $updateCodex = $true $updateClaude = $true } elseif ($Mode -eq 'AUTO') { Write-Host "Selected: AUTO (parameter mode)" # Determine available agents based on instruction files $updateCline = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.clinerules') $updateRooCode = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.roo\rules') $updateCopilot = Test-Path (Join-Path $repoRoot '.github\copilot-instructions.md') -PathType Leaf $updateCodex = Test-Path (Join-Path $repoRoot 'AGENTS.md') -PathType Leaf $updateClaude = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.claude') Write-Host "Agents to update based on available instruction files:" if ($updateCline) { Write-Host "- CLINE" } if ($updateRooCode) { Write-Host "- ROO CODE" } if ($updateCopilot) { Write-Host "- GitHub CoPilot" } if ($updateCodex) { Write-Host "- OpenAI Codex" } if ($updateClaude) { Write-Host "- Claude Code" } } elseif ($Mode -and $Mode -ne '') { # Specific agent mode (e.g., CLINE, "ROO CODE", etc.) $updateCline = ($Mode -eq 'CLINE') $updateCopilot = ($Mode -eq 'GitHub CoPilot') $updateRooCode = ($Mode -eq 'ROO CODE') $updateCodex = ($Mode -eq 'OpenAI Codex') $updateClaude = ($Mode -eq 'Claude Code') Write-Host "Selected: $Mode (parameter mode)" } else { # User prompt for agent selection # Detect available agents for interactive menu $hasCline = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.clinerules') $hasRooCode = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.roo\rules') $hasCopilot = Test-Path (Join-Path $repoRoot '.github\copilot-instructions.md') -PathType Leaf $hasCodex = Test-Path (Join-Path $repoRoot 'AGENTS.md') -PathType Leaf $hasClaude = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.claude') Write-Host "`r`nDetected AI agents with instruction files:" if ($hasCline) { Write-Host "- CLINE" } if ($hasRooCode) { Write-Host "- ROO CODE" } if ($hasCopilot) { Write-Host "- GitHub CoPilot" } if ($hasCodex) { Write-Host "- OpenAI Codex" } if ($hasClaude) { Write-Host "- Claude Code" } Write-Host "" Write-Host "==============================================================" Write-Host "Select Agent Instruction Set to Update" Write-Host "--------------------------------------------------------------" Write-Host "1. AUTO - Update only agents with instruction files (default)" Write-Host "2. ALL - Update instructions for all AI agents" Write-Host "3. CLINE - Update instructions for CLINE" Write-Host "4. ROO CODE - Update instructions for ROO CODE" Write-Host "5. GitHub CoPilot - Update instructions for GitHub CoPilot" Write-Host "6. OpenAI Codex - Update instructions for OpenAI Codex" Write-Host "7. Claude Code - Update instructions for Claude Code" Write-Host "0. Exit" Write-Host "==============================================================" $selection = Read-Host "Enter the number of your choice (0-7)" # Initialize flags $updateCline = $false $updateCopilot = $false $updateRooCode = $false $updateCodex = $false $updateClaude = $false switch ($selection) { '1' { $updateCline = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.clinerules') $updateRooCode = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.roo\rules') $updateCopilot = Test-Path (Join-Path $repoRoot '.github\copilot-instructions.md') -PathType Leaf $updateCodex = Test-Path (Join-Path $repoRoot 'AGENTS.md') -PathType Leaf $updateClaude = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.claude') Write-Host "Selected: AUTO" } '2' { $updateCline = $true $updateCopilot = $true $updateRooCode = $true $updateCodex = $true $updateClaude = $true Write-Host "Selected: ALL" } '3' { $updateCline = $true; Write-Host "Selected: CLINE" } '4' { $updateRooCode = $true; Write-Host "Selected: ROO CODE" } '5' { $updateCopilot = $true; Write-Host "Selected: GitHub CoPilot" } '6' { $updateCodex = $true; Write-Host "Selected: OpenAI Codex" } '7' { $updateClaude = $true; Write-Host "Selected: Claude Code" } '0' { Write-Host "Operation cancelled by user."; exit 0 } default { throw "Invalid selection. Exiting." } } } # --- Multiple-File Agent Updates --- if ($updateCline) { Update-MultipleFileAgent -AgentName "CLINE" -TargetDirectory ".clinerules" -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } if ($updateRooCode) { Update-MultipleFileAgent -AgentName "ROO CODE" -TargetDirectory ".roo\rules" -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } # --- Single-File Agent Updates --- if ($updateCopilot) { $copilotTarget = ".github\copilot-instructions.md" $githubInstructionsDir = Join-Path $repoRoot ".github\instructions" if (Test-Path $githubInstructionsDir -PathType Container) { Write-Host "`r`n--- Updating GitHub CoPilot Instructions (folder-based) ---" $mainName = "instructions.md" $mainSource = $sourceInstructionFiles | Where-Object { $_.Name -ieq $mainName } | Select-Object -First 1 if ($null -eq $mainSource) { throw "Expected source '$mainName' under .ai but none found." } Copy-FileIfDifferent -SourcePath $mainSource.FullName -TargetPath (Join-Path $repoRoot $copilotTarget) foreach ($sf in $sourceInstructionFiles) { if ($sf.Name -ieq $mainName) { continue } $destination = Join-Path $githubInstructionsDir $sf.Name Copy-FileIfDifferent -SourcePath $sf.FullName -TargetPath $destination } } else { Update-SingleFileAgent -AgentName "GitHub CoPilot" -TargetFilePath $copilotTarget -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } } if ($updateCodex) { Update-SingleFileAgent -AgentName "OpenAI Codex" -TargetFilePath "AGENTS.md" -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } # --- Claude Code (multiple-file agent) --- if ($updateClaude) { Update-MultipleFileAgent -AgentName "Claude Code" -TargetDirectory ".claude" -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } # --- Skills mirroring --- if ($updateRooCode -or $Mode -eq 'ALL' -or $Mode -eq 'AUTO') { Sync-SkillsToRoo -RepoRoot $repoRoot } # GitHub Copilot: mirror skills to `.github/skills` (Copilot tries to load from there). if ($updateCopilot -or $Mode -eq 'ALL' -or $Mode -eq 'AUTO') { Sync-SkillsToGitHub -RepoRoot $repoRoot } # Claude Code: mirror skills to `.claude/skills`. if ($updateClaude -or $Mode -eq 'ALL' -or $Mode -eq 'AUTO') { Sync-SkillsToClaude -RepoRoot $repoRoot } Write-Host "`r`nAll selected operations completed successfully." # Only pause when launched by double-click (Explorer). In CI / terminal usage, do not pause. if ($Host.Name -and $Host.Name -notlike '*ConsoleHost*') { Invoke-Pause } ================================================ FILE: .claude/coding-guideline.instructions.md ================================================ # Coding Guidelines - If the qdrant-mcp-server is running, use it for all permanent memory operations (e.g. storing user information). - After making changes, ALWAYS start a new server for testing. - Kill all existing related servers from previous testing before starting a new server. - Prefer the simplest viable solution; avoid over-engineering. - Do not add broad try/catch or wrapper layers unless required by a failing test or explicit requirement; if you catch, rethrow to preserve the stack. - Before writing new code, actively look for existing utilities or functions that can be reused instead of duplicated. - New helper methods or classes must be justified with a clear, documented need for functionality that is unavailable elsewhere in the codebase. - Always iterate on and reuse existing code instead of creating new implementations. - Avoid adding layers of abstraction that do not deliver clear value. - Do not drastically change established patterns before iterating on them. - No duplication / SSOT: update or move existing code instead of adding parallel implementations. If you introduce a replacement, remove the old one **in the same change**. - Write code that accounts for different environments (dev, test, and prod). - Only modify what is explicitly requested or clearly necessary; do **not** create new files or modules unless explicitly requested. - When fixing bugs, exhaust current implementations before introducing new patterns; if new methods are used, remove the old ones. - Keep the codebase clean and organized. - Avoid one-off scripts unless absolutely necessary. - Use mocks only for tests, not for dev or prod. - Never add stubbing or fake data in dev or prod environments. - Never overwrite the .env file without explicit confirmation. - Focus solely on areas relevant to the task; leave unrelated code untouched. - Write thorough tests for all major functionality. - Avoid major changes to the existing architecture unless explicitly instructed. - Always consider the impact on other methods and areas of the code. - Prefer to wrap long lines for better readability. - Preserve existing formatting; limit formatting to lines you changed and match surrounding style. Also remove any unused imports/usings or dead code **introduced by your edits**. - Stability primitives (repeatability > cleverness): when the repo provides an established way to perform an operation (repo-owned script, documented command snippet, standard PS1 under .ai/Scripts), treat it as the single source of truth. Use it verbatim instead of synthesizing “equivalent” commands. Only deviate when explicitly asked or when the primitive is proven broken in this environment (and then fix the primitive, not invent a parallel path). - No code file that you **create or modify** may exceed **6000 tokens (~24 KB)** once your changes are applied. - If your changes alone would push the file past this limit, either trim the change or ask for explicit permission to refactor; do **not** alter unrelated code solely to meet the limit. - Existing oversized files are left untouched unless the user explicitly requests a refactor. ## Source Control Conventions Naming conventions for issues, branches, and pull requests. ### Categories | Category | Use for | |----------|---------| | `FEAT` | New features | | `FIX` | Bug fixes | | `TECH` | Infrastructure, dependencies, refactoring | | `DOCS` | Documentation | ### Naming Patterns | Item | Pattern | Example | |------------|---------|---------| | **Issue** | `{CATEGORY}: {Description}` | `FEAT: Download logs to CSV` | | **Branch** | `{CATEGORY}-{issue#}-{lowercase-dashed-name}` | `FEAT-21-download-logs-to-csv` | | **PR** | `PR: #{issue#}: {CATEGORY}: {Description}` | `PR: #21: FEAT: Download logs to CSV` | - Branch names: lowercase, words separated by dashes, non-ASCII replaced with a single dash, derived from issue title but may be shortened. - PR body must reference the issue with `Closes #{issue#}`. - Merge via PR only — no direct pushes to `main` or `master`. - Feature branches are always created from `main` or `master`. Never merge one feature branch into another — only merge `main` if you need to catch up. - Always create branches from latest origin/main or origin/main, never merge sub-branches, confirm before risky git ops. - Do not add Co-Authored-By {AI model}/{AI company} lines to commits. - Never close issues before a release is published with the fix. Use the following guidelines: 1. Doc Comment Enhancement for IntelliSense - Replace or augment simple comments with relevant doc comment syntax that is supported by IntelliSense as needed. - Preserve the original intent and wording of existing comments wherever possible. 2. Code Layout for Clarity - Place the most important or user-editable sections at the top if logically appropriate. - Insert headings or separators within the code to clearly delineate where customizations or key logic sections can be adjusted. 3. No Extraneous Code Comments - Do not include "one-off" or user-directed commentary in the code. - Confine all clarifications or additional suggestions to explanations outside of the code snippet. 4. Avoid Outdated or Deprecated Methods - Refrain from introducing or relying on obsolete or deprecated methods and libraries. - If the current code relies on potentially deprecated approaches, ask for clarification or provide viable, modern alternatives that align with best practices. 5. Testing and Validation - Suggest running unit tests or simulations on the modified segments to confirm that the changes fix the issue without impacting overall functionality. - Ensure that any proposed improvements, including doc comment upgrades, integrate seamlessly with the existing codebase. - After all code modifications, navigate to the affected project directory and build C# then Angular to confirm the application compiles without errors: cd {PROJECT} && dotnet build {PROJECT}.csproj cd {PROJECT}/ClientApp && ng build - Run relevant unit tests if code changes affect core logic. - If the developer certificate is not trusted, then execute: dotnet dev-certs https --trust - To launch project use: dotnet watch run --project {PROJECT}/{PROJECT}.csproj --launch-profile "{PROJECT} (NG Build)" 6. Rationale and Explanation - For every change (including comment conversions), provide a concise explanation detailing how the modification resolves the identified issue while preserving the original design and context. - Clearly highlight only the modifications made, ensuring that no previously validated progress is altered. - NOTE: Summarize reasoning for the user, but do NOT expose full chain-of-thought. Keep internal deliberations internal; surface only the concise rationale needed to justify each change. 7. Contextual Analysis - Use all available context—such as code history, inline documentation, style guidelines—to understand the intended functionality. - When inspecting an existing file for understanding, prefer reading the whole file in a single `read_file` call when it comfortably fits in context; switch to targeted slices only when the file is too large, the tool truncates it, or a specific anchor line is already known. - If the role or intent behind a code segment is ambiguous, ask for clarification rather than making assumptions. 8. Targeted, Incremental Changes - Identify and isolate only the problematic code segments (including places where IntelliSense doc comments can replace simple comments). - Provide minimal code snippets that address the issue without rewriting larger sections. - For each suggested code change, explicitly indicate the exact location in the code (e.g., by specifying the function name, class name, line number, or section heading) where the modification should be implemented. 9. Preservation of Context - Maintain all developer comments, annotations, and workarounds exactly as they appear, transforming them to doc comment format only when it improves IntelliSense support. - Do not modify or remove any non-code context unless explicitly instructed. - Avoid introducing new, irrelevant comments in the code. 10. Launching {PROJECT} Correctly: - Navigate to the {PROJECT} project folder. - Run the following command to launch the project with live reload and proper debugging configuration: dotnet watch run --launch-profile "{PROJECT} (NG Build)" --project {PROJECT}/{PROJECT}.csproj - This command will start the {PROJECT} project on the designated debugging session URL. - Ensure that any previous {PROJECT} instances are terminated before running this command. ================================================ FILE: .claude/instructions.md ================================================ ## Role Your role is to analyze and improve code by making only localized, targeted changes. You must preserve all validated code, comments, and documented workarounds exactly as they appear. Your suggestions should strictly address only the specific issues identified—such as upgrading simple comments to doc comments for IntelliSense—without altering any surrounding context. Additionally, ensure that no obsolete or deprecated methods are introduced during the improvement process, and do not add extraneous comments that do not directly contribute to the code’s logic. Furthermore, ensure code snippets are clearly structured for readability, placing important or user-editable sections at the top when logical, and using clear separators or headings to highlight customization points. Wherever beneficial, convert simple comments into recognized documentation comment syntax (e.g., JSDoc for JavaScript, XML comments for C#, JavaDoc for Java) that can be parsed by code intelligence tools like IntelliSense. Maintain the original meaning of these comments, but structure them in a way that provides maximum benefit for automated tools and refactoring methods. Apply chain-of-thought reasoning to identify code segments best served by doc comments, analyze the existing context of each comment, and then make precise, incremental modifications that enhance IntelliSense compatibility while preserving existing functionality. ## Output Wrap any and all code—including regular code snippets, inline code segments, outputs, pseudocode, or any text that represents code—in Markdown code blocks with a language identifier (e.g., ```typescript, ```powershell). ================================================ FILE: .claude/repository-analysis.instructions.md ================================================ # Repository Analysis ## 1. Repository Overview This document provides a factual reference for the Jocys.com FocusLogger repository, aimed at developers and AI coding agents working on the codebase. **FocusLogger** is a Windows desktop utility that monitors and logs which process or program takes window focus. It targets users (especially gamers and power users) who experience unexpected focus stealing — where a background process briefly grabs foreground focus, interrupting gameplay or work. The tool logs every focus change with timestamps, process details, window class names, and focus-state flags, allowing users to identify the culprit. - **Repository:** https://github.com/JocysCom/FocusLogger - **License:** GNU General Public License v3.0 - **Current version:** 1.2.6 - **Target platform:** Windows 10+ with .NET 8.0 - **Primary audiences:** Gamers, power users, IT support personnel diagnosing focus-stealing issues. ## 2. Top-Level Structure This section maps every top-level directory and file to help navigate the repository quickly. | Path | Purpose | |------|---------| | `FocusLogger/` | Main application project (WPF, .NET 8.0). Contains all app source code, shared library, and resources. | | `FocusLogger.Tests/` | MSTest test project. Unit tests for CSV export and UI automation tests. | | `Documents/` | Release engineering: signing scripts, zip packaging scripts, screenshot tooling, and pre-built release files. | | `Resources/` | Solution-level shared scripts (currently `ZipFiles.ps1` for checksum-aware zip packaging). | | `.ai/` | AI agent instructions, coding guidelines, repository analysis, and skills. | | `JocysCom.FocusLogger.slnx` | Solution file (XML-based `.slnx` format) referencing the two projects. | | `README.md` | Project overview, download link, system requirements, screenshot. | | `LICENSE` | GPLv3 license text. | | `SECURITY.md` | Security vulnerability reporting policy (support@jocys.com). | | `Settings.XamlStyler` | XamlStyler configuration for consistent XAML formatting. | | `Solution_Cleanup.ps1` | PowerShell script for cleaning build artifacts. | ## 3. Technology Stack & Key Dependencies This section lists verified technologies and versions drawn from project files. | Technology | Version / Detail | Evidence | |------------|-----------------|----------| | .NET | 8.0 (`net8.0-windows`) | `JocysCom.FocusLogger.csproj` TargetFramework | | C# | Implicit (SDK default for .NET 8) | SDK-style project | | WPF | `true` | csproj | | Windows Forms interop | `true` | csproj — used for P/Invoke helpers and DPI awareness | | MSTest | v3.x (`MSTest.TestFramework 3.*`, `MSTest.TestAdapter 3.*`) | Test csproj PackageReference | | Microsoft.NET.Test.Sdk | 17.x | Test csproj PackageReference | | Windows API (user32.dll) | P/Invoke | `NativeMethods.cs` | | PowerShell | Scripts for build, sign, zip, cleanup | `Documents/`, `Resources/`, root | | XamlStyler | Config present | `Settings.XamlStyler` | **No NuGet package dependencies** in the main application project — all functionality comes from .NET SDK and the embedded `JocysCom.ClassLibrary`. ## 4. Architecture & Runtime Model This section describes how the application is structured and how it operates at runtime. FocusLogger is a **single-executable WPF desktop application** that polls Windows API functions to detect focus changes. It does not persist log data between sessions (in-memory only) but provides CSV export for offline analysis. ### Architectural layers ```mermaid graph TD subgraph UI["UI Layer (WPF)"] App["App.xaml.cs
Entry point, DPI aware"] MW["MainWindow.xaml.cs
Main window frame"] DLC["DataListControl.xaml.cs
Core logging UI + logic"] end subgraph Core["Core Logic"] DI["DataItem.cs
Log entry model"] DIT["DataItemType.cs
Entry type enum"] NM["NativeMethods.cs
P/Invoke declarations"] CSV["CSV Export
BuildCsvContent, CsvEscape"] end subgraph Shared["JocysCom.ClassLibrary (embedded)"] Config["Configuration
SettingsData, SettingsItem, AssemblyInfo"] CompModel["ComponentModel
SortableBindingList, BindingListInvoked"] Controls["Controls
ControlsHelper, ItemFormattingConverter,
InfoControl, MessageBoxWindow"] Other["Collections, IO, Text, Runtime, Data"] end subgraph WinAPI["Windows OS"] User32["user32.dll"] end App --> MW --> DLC DLC --> DI DLC --> NM DLC --> CSV NM --> User32 DI --> Config DLC --> CompModel DLC --> Controls MW --> Controls ``` ### Key architectural decisions - **Polling via timer:** A `System.Timers.Timer` with 1ms interval (non-auto-reset) continuously polls `GetActiveWindow()` and `GetForegroundWindow()`. Duplicate events are suppressed via `DataItem.IsSame()`. - **Thread safety:** Timer fires on a thread-pool thread; UI updates are marshalled via `ControlsHelper.BeginInvoke()`. A `lock(AddLock)` synchronizes the polling logic. - **Embedded shared library:** `JocysCom.ClassLibrary` files are included directly in `FocusLogger/JocysCom/` rather than as a compiled DLL or NuGet package. - **No MVVM framework:** Code-behind pattern with data binding. `DataListControl.xaml.cs` contains both view-model-like logic and model interaction. ## 5. Project Inventory This section lists each project in the solution with its key metadata. ### 5.1 JocysCom.FocusLogger (main application) | Property | Value | |----------|-------| | Path | `FocusLogger/JocysCom.FocusLogger.csproj` | | Output type | `WinExe` | | Target framework | `net8.0-windows` | | Assembly name | `JocysCom.FocusLogger` | | Description | Find out which process or program is taking the window focus. In game, mouse and keyboard could temporarily stop responding if another program takes the focus. This tool could help diagnose which program is stealing the focus. | | Version | 1.2.6 | | NuGet dependencies | None | | Embedded resources | `Resources/BuildDate.txt` (auto-generated), `Resources/AiAnalysisPrompt.md` | **Source structure:** | Directory | Contents | |-----------|----------| | `FocusLogger/` (root) | `App.xaml(.cs)`, `MainWindow.xaml(.cs)`, `AssemblyInfo.cs`, `App.ico` | | `FocusLogger/Common/` | `DataItem.cs`, `DataItemType.cs`, `NativeMethods.cs` | | `FocusLogger/Controls/` | `DataListControl.xaml(.cs)` — core logging control | | `FocusLogger/JocysCom/` | Embedded `JocysCom.ClassLibrary` (~30 files across Collections, Common, ComponentModel, Configuration, Controls, Data, IO, Runtime, Text) | | `FocusLogger/Resources/` | `AiAnalysisPrompt.md`, `BuildDate.txt`, `Icons/` (SVG sources, XAML icons, conversion scripts) | | `FocusLogger/Properties/` | Publish profiles | ### 5.2 JocysCom.FocusLogger.Tests (test project) | Property | Value | |----------|-------| | Path | `FocusLogger.Tests/JocysCom.FocusLogger.Tests.csproj` | | Target framework | `net8.0-windows` | | Test framework | MSTest v3.x | | Project reference | `FocusLogger/JocysCom.FocusLogger.csproj` | **Test files:** | File | Purpose | |------|---------| | `CsvExportTests.cs` | Unit tests for `CsvEscape` and `BuildCsvContent` methods | | `UIAutomationTests.cs` | UI automation tests using `System.Windows.Automation` — launches the built app and interacts with controls by AutomationId | Run tests with: `dotnet test FocusLogger.Tests/JocysCom.FocusLogger.Tests.csproj` ## 6. Dependency & Data Flow This section explains how the projects and components relate to each other and how data moves through the system. ### Project dependency graph ```mermaid graph LR Tests["FocusLogger.Tests
(MSTest v3)"] -->|ProjectReference| App["JocysCom.FocusLogger
(WPF App)"] App -->|embedded source| Shared["JocysCom.ClassLibrary
(in FocusLogger/JocysCom/)"] App -->|P/Invoke| WinAPI["user32.dll"] ``` ### Runtime data flow 1. `System.Timers.Timer` fires (1ms interval, non-auto-reset). 2. `DataListControl.UpdateInfo()` acquires `AddLock`. 3. Calls `NativeMethods.GetActiveWindow()` and `NativeMethods.GetForegroundWindow()`. 4. For each handle, `GetItemFromHandle()` creates a `DataItem` with timestamp, focus flags (mouse/keyboard/caret), window title, and window class name. 5. `IsSame()` checks if the event differs from the previous one; if not, it is skipped. 6. `UpdateFromProcess()` enriches the `DataItem` with process name and path (with error handling for restricted processes). 7. The item is inserted at position 0 of `SortableBindingList` via `ControlsHelper.BeginInvoke()` (UI thread dispatch). 8. The WPF `DataGrid` updates via data binding. `ItemFormattingConverter` translates boolean flags to icons. ### CSV export flow 1. User clicks "Save CSV" button. 2. `SaveFileDialog` prompts for file location. 3. `BuildCsvContent()` iterates all `DataItem` entries, writing CSV with headers: Date, PID, Process Name, Active, Mouse, Keyboard, Caret, Window Title, Window Class, Path. 4. File is written as UTF-8. 5. "Explore" button opens the saved file location in Explorer. 6. "AI Prompt Example" button shows the embedded `AiAnalysisPrompt.md` in a `MessageBoxWindow` for users to copy and paste into an AI assistant along with their CSV. ## 7. Build, Test, CI/CD & Operational Workflows This section documents how the project is built, tested, and released based on repository evidence. ### Build ```bash dotnet build JocysCom.FocusLogger.slnx ``` - **Pre-build event:** Generates `Resources/BuildDate.txt` with the current ISO 8601 timestamp via PowerShell. - **Output:** Single `JocysCom.FocusLogger.exe` in `bin/{Configuration}/net8.0-windows/`. - **Debug configuration:** Embedded PDB symbols (`DebugType: embedded`). ### Test ```bash dotnet test FocusLogger.Tests/JocysCom.FocusLogger.Tests.csproj ``` - **Framework:** MSTest v3.x with Microsoft.NET.Test.Sdk 17.x. - **Unit tests:** `CsvExportTests` — validates CSV escaping and content generation. - **UI automation tests:** `UIAutomationTests` — launches the built application and interacts via `System.Windows.Automation`. Requires a prior build of the main project. ### Release / packaging scripts | Script | Purpose | |--------|---------| | `Documents/App_1_Sign.ps1` | Code-signs the application executable. | | `Documents/App_2_Zip.ps1` | Packages the signed executable into a release ZIP. | | `Resources/ZipFiles.ps1` | Shared utility for checksum-aware ZIP creation (compares source/dest checksums before rebuilding). | | `Documents/Take_Screenshot.ps1` | Captures application screenshot for documentation. | | `Documents/Take_Screenshot.ps1.cs` | C# helper compiled by the screenshot script. | | `Solution_Cleanup.ps1` | Cleans `bin/`, `obj/`, and other build artifacts. | ### Icon workflow SVG icon sources are stored in `FocusLogger/Resources/Icons/Icons_Default/`. The script `Convert_SVG_to_XAML.ps1` converts them to XAML resource dictionaries (`Icons_Default.xaml`). ### CI/CD No CI/CD workflow files were found under `.github/workflows/`. Builds and releases appear to be performed locally. ## 8. Documentation Map This section identifies where documentation lives in the repository. | Location | Audience | Content | |----------|----------|---------| | `README.md` | End users, contributors | Project overview, download link, system requirements, screenshot | | `SECURITY.md` | Security researchers | Vulnerability reporting policy | | `LICENSE` | All | GPLv3 full text | | `.ai/ReadMe.md` | AI agents, developers | Explains the purpose of each file in the `.ai/` directory and how custom instructions work for Copilot, CLINE, and Codex | | `.ai/instructions.md` | AI agents | Role definition and output formatting rules for AI-assisted edits | | `.ai/coding-guideline.instructions.md` | AI agents | Detailed coding guidelines, source control conventions (branch/PR naming), testing workflow, and constraints for AI agents | | `.ai/repository-analysis.instructions.md` | AI agents, developers | This file — comprehensive repository reference | | `.ai/skills/` | AI agents | Skill definitions (e.g., `ai-self-improvement`) for agent-assisted workflows | | `FocusLogger/Resources/AiAnalysisPrompt.md` | End users | Prompt template for users to paste into AI assistants alongside exported CSV logs | | `Documents/Images/` | README, users | Application screenshot | | `Settings.XamlStyler` | Developers | XamlStyler formatting configuration | ### Documentation taxonomy ```mermaid graph TD subgraph EndUsers["End-User Documentation"] README["README.md
Overview, download, requirements"] SECURITY["SECURITY.md
Vulnerability reporting"] LICENSE["LICENSE
GPLv3"] AiPrompt["FocusLogger/Resources/
AiAnalysisPrompt.md
Prompt template for CSV analysis"] Screenshot["Documents/Images/
Application screenshot"] end subgraph AIAgents["AI Agent Instructions"] AiReadMe[".ai/ReadMe.md
Directory guide"] AiInstr[".ai/instructions.md
Role & output rules"] AiCoding[".ai/coding-guideline
.instructions.md
Coding & SCM conventions"] AiRepo[".ai/repository-analysis
.instructions.md
This file"] AiSkills[".ai/skills/
Agent skill definitions"] end subgraph DevConfig["Developer Configuration"] XamlStyler["Settings.XamlStyler
XAML formatting"] end ``` ## 9. AI-Agent-Relevant Conventions and Constraints This section captures rules and patterns that materially affect automated edits. The full set of coding and source-control conventions is defined in `.ai/coding-guideline.instructions.md`; the highlights below are the items most likely to cause mistakes if overlooked. 1. **Coding style:** Follow Microsoft C# conventions. PascalCase for public members, camelCase for locals. Some private fields use `_PascalCase` (e.g., `_Date`). Preserve existing naming patterns in each file. Prefer the simplest viable solution; avoid over-engineering. 2. **Doc comments:** Per `.ai/instructions.md`, convert simple comments to XML documentation comments where beneficial for IntelliSense. Do not alter surrounding code when doing so. 3. **Shared library files (`FocusLogger/JocysCom/`):** These are embedded from a shared `JocysCom.ClassLibrary`. Exercise caution when editing — changes here may diverge from the upstream library. 4. **XAML formatting:** The repository uses XamlStyler (see `Settings.XamlStyler`). XAML edits should conform to the configured style. 5. **No NuGet packages in the main app:** All dependencies are framework-provided or embedded source. Do not introduce NuGet package dependencies without explicit approval. 6. **Test project uses MSTest v3:** New tests should follow MSTest v3 patterns (`[TestClass]`, `[TestMethod]`, `Assert.*`). 7. **UI automation tests depend on a built executable:** `UIAutomationTests` locate the app at a relative path from the test output. Building the main project before running these tests is required. 8. **Pre-build event:** The csproj generates `Resources/BuildDate.txt` via PowerShell. This file should not be manually edited or committed. 9. **Solution format:** Uses `.slnx` (XML-based solution format), not the older `.sln` text format. 10. **No CI/CD pipelines:** All build and release steps are manual/local. Scripts in `Documents/` handle signing and packaging. 11. **File size limit:** No code file that is created or modified may exceed 6000 tokens (~24 KB). 12. **Source control conventions:** Issues use `{CATEGORY}: {Description}` (FEAT, FIX, TECH, DOCS). Branches use `{CATEGORY}-{issue#}-{lowercase-dashed-name}`. PRs use `PR: #{issue#}: {CATEGORY}: {Description}` and must reference the issue with `Closes #{issue#}`. Merge via PR only — no direct pushes to `main`. 13. **No Co-Authored-By lines:** Do not add `Co-Authored-By` AI model/company lines to commits. 14. **Issue closure:** Never close issues before a release is published with the fix. 15. **No duplication / SSOT:** Update or move existing code instead of adding parallel implementations. If introducing a replacement, remove the old one in the same change. 16. **Stability primitives:** When the repo provides an established script or command, use it verbatim. Only deviate when it is proven broken, and then fix the primitive rather than inventing a parallel path. ================================================ FILE: .claude/skills/ai-self-improvement/SKILL.md ================================================ --- name: ai-self-improvement description: Update, create, improve, and synchronise this repository's AI agent instructions and related assets (including skills). Use when the user asks to create or edit a skill/SKILL.md, modify the agent's own instructions/processes, restructure instruction governance, migrate instruction content into skills, or run/adjust the sync pipeline that publishes `.ai/` sources into agent-specific folders. Load this skill before writing any SKILL.md, .instructions.md, or touching any skills/ folder (.ai/, .claude/, .roo/, .github/). It tells you the correct location (.ai/) and the sync step, so files end up in the right place. --- # AI Self-Improvement (Instructions + Skills) ## Critical: `.ai/` is the Primary Source for ALL Agents The `.ai/` folder is the **single source of truth** for all AI agent configurations in this repository. This applies to: - **CLINE / Roo Code** — synced to `.roo/rules/` and `.roo/skills/` - **GitHub Copilot** — synced to `.github/copilot-instructions.md` - **OpenAI Codex / AGENTS.md** — synced to `AGENTS.md` at repo root - **Claude Code** — synced to `.claude/*.instructions.md` and `.claude/skills/` **IMPORTANT:** When asked to modify skills, instructions, or perform any AI self-improvement task, you MUST: 1. Locate the source file under `.ai/` (not the agent-specific output) 2. Make changes to the `.ai/` source 3. Run the sync script to propagate changes to all agents ## Path Mapping Reference When you encounter a path in an agent-specific folder, map it to `.ai/`: | Agent-Specific Path | Source Path (Edit Here) | |---------------------|------------------------| | `.roo/rules/*.md` | `.ai/*.instructions.md` | | `.roo/skills//SKILL.md` | `.ai/skills//SKILL.md` | | `.github/copilot-instructions.md` | `.ai/instructions.md` (generated) | | `AGENTS.md` | `.ai/instructions.md` (generated) | | `.claude/*.instructions.md` | `.ai/*.instructions.md` | | `.claude/skills//SKILL.md` | `.ai/skills//SKILL.md` | **Example:** If asked to update `.roo/skills/ai-self-improvement/SKILL.md`, you must edit `.ai/skills/ai-self-improvement/SKILL.md` instead. ## Editable instruction files (sources of truth) You can update your own instruction files under `.ai/`: - `.ai/instructions.md` — the main system instructions file - `.ai/*instructions.md` — additional instruction files (auto-included) - `.ai/*instructions-detail.md` — detailed instruction files (read only when needed) - `.ai/skills//SKILL.md` — skill definition files ## Workflow 1. Treat `.ai/` as the **single source of truth** for agent instructions **and skills**. 2. When creating or migrating a skill, create/update it under `.ai/skills/`. 3. Make instruction changes in `.ai/instructions.md` and related `*.instructions.md` / `*.instructions-detail.md` files. 4. Do **not** edit generated outputs directly (they are produced by the sync script): - `.roo/rules/` - `.roo/skills/` - `.github/copilot-instructions.md` - `AGENTS.md` - `.claude/` 5. **Test changes before syncing** — verify scripts execute correctly and changes work as expected. 6. After testing, run the sync script to apply to all agents. ## Testing Before Sync Before running the sync script, always verify your changes work correctly: - **For script changes**: Execute the modified script and verify output is correct - **For instruction changes**: Review the markdown renders properly and instructions are clear - **For skill changes**: Test any bundled tools or scripts included in the skill **Example**: If you modify a PowerShell script in a skill, run it directly from `.ai/skills//scripts/` to confirm it works before syncing. ## Activation process After editing instruction files (or master skills), run from repository root: ```powershell .\.ai\skills\ai-self-improvement\scripts\Sync-AgentAssets.ps1 AUTO ``` This script synchronizes changes from `.ai/` to all agent-specific folders. ## Single source of truth **Never embed template content in instructions — reference template files instead.** Example: - ✅ "Template maintained in `pr/checklist.template.md`" - ❌ Pasting template content into instructions ## Bundled scripts - Sync entrypoint (instructions + skills): `.ai/skills/ai-self-improvement/scripts/Sync-AgentAssets.ps1` ================================================ FILE: .claude/skills/ai-self-improvement/scripts/Sync-AgentAssets.ps1 ================================================ # Script: Sync-AgentAssets.ps1 # Location: .ai/skills/ai-self-improvement/scripts/Sync-AgentAssets.ps1 # Description: # Synchronises AI agent instruction files and skills from master sources under `.ai/`. # - Instructions: copies `*.instructions.md` from `.ai/` into agent-specific outputs. # - Skills: mirrors `.ai/skills/*` into agent skill folders (e.g. `.roo/skills/*`). # # Options for Mode: # ALL - update all known agent outputs # AUTO - update only agents that exist in this repository (default usage) # Or a specific agent name: CLINE, ROO CODE, GitHub CoPilot, OpenAI Codex, Claude Code param( [Parameter(Position = 0)] [string]$Mode, [switch]$NoClear ) # Combine remaining args so Windows PowerShell (-File) invocations like: # Sync-AgentAssets.ps1 GitHub CoPilot # work the same as: # Sync-AgentAssets.ps1 "GitHub CoPilot" if ($args.Count -gt 0) { $ModeFromArgs = ($args -join ' ') if (-not $Mode -or $Mode -eq '') { $Mode = $ModeFromArgs } } # Allow calling via the old filename (if invoked through a copied/renamed script). # This only affects displayed script name in prompts/logs. $scriptName = [System.IO.Path]::GetFileName($MyInvocation.MyCommand.Path) # Strict mode Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Ensure-Directory { param( [Parameter(Mandatory = $true)] [string]$Path ) if (-not (Test-Path -Path $Path -PathType Container)) { New-Item -ItemType Directory -Force -Path $Path | Out-Null } } # Function to check if instruction files exist in a directory function Test-HasInstructionFiles { param( [Parameter(Mandatory = $true)] [string]$Path, [string]$Filter = '*instructions.md' ) if (Test-Path $Path -PathType Container) { $files = @(Get-ChildItem $Path -Filter $Filter -File -ErrorAction SilentlyContinue) return ($files.Length -gt 0) } return $false } # Function to pause at the end (unless -NoWait is specified) function Invoke-Pause { Write-Host "Pausing for 2 seconds..." Start-Sleep -Seconds 2 } function Copy-FileIfDifferent { param( [Parameter(Mandatory = $true)] [string]$SourcePath, [Parameter(Mandatory = $true)] [string]$TargetPath ) $targetDir = Split-Path -Path $TargetPath -Parent Ensure-Directory -Path $targetDir if (-not (Test-Path -Path $TargetPath -PathType Leaf)) { Copy-Item -LiteralPath $SourcePath -Destination $TargetPath -Force $relative = $TargetPath.Substring($repoRoot.Length + 1) Write-Host "Created: $relative" return } $srcBytes = [System.IO.File]::ReadAllBytes($SourcePath) $dstBytes = [System.IO.File]::ReadAllBytes($TargetPath) if ($srcBytes.Length -eq $dstBytes.Length) { $same = $true for ($i = 0; $i -lt $srcBytes.Length; $i++) { if ($srcBytes[$i] -ne $dstBytes[$i]) { $same = $false; break } } if ($same) { $relative = $TargetPath.Substring($repoRoot.Length + 1) Write-Host "Up-to-date: $relative" return } } Copy-Item -LiteralPath $SourcePath -Destination $TargetPath -Force $relative = $TargetPath.Substring($repoRoot.Length + 1) Write-Host "Updated: $relative" } function Get-TextAuto { param( [Parameter(Mandatory = $true)] [string]$Path ) # .NET StreamReader detects BOM for UTF-8/UTF-16/UTF-32 automatically. $sr = New-Object System.IO.StreamReader($Path, $true) try { return $sr.ReadToEnd() } finally { $sr.Dispose() } } function Write-Utf8NoBom { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [string]$Content ) $dir = Split-Path -Path $Path -Parent Ensure-Directory -Path $dir $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) } function Assert-InstructionSync { param( [Parameter(Mandatory = $true)] [string]$SourceDirectory, [Parameter(Mandatory = $true)] [string]$TargetDirectory, [Parameter(Mandatory = $true)] [System.IO.FileSystemInfo[]]$SourceFiles ) $srcDir = Join-Path $repoRoot $SourceDirectory $dstDir = Join-Path $repoRoot $TargetDirectory foreach ($sourceFile in $SourceFiles) { $srcPath = Join-Path $srcDir $sourceFile.Name $dstPath = Join-Path $dstDir $sourceFile.Name if (-not (Test-Path $dstPath -PathType Leaf)) { throw "Binary comparison failed. Destination file missing: $dstPath" } $srcBytes = [System.IO.File]::ReadAllBytes($srcPath) $dstBytes = [System.IO.File]::ReadAllBytes($dstPath) if ($srcBytes.Length -ne $dstBytes.Length) { throw "Binary comparison failed. Source and target size mismatch in binary: Source: $srcPath Target: $dstPath" } for ($i = 0; $i -lt $srcBytes.Length; $i++) { if ($srcBytes[$i] -ne $dstBytes[$i]) { throw "Binary comparison failed. Source and target content mismatch in binary: Source: $srcPath Target: $dstPath" } } } } # Function to update agents that use multiple separate instruction files function Update-MultipleFileAgent { param( [Parameter(Mandatory = $true)] [string]$AgentName, [Parameter(Mandatory = $true)] [string]$TargetDirectory, [Parameter(Mandatory = $true)] [System.IO.FileSystemInfo[]]$SourceFiles, [Parameter(Mandatory = $true)] [string]$RepoRoot ) Write-Host "`r`n--- Updating $AgentName Instructions ---" $targetDir = Join-Path $RepoRoot $TargetDirectory foreach ($sourceFile in $SourceFiles) { $targetFile = Join-Path $targetDir $sourceFile.Name Copy-FileIfDifferent -SourcePath $sourceFile.FullName -TargetPath $targetFile } Assert-InstructionSync -SourceDirectory ".ai" -TargetDirectory $TargetDirectory -SourceFiles $SourceFiles } # Function to update agents that use a single combined instruction file function Update-SingleFileAgent { param( [Parameter(Mandatory = $true)] [string]$AgentName, [Parameter(Mandatory = $true)] [string]$TargetFilePath, [Parameter(Mandatory = $true)] [System.IO.FileSystemInfo[]]$SourceFiles, [Parameter(Mandatory = $true)] [string]$RepoRoot ) Write-Host "`r`n--- Updating $AgentName Instructions ---" $targetFile = Join-Path $RepoRoot $TargetFilePath $relativeTarget = $targetFile.Substring($repoRoot.Length + 1) $allInstructionsContent = New-Object System.Text.StringBuilder $firstFile = $true foreach ($sourceFile in $SourceFiles) { $sourceContent = Get-TextAuto -Path $sourceFile.FullName if ([string]::IsNullOrWhiteSpace($sourceContent)) { Write-Warning "Skipping empty file: $($sourceFile.Name)" continue } if (-not $firstFile) { [void]$allInstructionsContent.AppendLine("") } [void]$allInstructionsContent.AppendLine("==== START OF INSTRUCTIONS FROM: $($sourceFile.Name) ====") [void]$allInstructionsContent.AppendLine("") [void]$allInstructionsContent.AppendLine("# Instructions from: $($sourceFile.Name)") [void]$allInstructionsContent.AppendLine("") [void]$allInstructionsContent.AppendLine($sourceContent.Trim()) [void]$allInstructionsContent.AppendLine("") [void]$allInstructionsContent.AppendLine("==== END OF INSTRUCTIONS FROM: $($sourceFile.Name) ====") $firstFile = $false } $finalContent = $allInstructionsContent.ToString() $existing = if (Test-Path -Path $targetFile -PathType Leaf) { Get-TextAuto -Path $targetFile } else { $null } if ($null -ne $existing -and $existing -eq $finalContent) { Write-Host "Up-to-date: $relativeTarget" return } Write-Utf8NoBom -Path $targetFile -Content $finalContent Write-Host "Updated: $relativeTarget" } function Invoke-RoboCopyMirror { param( [Parameter(Mandatory = $true)] [string]$SourceDirectory, [Parameter(Mandatory = $true)] [string]$DestinationDirectory, [Parameter(Mandatory = $true)] [string]$Label ) if (-not (Test-Path $SourceDirectory -PathType Container)) { Write-Host "No skills folder found at: $SourceDirectory" return } Ensure-Directory -Path $DestinationDirectory Write-Host "`r`n--- Mirroring skills to $Label ---" Write-Host "Source: $SourceDirectory" Write-Host "Destination: $DestinationDirectory" # /MIR = mirror (copy + delete removed) # /FFT = tolerate 2s timestamp granularity # /R:1 /W:1 = retry quickly # /NFL/NDL = no file/dir listing (keep output compact) # /NJH/NJS = no job header/summary # /NP = no progress # /XD = exclude version control/build dirs $excludedDirs = @('.git', '.vs', 'bin', 'obj') $args = @( $SourceDirectory, $DestinationDirectory, '/MIR', '/FFT', '/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP' ) foreach ($d in $excludedDirs) { $args += '/XD' $args += $d } $exe = 'robocopy' # Do not echo the full robocopy command; it is noisy and can wrap in some terminals. Write-Host "robocopy /MIR /NFL /NDL /NJH /NJS /NP ..." & $exe @args | Out-Null $exitCode = $LASTEXITCODE # Robocopy uses bitmask exit codes. # 0-7 are success with various flags; >= 8 indicates failure. if ($exitCode -ge 8) { throw "Robocopy failed with exit code $exitCode. Command: $cmd" } # IMPORTANT: robocopy returns 1+ for successful copies. # Ensure PowerShell script does not propagate a non-zero exit code for success cases. $global:LASTEXITCODE = 0 Write-Host "Mirrored skills to $Label (robocopy exit code $exitCode)." } function Sync-SkillsToRoo { param( [Parameter(Mandatory = $true)] [string]$RepoRoot ) $srcSkillsRoot = Join-Path $RepoRoot ".ai\skills" $rooSkillsRoot = Join-Path $RepoRoot ".roo\skills" Invoke-RoboCopyMirror -SourceDirectory $srcSkillsRoot -DestinationDirectory $rooSkillsRoot -Label "Roo (.roo\\skills)" } function Sync-SkillsToGitHub { param( [Parameter(Mandatory = $true)] [string]$RepoRoot ) $srcSkillsRoot = Join-Path $RepoRoot ".ai\skills" $githubSkillsRoot = Join-Path $RepoRoot ".github\skills" Invoke-RoboCopyMirror -SourceDirectory $srcSkillsRoot -DestinationDirectory $githubSkillsRoot -Label "GitHub (.github\\skills)" } function Sync-SkillsToClaude { param( [Parameter(Mandatory = $true)] [string]$RepoRoot ) $srcSkillsRoot = Join-Path $RepoRoot ".ai\skills" $claudeSkillsRoot = Join-Path $RepoRoot ".claude\skills" Invoke-RoboCopyMirror -SourceDirectory $srcSkillsRoot -DestinationDirectory $claudeSkillsRoot -Label "Claude Code (.claude\\skills)" } # --- Main Script --- if (-not $NoClear) { Clear-Host } # We are located under `.ai/skills//tools`. Find repo root by going up 4 levels. $scriptDir = $PSScriptRoot $repoRoot = (Join-Path -Path $scriptDir -ChildPath "..\..\..\.." | Resolve-Path).Path # `.ai` folder path $aiDir = Join-Path $repoRoot ".ai" # Discover source files matching *instructions.md in the .ai folder [System.IO.FileSystemInfo[]]$sourceInstructionFiles = Get-ChildItem -Path $aiDir -Filter "*instructions.md" -File | Sort-Object Name if ($null -eq $sourceInstructionFiles -or $sourceInstructionFiles.Length -eq 0) { Write-Warning "No '*instructions.md' files found in '$aiDir'. Nothing to process." exit 0 } Write-Host "Found the following source instruction files in '$aiDir':" $sourceInstructionFiles | ForEach-Object { Write-Host "- $($_.Name)" } # Mode parameter handling: if 'ALL' or 'AUTO', skip interactive prompt if ($Mode -eq 'ALL') { Write-Host "Selected: ALL (parameter mode)" $updateCline = $true $updateCopilot = $true $updateRooCode = $true $updateCodex = $true $updateClaude = $true } elseif ($Mode -eq 'AUTO') { Write-Host "Selected: AUTO (parameter mode)" # Determine available agents based on instruction files $updateCline = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.clinerules') $updateRooCode = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.roo\rules') $updateCopilot = Test-Path (Join-Path $repoRoot '.github\copilot-instructions.md') -PathType Leaf $updateCodex = Test-Path (Join-Path $repoRoot 'AGENTS.md') -PathType Leaf $updateClaude = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.claude') Write-Host "Agents to update based on available instruction files:" if ($updateCline) { Write-Host "- CLINE" } if ($updateRooCode) { Write-Host "- ROO CODE" } if ($updateCopilot) { Write-Host "- GitHub CoPilot" } if ($updateCodex) { Write-Host "- OpenAI Codex" } if ($updateClaude) { Write-Host "- Claude Code" } } elseif ($Mode -and $Mode -ne '') { # Specific agent mode (e.g., CLINE, "ROO CODE", etc.) $updateCline = ($Mode -eq 'CLINE') $updateCopilot = ($Mode -eq 'GitHub CoPilot') $updateRooCode = ($Mode -eq 'ROO CODE') $updateCodex = ($Mode -eq 'OpenAI Codex') $updateClaude = ($Mode -eq 'Claude Code') Write-Host "Selected: $Mode (parameter mode)" } else { # User prompt for agent selection # Detect available agents for interactive menu $hasCline = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.clinerules') $hasRooCode = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.roo\rules') $hasCopilot = Test-Path (Join-Path $repoRoot '.github\copilot-instructions.md') -PathType Leaf $hasCodex = Test-Path (Join-Path $repoRoot 'AGENTS.md') -PathType Leaf $hasClaude = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.claude') Write-Host "`r`nDetected AI agents with instruction files:" if ($hasCline) { Write-Host "- CLINE" } if ($hasRooCode) { Write-Host "- ROO CODE" } if ($hasCopilot) { Write-Host "- GitHub CoPilot" } if ($hasCodex) { Write-Host "- OpenAI Codex" } if ($hasClaude) { Write-Host "- Claude Code" } Write-Host "" Write-Host "==============================================================" Write-Host "Select Agent Instruction Set to Update" Write-Host "--------------------------------------------------------------" Write-Host "1. AUTO - Update only agents with instruction files (default)" Write-Host "2. ALL - Update instructions for all AI agents" Write-Host "3. CLINE - Update instructions for CLINE" Write-Host "4. ROO CODE - Update instructions for ROO CODE" Write-Host "5. GitHub CoPilot - Update instructions for GitHub CoPilot" Write-Host "6. OpenAI Codex - Update instructions for OpenAI Codex" Write-Host "7. Claude Code - Update instructions for Claude Code" Write-Host "0. Exit" Write-Host "==============================================================" $selection = Read-Host "Enter the number of your choice (0-7)" # Initialize flags $updateCline = $false $updateCopilot = $false $updateRooCode = $false $updateCodex = $false $updateClaude = $false switch ($selection) { '1' { $updateCline = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.clinerules') $updateRooCode = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.roo\rules') $updateCopilot = Test-Path (Join-Path $repoRoot '.github\copilot-instructions.md') -PathType Leaf $updateCodex = Test-Path (Join-Path $repoRoot 'AGENTS.md') -PathType Leaf $updateClaude = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.claude') Write-Host "Selected: AUTO" } '2' { $updateCline = $true $updateCopilot = $true $updateRooCode = $true $updateCodex = $true $updateClaude = $true Write-Host "Selected: ALL" } '3' { $updateCline = $true; Write-Host "Selected: CLINE" } '4' { $updateRooCode = $true; Write-Host "Selected: ROO CODE" } '5' { $updateCopilot = $true; Write-Host "Selected: GitHub CoPilot" } '6' { $updateCodex = $true; Write-Host "Selected: OpenAI Codex" } '7' { $updateClaude = $true; Write-Host "Selected: Claude Code" } '0' { Write-Host "Operation cancelled by user."; exit 0 } default { throw "Invalid selection. Exiting." } } } # --- Multiple-File Agent Updates --- if ($updateCline) { Update-MultipleFileAgent -AgentName "CLINE" -TargetDirectory ".clinerules" -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } if ($updateRooCode) { Update-MultipleFileAgent -AgentName "ROO CODE" -TargetDirectory ".roo\rules" -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } # --- Single-File Agent Updates --- if ($updateCopilot) { $copilotTarget = ".github\copilot-instructions.md" $githubInstructionsDir = Join-Path $repoRoot ".github\instructions" if (Test-Path $githubInstructionsDir -PathType Container) { Write-Host "`r`n--- Updating GitHub CoPilot Instructions (folder-based) ---" $mainName = "instructions.md" $mainSource = $sourceInstructionFiles | Where-Object { $_.Name -ieq $mainName } | Select-Object -First 1 if ($null -eq $mainSource) { throw "Expected source '$mainName' under .ai but none found." } Copy-FileIfDifferent -SourcePath $mainSource.FullName -TargetPath (Join-Path $repoRoot $copilotTarget) foreach ($sf in $sourceInstructionFiles) { if ($sf.Name -ieq $mainName) { continue } $destination = Join-Path $githubInstructionsDir $sf.Name Copy-FileIfDifferent -SourcePath $sf.FullName -TargetPath $destination } } else { Update-SingleFileAgent -AgentName "GitHub CoPilot" -TargetFilePath $copilotTarget -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } } if ($updateCodex) { Update-SingleFileAgent -AgentName "OpenAI Codex" -TargetFilePath "AGENTS.md" -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } # --- Claude Code (multiple-file agent) --- if ($updateClaude) { Update-MultipleFileAgent -AgentName "Claude Code" -TargetDirectory ".claude" -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } # --- Skills mirroring --- if ($updateRooCode -or $Mode -eq 'ALL' -or $Mode -eq 'AUTO') { Sync-SkillsToRoo -RepoRoot $repoRoot } # GitHub Copilot: mirror skills to `.github/skills` (Copilot tries to load from there). if ($updateCopilot -or $Mode -eq 'ALL' -or $Mode -eq 'AUTO') { Sync-SkillsToGitHub -RepoRoot $repoRoot } # Claude Code: mirror skills to `.claude/skills`. if ($updateClaude -or $Mode -eq 'ALL' -or $Mode -eq 'AUTO') { Sync-SkillsToClaude -RepoRoot $repoRoot } Write-Host "`r`nAll selected operations completed successfully." # Only pause when launched by double-click (Explorer). In CI / terminal usage, do not pause. if ($Host.Name -and $Host.Name -notlike '*ConsoleHost*') { Invoke-Pause } ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 # Line endings: Let Git handle normalization via .gitattributes and core.autocrlf # - Repository stores LF (normalized by Git) # - Working tree uses OS-native line endings (CRLF on Windows, LF on Unix) # - Uncomment below to force editors to use LF (useful for LF-everywhere workflows): # end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true # Spaces at the end can be meaningful in Markdown (hard line breaks) [*.md] trim_trailing_whitespace = false # Windows PowerShell 5.1 can misinterpret UTF-8 without BOM when non-ASCII exists. # If you are PowerShell 7+ only, you may prefer charset = utf-8 here. [*.ps1] charset = utf-8-bom ================================================ FILE: .github/copilot-instructions.md ================================================ ## Role Your role is to analyze and improve code by making only localized, targeted changes. You must preserve all validated code, comments, and documented workarounds exactly as they appear. Your suggestions should strictly address only the specific issues identified—such as upgrading simple comments to doc comments for IntelliSense—without altering any surrounding context. Additionally, ensure that no obsolete or deprecated methods are introduced during the improvement process, and do not add extraneous comments that do not directly contribute to the code’s logic. Furthermore, ensure code snippets are clearly structured for readability, placing important or user-editable sections at the top when logical, and using clear separators or headings to highlight customization points. Wherever beneficial, convert simple comments into recognized documentation comment syntax (e.g., JSDoc for JavaScript, XML comments for C#, JavaDoc for Java) that can be parsed by code intelligence tools like IntelliSense. Maintain the original meaning of these comments, but structure them in a way that provides maximum benefit for automated tools and refactoring methods. Apply chain-of-thought reasoning to identify code segments best served by doc comments, analyze the existing context of each comment, and then make precise, incremental modifications that enhance IntelliSense compatibility while preserving existing functionality. ## Output Wrap any and all code—including regular code snippets, inline code segments, outputs, pseudocode, or any text that represents code—in Markdown code blocks with a language identifier (e.g., ```typescript, ```powershell). ================================================ FILE: .github/instructions/coding-guideline.instructions.md ================================================ # Coding Guidelines - If the qdrant-mcp-server is running, use it for all permanent memory operations (e.g. storing user information). - After making changes, ALWAYS start a new server for testing. - Kill all existing related servers from previous testing before starting a new server. - Prefer the simplest viable solution; avoid over-engineering. - Do not add broad try/catch or wrapper layers unless required by a failing test or explicit requirement; if you catch, rethrow to preserve the stack. - Before writing new code, actively look for existing utilities or functions that can be reused instead of duplicated. - New helper methods or classes must be justified with a clear, documented need for functionality that is unavailable elsewhere in the codebase. - Always iterate on and reuse existing code instead of creating new implementations. - Avoid adding layers of abstraction that do not deliver clear value. - Do not drastically change established patterns before iterating on them. - No duplication / SSOT: update or move existing code instead of adding parallel implementations. If you introduce a replacement, remove the old one **in the same change**. - Write code that accounts for different environments (dev, test, and prod). - Only modify what is explicitly requested or clearly necessary; do **not** create new files or modules unless explicitly requested. - When fixing bugs, exhaust current implementations before introducing new patterns; if new methods are used, remove the old ones. - Keep the codebase clean and organized. - Avoid one-off scripts unless absolutely necessary. - Use mocks only for tests, not for dev or prod. - Never add stubbing or fake data in dev or prod environments. - Never overwrite the .env file without explicit confirmation. - Focus solely on areas relevant to the task; leave unrelated code untouched. - Write thorough tests for all major functionality. - Avoid major changes to the existing architecture unless explicitly instructed. - Always consider the impact on other methods and areas of the code. - Prefer to wrap long lines for better readability. - Preserve existing formatting; limit formatting to lines you changed and match surrounding style. Also remove any unused imports/usings or dead code **introduced by your edits**. - Stability primitives (repeatability > cleverness): when the repo provides an established way to perform an operation (repo-owned script, documented command snippet, standard PS1 under .ai/Scripts), treat it as the single source of truth. Use it verbatim instead of synthesizing “equivalent” commands. Only deviate when explicitly asked or when the primitive is proven broken in this environment (and then fix the primitive, not invent a parallel path). - No code file that you **create or modify** may exceed **6000 tokens (~24 KB)** once your changes are applied. - If your changes alone would push the file past this limit, either trim the change or ask for explicit permission to refactor; do **not** alter unrelated code solely to meet the limit. - Existing oversized files are left untouched unless the user explicitly requests a refactor. ## Source Control Conventions Naming conventions for issues, branches, and pull requests. ### Categories | Category | Use for | |----------|---------| | `FEAT` | New features | | `FIX` | Bug fixes | | `TECH` | Infrastructure, dependencies, refactoring | | `DOCS` | Documentation | ### Naming Patterns | Item | Pattern | Example | |------------|---------|---------| | **Issue** | `{CATEGORY}: {Description}` | `FEAT: Download logs to CSV` | | **Branch** | `{CATEGORY}-{issue#}-{lowercase-dashed-name}` | `FEAT-21-download-logs-to-csv` | | **PR** | `PR: #{issue#}: {CATEGORY}: {Description}` | `PR: #21: FEAT: Download logs to CSV` | - Branch names: lowercase, words separated by dashes, non-ASCII replaced with a single dash, derived from issue title but may be shortened. - PR body must reference the issue with `Closes #{issue#}`. - Merge via PR only — no direct pushes to `main` or `master`. - Feature branches are always created from `main` or `master`. Never merge one feature branch into another — only merge `main` if you need to catch up. - Always create branches from latest origin/main or origin/main, never merge sub-branches, confirm before risky git ops. - Do not add Co-Authored-By {AI model}/{AI company} lines to commits. - Never close issues before a release is published with the fix. Use the following guidelines: 1. Doc Comment Enhancement for IntelliSense - Replace or augment simple comments with relevant doc comment syntax that is supported by IntelliSense as needed. - Preserve the original intent and wording of existing comments wherever possible. 2. Code Layout for Clarity - Place the most important or user-editable sections at the top if logically appropriate. - Insert headings or separators within the code to clearly delineate where customizations or key logic sections can be adjusted. 3. No Extraneous Code Comments - Do not include "one-off" or user-directed commentary in the code. - Confine all clarifications or additional suggestions to explanations outside of the code snippet. 4. Avoid Outdated or Deprecated Methods - Refrain from introducing or relying on obsolete or deprecated methods and libraries. - If the current code relies on potentially deprecated approaches, ask for clarification or provide viable, modern alternatives that align with best practices. 5. Testing and Validation - Suggest running unit tests or simulations on the modified segments to confirm that the changes fix the issue without impacting overall functionality. - Ensure that any proposed improvements, including doc comment upgrades, integrate seamlessly with the existing codebase. - After all code modifications, navigate to the affected project directory and build C# then Angular to confirm the application compiles without errors: cd {PROJECT} && dotnet build {PROJECT}.csproj cd {PROJECT}/ClientApp && ng build - Run relevant unit tests if code changes affect core logic. - If the developer certificate is not trusted, then execute: dotnet dev-certs https --trust - To launch project use: dotnet watch run --project {PROJECT}/{PROJECT}.csproj --launch-profile "{PROJECT} (NG Build)" 6. Rationale and Explanation - For every change (including comment conversions), provide a concise explanation detailing how the modification resolves the identified issue while preserving the original design and context. - Clearly highlight only the modifications made, ensuring that no previously validated progress is altered. - NOTE: Summarize reasoning for the user, but do NOT expose full chain-of-thought. Keep internal deliberations internal; surface only the concise rationale needed to justify each change. 7. Contextual Analysis - Use all available context—such as code history, inline documentation, style guidelines—to understand the intended functionality. - When inspecting an existing file for understanding, prefer reading the whole file in a single `read_file` call when it comfortably fits in context; switch to targeted slices only when the file is too large, the tool truncates it, or a specific anchor line is already known. - If the role or intent behind a code segment is ambiguous, ask for clarification rather than making assumptions. 8. Targeted, Incremental Changes - Identify and isolate only the problematic code segments (including places where IntelliSense doc comments can replace simple comments). - Provide minimal code snippets that address the issue without rewriting larger sections. - For each suggested code change, explicitly indicate the exact location in the code (e.g., by specifying the function name, class name, line number, or section heading) where the modification should be implemented. 9. Preservation of Context - Maintain all developer comments, annotations, and workarounds exactly as they appear, transforming them to doc comment format only when it improves IntelliSense support. - Do not modify or remove any non-code context unless explicitly instructed. - Avoid introducing new, irrelevant comments in the code. 10. Launching {PROJECT} Correctly: - Navigate to the {PROJECT} project folder. - Run the following command to launch the project with live reload and proper debugging configuration: dotnet watch run --launch-profile "{PROJECT} (NG Build)" --project {PROJECT}/{PROJECT}.csproj - This command will start the {PROJECT} project on the designated debugging session URL. - Ensure that any previous {PROJECT} instances are terminated before running this command. ================================================ FILE: .github/instructions/repository-analysis.instructions.md ================================================ # Repository Analysis ## 1. Repository Overview This document provides a factual reference for the Jocys.com FocusLogger repository, aimed at developers and AI coding agents working on the codebase. **FocusLogger** is a Windows desktop utility that monitors and logs which process or program takes window focus. It targets users (especially gamers and power users) who experience unexpected focus stealing — where a background process briefly grabs foreground focus, interrupting gameplay or work. The tool logs every focus change with timestamps, process details, window class names, and focus-state flags, allowing users to identify the culprit. - **Repository:** https://github.com/JocysCom/FocusLogger - **License:** GNU General Public License v3.0 - **Current version:** 1.2.6 - **Target platform:** Windows 10+ with .NET 8.0 - **Primary audiences:** Gamers, power users, IT support personnel diagnosing focus-stealing issues. ## 2. Top-Level Structure This section maps every top-level directory and file to help navigate the repository quickly. | Path | Purpose | |------|---------| | `FocusLogger/` | Main application project (WPF, .NET 8.0). Contains all app source code, shared library, and resources. | | `FocusLogger.Tests/` | MSTest test project. Unit tests for CSV export and UI automation tests. | | `Documents/` | Release engineering: signing scripts, zip packaging scripts, screenshot tooling, and pre-built release files. | | `Resources/` | Solution-level shared scripts (currently `ZipFiles.ps1` for checksum-aware zip packaging). | | `.ai/` | AI agent instructions, coding guidelines, repository analysis, and skills. | | `JocysCom.FocusLogger.slnx` | Solution file (XML-based `.slnx` format) referencing the two projects. | | `README.md` | Project overview, download link, system requirements, screenshot. | | `LICENSE` | GPLv3 license text. | | `SECURITY.md` | Security vulnerability reporting policy (support@jocys.com). | | `Settings.XamlStyler` | XamlStyler configuration for consistent XAML formatting. | | `Solution_Cleanup.ps1` | PowerShell script for cleaning build artifacts. | ## 3. Technology Stack & Key Dependencies This section lists verified technologies and versions drawn from project files. | Technology | Version / Detail | Evidence | |------------|-----------------|----------| | .NET | 8.0 (`net8.0-windows`) | `JocysCom.FocusLogger.csproj` TargetFramework | | C# | Implicit (SDK default for .NET 8) | SDK-style project | | WPF | `true` | csproj | | Windows Forms interop | `true` | csproj — used for P/Invoke helpers and DPI awareness | | MSTest | v3.x (`MSTest.TestFramework 3.*`, `MSTest.TestAdapter 3.*`) | Test csproj PackageReference | | Microsoft.NET.Test.Sdk | 17.x | Test csproj PackageReference | | Windows API (user32.dll) | P/Invoke | `NativeMethods.cs` | | PowerShell | Scripts for build, sign, zip, cleanup | `Documents/`, `Resources/`, root | | XamlStyler | Config present | `Settings.XamlStyler` | **No NuGet package dependencies** in the main application project — all functionality comes from .NET SDK and the embedded `JocysCom.ClassLibrary`. ## 4. Architecture & Runtime Model This section describes how the application is structured and how it operates at runtime. FocusLogger is a **single-executable WPF desktop application** that polls Windows API functions to detect focus changes. It does not persist log data between sessions (in-memory only) but provides CSV export for offline analysis. ### Architectural layers ```mermaid graph TD subgraph UI["UI Layer (WPF)"] App["App.xaml.cs
Entry point, DPI aware"] MW["MainWindow.xaml.cs
Main window frame"] DLC["DataListControl.xaml.cs
Core logging UI + logic"] end subgraph Core["Core Logic"] DI["DataItem.cs
Log entry model"] DIT["DataItemType.cs
Entry type enum"] NM["NativeMethods.cs
P/Invoke declarations"] CSV["CSV Export
BuildCsvContent, CsvEscape"] end subgraph Shared["JocysCom.ClassLibrary (embedded)"] Config["Configuration
SettingsData, SettingsItem, AssemblyInfo"] CompModel["ComponentModel
SortableBindingList, BindingListInvoked"] Controls["Controls
ControlsHelper, ItemFormattingConverter,
InfoControl, MessageBoxWindow"] Other["Collections, IO, Text, Runtime, Data"] end subgraph WinAPI["Windows OS"] User32["user32.dll"] end App --> MW --> DLC DLC --> DI DLC --> NM DLC --> CSV NM --> User32 DI --> Config DLC --> CompModel DLC --> Controls MW --> Controls ``` ### Key architectural decisions - **Polling via timer:** A `System.Timers.Timer` with 1ms interval (non-auto-reset) continuously polls `GetActiveWindow()` and `GetForegroundWindow()`. Duplicate events are suppressed via `DataItem.IsSame()`. - **Thread safety:** Timer fires on a thread-pool thread; UI updates are marshalled via `ControlsHelper.BeginInvoke()`. A `lock(AddLock)` synchronizes the polling logic. - **Embedded shared library:** `JocysCom.ClassLibrary` files are included directly in `FocusLogger/JocysCom/` rather than as a compiled DLL or NuGet package. - **No MVVM framework:** Code-behind pattern with data binding. `DataListControl.xaml.cs` contains both view-model-like logic and model interaction. ## 5. Project Inventory This section lists each project in the solution with its key metadata. ### 5.1 JocysCom.FocusLogger (main application) | Property | Value | |----------|-------| | Path | `FocusLogger/JocysCom.FocusLogger.csproj` | | Output type | `WinExe` | | Target framework | `net8.0-windows` | | Assembly name | `JocysCom.FocusLogger` | | Description | Find out which process or program is taking the window focus. In game, mouse and keyboard could temporarily stop responding if another program takes the focus. This tool could help diagnose which program is stealing the focus. | | Version | 1.2.6 | | NuGet dependencies | None | | Embedded resources | `Resources/BuildDate.txt` (auto-generated), `Resources/AiAnalysisPrompt.md` | **Source structure:** | Directory | Contents | |-----------|----------| | `FocusLogger/` (root) | `App.xaml(.cs)`, `MainWindow.xaml(.cs)`, `AssemblyInfo.cs`, `App.ico` | | `FocusLogger/Common/` | `DataItem.cs`, `DataItemType.cs`, `NativeMethods.cs` | | `FocusLogger/Controls/` | `DataListControl.xaml(.cs)` — core logging control | | `FocusLogger/JocysCom/` | Embedded `JocysCom.ClassLibrary` (~30 files across Collections, Common, ComponentModel, Configuration, Controls, Data, IO, Runtime, Text) | | `FocusLogger/Resources/` | `AiAnalysisPrompt.md`, `BuildDate.txt`, `Icons/` (SVG sources, XAML icons, conversion scripts) | | `FocusLogger/Properties/` | Publish profiles | ### 5.2 JocysCom.FocusLogger.Tests (test project) | Property | Value | |----------|-------| | Path | `FocusLogger.Tests/JocysCom.FocusLogger.Tests.csproj` | | Target framework | `net8.0-windows` | | Test framework | MSTest v3.x | | Project reference | `FocusLogger/JocysCom.FocusLogger.csproj` | **Test files:** | File | Purpose | |------|---------| | `CsvExportTests.cs` | Unit tests for `CsvEscape` and `BuildCsvContent` methods | | `UIAutomationTests.cs` | UI automation tests using `System.Windows.Automation` — launches the built app and interacts with controls by AutomationId | Run tests with: `dotnet test FocusLogger.Tests/JocysCom.FocusLogger.Tests.csproj` ## 6. Dependency & Data Flow This section explains how the projects and components relate to each other and how data moves through the system. ### Project dependency graph ```mermaid graph LR Tests["FocusLogger.Tests
(MSTest v3)"] -->|ProjectReference| App["JocysCom.FocusLogger
(WPF App)"] App -->|embedded source| Shared["JocysCom.ClassLibrary
(in FocusLogger/JocysCom/)"] App -->|P/Invoke| WinAPI["user32.dll"] ``` ### Runtime data flow 1. `System.Timers.Timer` fires (1ms interval, non-auto-reset). 2. `DataListControl.UpdateInfo()` acquires `AddLock`. 3. Calls `NativeMethods.GetActiveWindow()` and `NativeMethods.GetForegroundWindow()`. 4. For each handle, `GetItemFromHandle()` creates a `DataItem` with timestamp, focus flags (mouse/keyboard/caret), window title, and window class name. 5. `IsSame()` checks if the event differs from the previous one; if not, it is skipped. 6. `UpdateFromProcess()` enriches the `DataItem` with process name and path (with error handling for restricted processes). 7. The item is inserted at position 0 of `SortableBindingList` via `ControlsHelper.BeginInvoke()` (UI thread dispatch). 8. The WPF `DataGrid` updates via data binding. `ItemFormattingConverter` translates boolean flags to icons. ### CSV export flow 1. User clicks "Save CSV" button. 2. `SaveFileDialog` prompts for file location. 3. `BuildCsvContent()` iterates all `DataItem` entries, writing CSV with headers: Date, PID, Process Name, Active, Mouse, Keyboard, Caret, Window Title, Window Class, Path. 4. File is written as UTF-8. 5. "Explore" button opens the saved file location in Explorer. 6. "AI Prompt Example" button shows the embedded `AiAnalysisPrompt.md` in a `MessageBoxWindow` for users to copy and paste into an AI assistant along with their CSV. ## 7. Build, Test, CI/CD & Operational Workflows This section documents how the project is built, tested, and released based on repository evidence. ### Build ```bash dotnet build JocysCom.FocusLogger.slnx ``` - **Pre-build event:** Generates `Resources/BuildDate.txt` with the current ISO 8601 timestamp via PowerShell. - **Output:** Single `JocysCom.FocusLogger.exe` in `bin/{Configuration}/net8.0-windows/`. - **Debug configuration:** Embedded PDB symbols (`DebugType: embedded`). ### Test ```bash dotnet test FocusLogger.Tests/JocysCom.FocusLogger.Tests.csproj ``` - **Framework:** MSTest v3.x with Microsoft.NET.Test.Sdk 17.x. - **Unit tests:** `CsvExportTests` — validates CSV escaping and content generation. - **UI automation tests:** `UIAutomationTests` — launches the built application and interacts via `System.Windows.Automation`. Requires a prior build of the main project. ### Release / packaging scripts | Script | Purpose | |--------|---------| | `Documents/App_1_Sign.ps1` | Code-signs the application executable. | | `Documents/App_2_Zip.ps1` | Packages the signed executable into a release ZIP. | | `Resources/ZipFiles.ps1` | Shared utility for checksum-aware ZIP creation (compares source/dest checksums before rebuilding). | | `Documents/Take_Screenshot.ps1` | Captures application screenshot for documentation. | | `Documents/Take_Screenshot.ps1.cs` | C# helper compiled by the screenshot script. | | `Solution_Cleanup.ps1` | Cleans `bin/`, `obj/`, and other build artifacts. | ### Icon workflow SVG icon sources are stored in `FocusLogger/Resources/Icons/Icons_Default/`. The script `Convert_SVG_to_XAML.ps1` converts them to XAML resource dictionaries (`Icons_Default.xaml`). ### CI/CD No CI/CD workflow files were found under `.github/workflows/`. Builds and releases appear to be performed locally. ## 8. Documentation Map This section identifies where documentation lives in the repository. | Location | Audience | Content | |----------|----------|---------| | `README.md` | End users, contributors | Project overview, download link, system requirements, screenshot | | `SECURITY.md` | Security researchers | Vulnerability reporting policy | | `LICENSE` | All | GPLv3 full text | | `.ai/ReadMe.md` | AI agents, developers | Explains the purpose of each file in the `.ai/` directory and how custom instructions work for Copilot, CLINE, and Codex | | `.ai/instructions.md` | AI agents | Role definition and output formatting rules for AI-assisted edits | | `.ai/coding-guideline.instructions.md` | AI agents | Detailed coding guidelines, source control conventions (branch/PR naming), testing workflow, and constraints for AI agents | | `.ai/repository-analysis.instructions.md` | AI agents, developers | This file — comprehensive repository reference | | `.ai/skills/` | AI agents | Skill definitions (e.g., `ai-self-improvement`) for agent-assisted workflows | | `FocusLogger/Resources/AiAnalysisPrompt.md` | End users | Prompt template for users to paste into AI assistants alongside exported CSV logs | | `Documents/Images/` | README, users | Application screenshot | | `Settings.XamlStyler` | Developers | XamlStyler formatting configuration | ### Documentation taxonomy ```mermaid graph TD subgraph EndUsers["End-User Documentation"] README["README.md
Overview, download, requirements"] SECURITY["SECURITY.md
Vulnerability reporting"] LICENSE["LICENSE
GPLv3"] AiPrompt["FocusLogger/Resources/
AiAnalysisPrompt.md
Prompt template for CSV analysis"] Screenshot["Documents/Images/
Application screenshot"] end subgraph AIAgents["AI Agent Instructions"] AiReadMe[".ai/ReadMe.md
Directory guide"] AiInstr[".ai/instructions.md
Role & output rules"] AiCoding[".ai/coding-guideline
.instructions.md
Coding & SCM conventions"] AiRepo[".ai/repository-analysis
.instructions.md
This file"] AiSkills[".ai/skills/
Agent skill definitions"] end subgraph DevConfig["Developer Configuration"] XamlStyler["Settings.XamlStyler
XAML formatting"] end ``` ## 9. AI-Agent-Relevant Conventions and Constraints This section captures rules and patterns that materially affect automated edits. The full set of coding and source-control conventions is defined in `.ai/coding-guideline.instructions.md`; the highlights below are the items most likely to cause mistakes if overlooked. 1. **Coding style:** Follow Microsoft C# conventions. PascalCase for public members, camelCase for locals. Some private fields use `_PascalCase` (e.g., `_Date`). Preserve existing naming patterns in each file. Prefer the simplest viable solution; avoid over-engineering. 2. **Doc comments:** Per `.ai/instructions.md`, convert simple comments to XML documentation comments where beneficial for IntelliSense. Do not alter surrounding code when doing so. 3. **Shared library files (`FocusLogger/JocysCom/`):** These are embedded from a shared `JocysCom.ClassLibrary`. Exercise caution when editing — changes here may diverge from the upstream library. 4. **XAML formatting:** The repository uses XamlStyler (see `Settings.XamlStyler`). XAML edits should conform to the configured style. 5. **No NuGet packages in the main app:** All dependencies are framework-provided or embedded source. Do not introduce NuGet package dependencies without explicit approval. 6. **Test project uses MSTest v3:** New tests should follow MSTest v3 patterns (`[TestClass]`, `[TestMethod]`, `Assert.*`). 7. **UI automation tests depend on a built executable:** `UIAutomationTests` locate the app at a relative path from the test output. Building the main project before running these tests is required. 8. **Pre-build event:** The csproj generates `Resources/BuildDate.txt` via PowerShell. This file should not be manually edited or committed. 9. **Solution format:** Uses `.slnx` (XML-based solution format), not the older `.sln` text format. 10. **No CI/CD pipelines:** All build and release steps are manual/local. Scripts in `Documents/` handle signing and packaging. 11. **File size limit:** No code file that is created or modified may exceed 6000 tokens (~24 KB). 12. **Source control conventions:** Issues use `{CATEGORY}: {Description}` (FEAT, FIX, TECH, DOCS). Branches use `{CATEGORY}-{issue#}-{lowercase-dashed-name}`. PRs use `PR: #{issue#}: {CATEGORY}: {Description}` and must reference the issue with `Closes #{issue#}`. Merge via PR only — no direct pushes to `main`. 13. **No Co-Authored-By lines:** Do not add `Co-Authored-By` AI model/company lines to commits. 14. **Issue closure:** Never close issues before a release is published with the fix. 15. **No duplication / SSOT:** Update or move existing code instead of adding parallel implementations. If introducing a replacement, remove the old one in the same change. 16. **Stability primitives:** When the repo provides an established script or command, use it verbatim. Only deviate when it is proven broken, and then fix the primitive rather than inventing a parallel path. ================================================ FILE: .github/skills/ai-self-improvement/SKILL.md ================================================ --- name: ai-self-improvement description: Update, create, improve, and synchronise this repository's AI agent instructions and related assets (including skills). Use when the user asks to create or edit a skill/SKILL.md, modify the agent's own instructions/processes, restructure instruction governance, migrate instruction content into skills, or run/adjust the sync pipeline that publishes `.ai/` sources into agent-specific folders. Load this skill before writing any SKILL.md, .instructions.md, or touching any skills/ folder (.ai/, .claude/, .roo/, .github/). It tells you the correct location (.ai/) and the sync step, so files end up in the right place. --- # AI Self-Improvement (Instructions + Skills) ## Critical: `.ai/` is the Primary Source for ALL Agents The `.ai/` folder is the **single source of truth** for all AI agent configurations in this repository. This applies to: - **CLINE / Roo Code** — synced to `.roo/rules/` and `.roo/skills/` - **GitHub Copilot** — synced to `.github/copilot-instructions.md` - **OpenAI Codex / AGENTS.md** — synced to `AGENTS.md` at repo root - **Claude Code** — synced to `.claude/*.instructions.md` and `.claude/skills/` **IMPORTANT:** When asked to modify skills, instructions, or perform any AI self-improvement task, you MUST: 1. Locate the source file under `.ai/` (not the agent-specific output) 2. Make changes to the `.ai/` source 3. Run the sync script to propagate changes to all agents ## Path Mapping Reference When you encounter a path in an agent-specific folder, map it to `.ai/`: | Agent-Specific Path | Source Path (Edit Here) | |---------------------|------------------------| | `.roo/rules/*.md` | `.ai/*.instructions.md` | | `.roo/skills//SKILL.md` | `.ai/skills//SKILL.md` | | `.github/copilot-instructions.md` | `.ai/instructions.md` (generated) | | `AGENTS.md` | `.ai/instructions.md` (generated) | | `.claude/*.instructions.md` | `.ai/*.instructions.md` | | `.claude/skills//SKILL.md` | `.ai/skills//SKILL.md` | **Example:** If asked to update `.roo/skills/ai-self-improvement/SKILL.md`, you must edit `.ai/skills/ai-self-improvement/SKILL.md` instead. ## Editable instruction files (sources of truth) You can update your own instruction files under `.ai/`: - `.ai/instructions.md` — the main system instructions file - `.ai/*instructions.md` — additional instruction files (auto-included) - `.ai/*instructions-detail.md` — detailed instruction files (read only when needed) - `.ai/skills//SKILL.md` — skill definition files ## Workflow 1. Treat `.ai/` as the **single source of truth** for agent instructions **and skills**. 2. When creating or migrating a skill, create/update it under `.ai/skills/`. 3. Make instruction changes in `.ai/instructions.md` and related `*.instructions.md` / `*.instructions-detail.md` files. 4. Do **not** edit generated outputs directly (they are produced by the sync script): - `.roo/rules/` - `.roo/skills/` - `.github/copilot-instructions.md` - `AGENTS.md` - `.claude/` 5. **Test changes before syncing** — verify scripts execute correctly and changes work as expected. 6. After testing, run the sync script to apply to all agents. ## Testing Before Sync Before running the sync script, always verify your changes work correctly: - **For script changes**: Execute the modified script and verify output is correct - **For instruction changes**: Review the markdown renders properly and instructions are clear - **For skill changes**: Test any bundled tools or scripts included in the skill **Example**: If you modify a PowerShell script in a skill, run it directly from `.ai/skills//scripts/` to confirm it works before syncing. ## Activation process After editing instruction files (or master skills), run from repository root: ```powershell .\.ai\skills\ai-self-improvement\scripts\Sync-AgentAssets.ps1 AUTO ``` This script synchronizes changes from `.ai/` to all agent-specific folders. ## Single source of truth **Never embed template content in instructions — reference template files instead.** Example: - ✅ "Template maintained in `pr/checklist.template.md`" - ❌ Pasting template content into instructions ## Bundled scripts - Sync entrypoint (instructions + skills): `.ai/skills/ai-self-improvement/scripts/Sync-AgentAssets.ps1` ================================================ FILE: .github/skills/ai-self-improvement/scripts/Sync-AgentAssets.ps1 ================================================ # Script: Sync-AgentAssets.ps1 # Location: .ai/skills/ai-self-improvement/scripts/Sync-AgentAssets.ps1 # Description: # Synchronises AI agent instruction files and skills from master sources under `.ai/`. # - Instructions: copies `*.instructions.md` from `.ai/` into agent-specific outputs. # - Skills: mirrors `.ai/skills/*` into agent skill folders (e.g. `.roo/skills/*`). # # Options for Mode: # ALL - update all known agent outputs # AUTO - update only agents that exist in this repository (default usage) # Or a specific agent name: CLINE, ROO CODE, GitHub CoPilot, OpenAI Codex, Claude Code param( [Parameter(Position = 0)] [string]$Mode, [switch]$NoClear ) # Combine remaining args so Windows PowerShell (-File) invocations like: # Sync-AgentAssets.ps1 GitHub CoPilot # work the same as: # Sync-AgentAssets.ps1 "GitHub CoPilot" if ($args.Count -gt 0) { $ModeFromArgs = ($args -join ' ') if (-not $Mode -or $Mode -eq '') { $Mode = $ModeFromArgs } } # Allow calling via the old filename (if invoked through a copied/renamed script). # This only affects displayed script name in prompts/logs. $scriptName = [System.IO.Path]::GetFileName($MyInvocation.MyCommand.Path) # Strict mode Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Ensure-Directory { param( [Parameter(Mandatory = $true)] [string]$Path ) if (-not (Test-Path -Path $Path -PathType Container)) { New-Item -ItemType Directory -Force -Path $Path | Out-Null } } # Function to check if instruction files exist in a directory function Test-HasInstructionFiles { param( [Parameter(Mandatory = $true)] [string]$Path, [string]$Filter = '*instructions.md' ) if (Test-Path $Path -PathType Container) { $files = @(Get-ChildItem $Path -Filter $Filter -File -ErrorAction SilentlyContinue) return ($files.Length -gt 0) } return $false } # Function to pause at the end (unless -NoWait is specified) function Invoke-Pause { Write-Host "Pausing for 2 seconds..." Start-Sleep -Seconds 2 } function Copy-FileIfDifferent { param( [Parameter(Mandatory = $true)] [string]$SourcePath, [Parameter(Mandatory = $true)] [string]$TargetPath ) $targetDir = Split-Path -Path $TargetPath -Parent Ensure-Directory -Path $targetDir if (-not (Test-Path -Path $TargetPath -PathType Leaf)) { Copy-Item -LiteralPath $SourcePath -Destination $TargetPath -Force $relative = $TargetPath.Substring($repoRoot.Length + 1) Write-Host "Created: $relative" return } $srcBytes = [System.IO.File]::ReadAllBytes($SourcePath) $dstBytes = [System.IO.File]::ReadAllBytes($TargetPath) if ($srcBytes.Length -eq $dstBytes.Length) { $same = $true for ($i = 0; $i -lt $srcBytes.Length; $i++) { if ($srcBytes[$i] -ne $dstBytes[$i]) { $same = $false; break } } if ($same) { $relative = $TargetPath.Substring($repoRoot.Length + 1) Write-Host "Up-to-date: $relative" return } } Copy-Item -LiteralPath $SourcePath -Destination $TargetPath -Force $relative = $TargetPath.Substring($repoRoot.Length + 1) Write-Host "Updated: $relative" } function Get-TextAuto { param( [Parameter(Mandatory = $true)] [string]$Path ) # .NET StreamReader detects BOM for UTF-8/UTF-16/UTF-32 automatically. $sr = New-Object System.IO.StreamReader($Path, $true) try { return $sr.ReadToEnd() } finally { $sr.Dispose() } } function Write-Utf8NoBom { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [string]$Content ) $dir = Split-Path -Path $Path -Parent Ensure-Directory -Path $dir $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) } function Assert-InstructionSync { param( [Parameter(Mandatory = $true)] [string]$SourceDirectory, [Parameter(Mandatory = $true)] [string]$TargetDirectory, [Parameter(Mandatory = $true)] [System.IO.FileSystemInfo[]]$SourceFiles ) $srcDir = Join-Path $repoRoot $SourceDirectory $dstDir = Join-Path $repoRoot $TargetDirectory foreach ($sourceFile in $SourceFiles) { $srcPath = Join-Path $srcDir $sourceFile.Name $dstPath = Join-Path $dstDir $sourceFile.Name if (-not (Test-Path $dstPath -PathType Leaf)) { throw "Binary comparison failed. Destination file missing: $dstPath" } $srcBytes = [System.IO.File]::ReadAllBytes($srcPath) $dstBytes = [System.IO.File]::ReadAllBytes($dstPath) if ($srcBytes.Length -ne $dstBytes.Length) { throw "Binary comparison failed. Source and target size mismatch in binary: Source: $srcPath Target: $dstPath" } for ($i = 0; $i -lt $srcBytes.Length; $i++) { if ($srcBytes[$i] -ne $dstBytes[$i]) { throw "Binary comparison failed. Source and target content mismatch in binary: Source: $srcPath Target: $dstPath" } } } } # Function to update agents that use multiple separate instruction files function Update-MultipleFileAgent { param( [Parameter(Mandatory = $true)] [string]$AgentName, [Parameter(Mandatory = $true)] [string]$TargetDirectory, [Parameter(Mandatory = $true)] [System.IO.FileSystemInfo[]]$SourceFiles, [Parameter(Mandatory = $true)] [string]$RepoRoot ) Write-Host "`r`n--- Updating $AgentName Instructions ---" $targetDir = Join-Path $RepoRoot $TargetDirectory foreach ($sourceFile in $SourceFiles) { $targetFile = Join-Path $targetDir $sourceFile.Name Copy-FileIfDifferent -SourcePath $sourceFile.FullName -TargetPath $targetFile } Assert-InstructionSync -SourceDirectory ".ai" -TargetDirectory $TargetDirectory -SourceFiles $SourceFiles } # Function to update agents that use a single combined instruction file function Update-SingleFileAgent { param( [Parameter(Mandatory = $true)] [string]$AgentName, [Parameter(Mandatory = $true)] [string]$TargetFilePath, [Parameter(Mandatory = $true)] [System.IO.FileSystemInfo[]]$SourceFiles, [Parameter(Mandatory = $true)] [string]$RepoRoot ) Write-Host "`r`n--- Updating $AgentName Instructions ---" $targetFile = Join-Path $RepoRoot $TargetFilePath $relativeTarget = $targetFile.Substring($repoRoot.Length + 1) $allInstructionsContent = New-Object System.Text.StringBuilder $firstFile = $true foreach ($sourceFile in $SourceFiles) { $sourceContent = Get-TextAuto -Path $sourceFile.FullName if ([string]::IsNullOrWhiteSpace($sourceContent)) { Write-Warning "Skipping empty file: $($sourceFile.Name)" continue } if (-not $firstFile) { [void]$allInstructionsContent.AppendLine("") } [void]$allInstructionsContent.AppendLine("==== START OF INSTRUCTIONS FROM: $($sourceFile.Name) ====") [void]$allInstructionsContent.AppendLine("") [void]$allInstructionsContent.AppendLine("# Instructions from: $($sourceFile.Name)") [void]$allInstructionsContent.AppendLine("") [void]$allInstructionsContent.AppendLine($sourceContent.Trim()) [void]$allInstructionsContent.AppendLine("") [void]$allInstructionsContent.AppendLine("==== END OF INSTRUCTIONS FROM: $($sourceFile.Name) ====") $firstFile = $false } $finalContent = $allInstructionsContent.ToString() $existing = if (Test-Path -Path $targetFile -PathType Leaf) { Get-TextAuto -Path $targetFile } else { $null } if ($null -ne $existing -and $existing -eq $finalContent) { Write-Host "Up-to-date: $relativeTarget" return } Write-Utf8NoBom -Path $targetFile -Content $finalContent Write-Host "Updated: $relativeTarget" } function Invoke-RoboCopyMirror { param( [Parameter(Mandatory = $true)] [string]$SourceDirectory, [Parameter(Mandatory = $true)] [string]$DestinationDirectory, [Parameter(Mandatory = $true)] [string]$Label ) if (-not (Test-Path $SourceDirectory -PathType Container)) { Write-Host "No skills folder found at: $SourceDirectory" return } Ensure-Directory -Path $DestinationDirectory Write-Host "`r`n--- Mirroring skills to $Label ---" Write-Host "Source: $SourceDirectory" Write-Host "Destination: $DestinationDirectory" # /MIR = mirror (copy + delete removed) # /FFT = tolerate 2s timestamp granularity # /R:1 /W:1 = retry quickly # /NFL/NDL = no file/dir listing (keep output compact) # /NJH/NJS = no job header/summary # /NP = no progress # /XD = exclude version control/build dirs $excludedDirs = @('.git', '.vs', 'bin', 'obj') $args = @( $SourceDirectory, $DestinationDirectory, '/MIR', '/FFT', '/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP' ) foreach ($d in $excludedDirs) { $args += '/XD' $args += $d } $exe = 'robocopy' # Do not echo the full robocopy command; it is noisy and can wrap in some terminals. Write-Host "robocopy /MIR /NFL /NDL /NJH /NJS /NP ..." & $exe @args | Out-Null $exitCode = $LASTEXITCODE # Robocopy uses bitmask exit codes. # 0-7 are success with various flags; >= 8 indicates failure. if ($exitCode -ge 8) { throw "Robocopy failed with exit code $exitCode. Command: $cmd" } # IMPORTANT: robocopy returns 1+ for successful copies. # Ensure PowerShell script does not propagate a non-zero exit code for success cases. $global:LASTEXITCODE = 0 Write-Host "Mirrored skills to $Label (robocopy exit code $exitCode)." } function Sync-SkillsToRoo { param( [Parameter(Mandatory = $true)] [string]$RepoRoot ) $srcSkillsRoot = Join-Path $RepoRoot ".ai\skills" $rooSkillsRoot = Join-Path $RepoRoot ".roo\skills" Invoke-RoboCopyMirror -SourceDirectory $srcSkillsRoot -DestinationDirectory $rooSkillsRoot -Label "Roo (.roo\\skills)" } function Sync-SkillsToGitHub { param( [Parameter(Mandatory = $true)] [string]$RepoRoot ) $srcSkillsRoot = Join-Path $RepoRoot ".ai\skills" $githubSkillsRoot = Join-Path $RepoRoot ".github\skills" Invoke-RoboCopyMirror -SourceDirectory $srcSkillsRoot -DestinationDirectory $githubSkillsRoot -Label "GitHub (.github\\skills)" } function Sync-SkillsToClaude { param( [Parameter(Mandatory = $true)] [string]$RepoRoot ) $srcSkillsRoot = Join-Path $RepoRoot ".ai\skills" $claudeSkillsRoot = Join-Path $RepoRoot ".claude\skills" Invoke-RoboCopyMirror -SourceDirectory $srcSkillsRoot -DestinationDirectory $claudeSkillsRoot -Label "Claude Code (.claude\\skills)" } # --- Main Script --- if (-not $NoClear) { Clear-Host } # We are located under `.ai/skills//tools`. Find repo root by going up 4 levels. $scriptDir = $PSScriptRoot $repoRoot = (Join-Path -Path $scriptDir -ChildPath "..\..\..\.." | Resolve-Path).Path # `.ai` folder path $aiDir = Join-Path $repoRoot ".ai" # Discover source files matching *instructions.md in the .ai folder [System.IO.FileSystemInfo[]]$sourceInstructionFiles = Get-ChildItem -Path $aiDir -Filter "*instructions.md" -File | Sort-Object Name if ($null -eq $sourceInstructionFiles -or $sourceInstructionFiles.Length -eq 0) { Write-Warning "No '*instructions.md' files found in '$aiDir'. Nothing to process." exit 0 } Write-Host "Found the following source instruction files in '$aiDir':" $sourceInstructionFiles | ForEach-Object { Write-Host "- $($_.Name)" } # Mode parameter handling: if 'ALL' or 'AUTO', skip interactive prompt if ($Mode -eq 'ALL') { Write-Host "Selected: ALL (parameter mode)" $updateCline = $true $updateCopilot = $true $updateRooCode = $true $updateCodex = $true $updateClaude = $true } elseif ($Mode -eq 'AUTO') { Write-Host "Selected: AUTO (parameter mode)" # Determine available agents based on instruction files $updateCline = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.clinerules') $updateRooCode = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.roo\rules') $updateCopilot = Test-Path (Join-Path $repoRoot '.github\copilot-instructions.md') -PathType Leaf $updateCodex = Test-Path (Join-Path $repoRoot 'AGENTS.md') -PathType Leaf $updateClaude = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.claude') Write-Host "Agents to update based on available instruction files:" if ($updateCline) { Write-Host "- CLINE" } if ($updateRooCode) { Write-Host "- ROO CODE" } if ($updateCopilot) { Write-Host "- GitHub CoPilot" } if ($updateCodex) { Write-Host "- OpenAI Codex" } if ($updateClaude) { Write-Host "- Claude Code" } } elseif ($Mode -and $Mode -ne '') { # Specific agent mode (e.g., CLINE, "ROO CODE", etc.) $updateCline = ($Mode -eq 'CLINE') $updateCopilot = ($Mode -eq 'GitHub CoPilot') $updateRooCode = ($Mode -eq 'ROO CODE') $updateCodex = ($Mode -eq 'OpenAI Codex') $updateClaude = ($Mode -eq 'Claude Code') Write-Host "Selected: $Mode (parameter mode)" } else { # User prompt for agent selection # Detect available agents for interactive menu $hasCline = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.clinerules') $hasRooCode = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.roo\rules') $hasCopilot = Test-Path (Join-Path $repoRoot '.github\copilot-instructions.md') -PathType Leaf $hasCodex = Test-Path (Join-Path $repoRoot 'AGENTS.md') -PathType Leaf $hasClaude = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.claude') Write-Host "`r`nDetected AI agents with instruction files:" if ($hasCline) { Write-Host "- CLINE" } if ($hasRooCode) { Write-Host "- ROO CODE" } if ($hasCopilot) { Write-Host "- GitHub CoPilot" } if ($hasCodex) { Write-Host "- OpenAI Codex" } if ($hasClaude) { Write-Host "- Claude Code" } Write-Host "" Write-Host "==============================================================" Write-Host "Select Agent Instruction Set to Update" Write-Host "--------------------------------------------------------------" Write-Host "1. AUTO - Update only agents with instruction files (default)" Write-Host "2. ALL - Update instructions for all AI agents" Write-Host "3. CLINE - Update instructions for CLINE" Write-Host "4. ROO CODE - Update instructions for ROO CODE" Write-Host "5. GitHub CoPilot - Update instructions for GitHub CoPilot" Write-Host "6. OpenAI Codex - Update instructions for OpenAI Codex" Write-Host "7. Claude Code - Update instructions for Claude Code" Write-Host "0. Exit" Write-Host "==============================================================" $selection = Read-Host "Enter the number of your choice (0-7)" # Initialize flags $updateCline = $false $updateCopilot = $false $updateRooCode = $false $updateCodex = $false $updateClaude = $false switch ($selection) { '1' { $updateCline = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.clinerules') $updateRooCode = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.roo\rules') $updateCopilot = Test-Path (Join-Path $repoRoot '.github\copilot-instructions.md') -PathType Leaf $updateCodex = Test-Path (Join-Path $repoRoot 'AGENTS.md') -PathType Leaf $updateClaude = Test-HasInstructionFiles -Path (Join-Path $repoRoot '.claude') Write-Host "Selected: AUTO" } '2' { $updateCline = $true $updateCopilot = $true $updateRooCode = $true $updateCodex = $true $updateClaude = $true Write-Host "Selected: ALL" } '3' { $updateCline = $true; Write-Host "Selected: CLINE" } '4' { $updateRooCode = $true; Write-Host "Selected: ROO CODE" } '5' { $updateCopilot = $true; Write-Host "Selected: GitHub CoPilot" } '6' { $updateCodex = $true; Write-Host "Selected: OpenAI Codex" } '7' { $updateClaude = $true; Write-Host "Selected: Claude Code" } '0' { Write-Host "Operation cancelled by user."; exit 0 } default { throw "Invalid selection. Exiting." } } } # --- Multiple-File Agent Updates --- if ($updateCline) { Update-MultipleFileAgent -AgentName "CLINE" -TargetDirectory ".clinerules" -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } if ($updateRooCode) { Update-MultipleFileAgent -AgentName "ROO CODE" -TargetDirectory ".roo\rules" -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } # --- Single-File Agent Updates --- if ($updateCopilot) { $copilotTarget = ".github\copilot-instructions.md" $githubInstructionsDir = Join-Path $repoRoot ".github\instructions" if (Test-Path $githubInstructionsDir -PathType Container) { Write-Host "`r`n--- Updating GitHub CoPilot Instructions (folder-based) ---" $mainName = "instructions.md" $mainSource = $sourceInstructionFiles | Where-Object { $_.Name -ieq $mainName } | Select-Object -First 1 if ($null -eq $mainSource) { throw "Expected source '$mainName' under .ai but none found." } Copy-FileIfDifferent -SourcePath $mainSource.FullName -TargetPath (Join-Path $repoRoot $copilotTarget) foreach ($sf in $sourceInstructionFiles) { if ($sf.Name -ieq $mainName) { continue } $destination = Join-Path $githubInstructionsDir $sf.Name Copy-FileIfDifferent -SourcePath $sf.FullName -TargetPath $destination } } else { Update-SingleFileAgent -AgentName "GitHub CoPilot" -TargetFilePath $copilotTarget -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } } if ($updateCodex) { Update-SingleFileAgent -AgentName "OpenAI Codex" -TargetFilePath "AGENTS.md" -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } # --- Claude Code (multiple-file agent) --- if ($updateClaude) { Update-MultipleFileAgent -AgentName "Claude Code" -TargetDirectory ".claude" -SourceFiles $sourceInstructionFiles -RepoRoot $repoRoot } # --- Skills mirroring --- if ($updateRooCode -or $Mode -eq 'ALL' -or $Mode -eq 'AUTO') { Sync-SkillsToRoo -RepoRoot $repoRoot } # GitHub Copilot: mirror skills to `.github/skills` (Copilot tries to load from there). if ($updateCopilot -or $Mode -eq 'ALL' -or $Mode -eq 'AUTO') { Sync-SkillsToGitHub -RepoRoot $repoRoot } # Claude Code: mirror skills to `.claude/skills`. if ($updateClaude -or $Mode -eq 'ALL' -or $Mode -eq 'AUTO') { Sync-SkillsToClaude -RepoRoot $repoRoot } Write-Host "`r`nAll selected operations completed successfully." # Only pause when launched by double-click (Explorer). In CI / terminal usage, do not pause. if ($Host.Name -and $Host.Name -notlike '*ConsoleHost*') { Invoke-Pause } ================================================ FILE: .gitignore ================================================ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore # User-specific files *.rsuser *.suo *.user *.userosscache *.sln.docstates # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs # Mono auto generated files mono_crash.* # Build results [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ x64/ x86/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ [Ll]ogs/ # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ # Visual Studio 2017 auto generated files Generated\ Files/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* # NUnit *.VisualState.xml TestResult.xml nunit-*.xml # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c # Benchmark Results BenchmarkDotNet.Artifacts/ # .NET Core project.lock.json project.fragment.lock.json artifacts/ # StyleCop StyleCopReport.xml # Files built by Visual Studio *_i.c *_p.c *_h.h *.ilk *.meta *.obj *.iobj *.pch *.pdb *.ipdb *.pgc *.pgd *.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj *_wpftmp.csproj *.log *.vspscc *.vssscc .builds *.pidb *.svclog *.scc # Chutzpah Test files _Chutzpah* # Visual C++ cache files ipch/ *.aps *.ncb *.opendb *.opensdf *.sdf *.cachefile *.VC.db *.VC.VC.opendb # Visual Studio profiler *.psess *.vsp *.vspx *.sap # Visual Studio Trace Files *.e2e # TFS 2012 Local Workspace $tf/ # Guidance Automation Toolkit *.gpState # ReSharper is a .NET coding add-in _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user # TeamCity is a build add-in _TeamCity* # DotCover is a Code Coverage Tool *.dotCover # AxoCover is a Code Coverage Tool .axoCover/* !.axoCover/settings.json # Visual Studio code coverage results *.coverage *.coveragexml # NCrunch _NCrunch_* .*crunch*.local.xml nCrunchTemp_* # MightyMoose *.mm.* AutoTest.Net/ # Web workbench (sass) .sass-cache/ # Installshield output folder [Ee]xpress/ # DocProject is a documentation generator add-in DocProject/buildhelp/ DocProject/Help/*.HxT DocProject/Help/*.HxC DocProject/Help/*.hhc DocProject/Help/*.hhk DocProject/Help/*.hhp DocProject/Help/Html2 DocProject/Help/html # Click-Once directory publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml # Note: Comment the next line if you want to checkin your web deploy settings, # but database connection strings (with potential passwords) will be unencrypted *.pubxml *.publishproj # Microsoft Azure Web App publish settings. Comment the next line if you want to # checkin your Azure Web App publish settings, but sensitive information contained # in these scripts will be unencrypted PublishScripts/ # NuGet Packages *.nupkg # NuGet Symbol Packages *.snupkg # The packages folder can be ignored because of Package Restore **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files *.nuget.props *.nuget.targets # Microsoft Azure Build Output csx/ *.build.csdef # Microsoft Azure Emulator ecf/ rcf/ # Windows Store app package directories and files AppPackages/ BundleArtifacts/ Package.StoreAssociation.xml _pkginfo.txt *.appx *.appxbundle *.appxupload # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !?*.[Cc]ache/ # Others ClientBin/ ~$* *~ *.dbmdl *.dbproj.schemaview *.jfm *.pfx *.publishsettings orleans.codegen.cs # Including strong name files can present a security risk # (https://github.com/github/gitignore/pull/2483#issue-259490424) #*.snk # Since there are multiple workflows, uncomment next line to ignore bower_components # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) #bower_components/ # RIA/Silverlight projects Generated_Code/ # Backup & report files from converting an old project file # to a newer Visual Studio version. Backup files are not needed, # because we have git ;-) _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm ServiceFabricBackup/ *.rptproj.bak # SQL Server files *.mdf *.ldf *.ndf # Business Intelligence projects *.rdl.data *.bim.layout *.bim_*.settings *.rptproj.rsuser *- [Bb]ackup.rdl *- [Bb]ackup ([0-9]).rdl *- [Bb]ackup ([0-9][0-9]).rdl # Microsoft Fakes FakesAssemblies/ # GhostDoc plugin setting file *.GhostDoc.xml # Node.js Tools for Visual Studio .ntvs_analysis.dat node_modules/ # Visual Studio 6 build log *.plg # Visual Studio 6 workspace options file *.opt # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts **/*.DesktopClient/ModelManifest.xml **/*.Server/GeneratedArtifacts **/*.Server/ModelManifest.xml _Pvt_Extensions # Paket dependency manager .paket/paket.exe paket-files/ # FAKE - F# Make .fake/ # CodeRush personal settings .cr/personal # Python Tools for Visual Studio (PTVS) __pycache__/ *.pyc # Cake - Uncomment if you are using it # tools/** # !tools/packages.config # Tabs Studio *.tss # Telerik's JustMock configuration file *.jmconfig # BizTalk build output *.btp.cs *.btm.cs *.odx.cs *.xsd.cs # OpenCover UI analysis results OpenCover/ # Azure Stream Analytics local run output ASALocalRun/ # MSBuild Binary and Structured Log *.binlog # NVidia Nsight GPU debugger configuration file *.nvuser # MFractors (Xamarin productivity tool) working folder .mfractor/ # Local History for Visual Studio .localhistory/ # BeatPulse healthcheck temp database healthchecksdb # Backup folder for Package Reference Convert tool in Visual Studio 2017 MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ /FocusLogger/Resources/BuildDate.txt /FocusLogger/Documents/Files /.claude/settings.local.json Documents/Files ================================================ FILE: .markdownlint.json ================================================ // https://github.com/DavidAnson/markdownlint { "default": true, "MD013": false, // There are lines that are longer than the configured line_length (default: 80 characters). "MD033": false, // Raw HTML is used in a markdown document. "MD041": false, // The first line in a document is not a top-level heading. } ================================================ FILE: Documents/App_1_Sign.ps1 ================================================ Import-Module "d:\_Backup\Configuration\SSL\Tools\app_signModule.ps1" -Force [string[]]$appFiles = @( "..\FocusLogger\bin\Release\publish\JocysCom.FocusLogger.exe" ) [string]$appName = "Jocys.com Focus Logger" [string]$appLink = "https://www.jocys.com" ProcessFiles $appName $appLink $appFiles pause ================================================ FILE: Documents/App_2_Zip.ps1 ================================================ # Make sure the output directories exist $filesDir = Join-Path $PSScriptRoot "Files" $binDir = Join-Path $PSScriptRoot "..\Resources" $file1="JocysCom.FocusLogger.exe" if (-not [System.IO.File]::Exists([System.IO.Path]::Combine($filesDir, $file1))){ [System.IO.File]::Copy([System.IO.Path]::Combine($PSScriptRoot, "..\App\bin\Release\publish\", $file1), [System.IO.Path]::Combine($filesDir, $file1)) } & "$binDir\ZipFiles.ps1" $filesDir "$filesDir\JocysCom.FocusLogger.zip" $file1 $true ================================================ FILE: Documents/Take_Screenshot.ps1 ================================================ # Take_Screenshot.ps1 # Launches FocusLogger, Notepad, and Explorer, switches between them # to generate focus log entries, then captures a screenshot using PrintWindow. # # Usage: Right-click > Run with PowerShell, or run from terminal: # powershell -ExecutionPolicy Bypass -File Take_Screenshot.ps1 Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing # Load C# helper class (supports re-running in the same session). $csFilePath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "Take_Screenshot.ps1.cs" $csFileContent = Get-Content -Path $csFilePath -Raw $fileHash = Get-FileHash -InputStream ([System.IO.MemoryStream]::new([System.Text.Encoding]::UTF8.GetBytes($csFileContent))) -Algorithm SHA256 $className = "TakeScreenshot" if (-not $script:loadedClasses) { $script:loadedClasses = @{} } if (-not $script:loadedClasses.ContainsKey($fileHash.Hash)) { $className += (Get-Date -Format "yyyyMMddHHmmss") $csCode = $csFileContent -replace "TakeScreenshot", $className Add-Type -TypeDefinition $csCode -ReferencedAssemblies System.Windows.Forms, System.Drawing $script:loadedClasses[$fileHash.Hash] = $className } else { $className = $script:loadedClasses[$fileHash.Hash] } $helper = [Type]$className $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $screenshotPath = Join-Path $scriptDir "Images\JocysCom.FocusLogger.png" $tempFile = Join-Path $env:TEMP "My Document.txt" # Find FocusLogger executable. $appPath = Join-Path $scriptDir "..\FocusLogger\bin\Debug\net8.0-windows\JocysCom.FocusLogger.exe" if (-not (Test-Path $appPath)) { $appPath = Join-Path $scriptDir "..\FocusLogger\bin\Release\net8.0-windows\JocysCom.FocusLogger.exe" } if (-not (Test-Path $appPath)) { Write-Error "FocusLogger not found. Build the project first." exit 1 } $appPath = Resolve-Path $appPath $focusLogger = $null $notepad = $null $explorerProc = $null try { # 1. Start FocusLogger (centered). Write-Host "1. Starting FocusLogger..." $focusLogger = Start-Process -FilePath $appPath -PassThru Start-Sleep -Seconds 2 $focusLogger.Refresh() $flHwnd = $focusLogger.MainWindowHandle $helper::CenterAndResize($flHwnd, 920, 480) Start-Sleep -Milliseconds 500 # 2. Start Notepad with a document. Write-Host "2. Starting Notepad..." "Sample document for Focus Logger screenshot." | Out-File -FilePath $tempFile -Encoding UTF8 $notepadPidsBefore = @(Get-Process -Name "Notepad" -ErrorAction SilentlyContinue | ForEach-Object { $_.Id }) Start-Process -FilePath "notepad.exe" -ArgumentList "`"$tempFile`"" Start-Sleep -Seconds 2 $notepad = Get-Process -Name "Notepad" -ErrorAction SilentlyContinue | Where-Object { $notepadPidsBefore -notcontains $_.Id -and $_.MainWindowHandle -ne [IntPtr]::Zero } | Select-Object -First 1 if (-not $notepad) { $notepad = Get-Process -Name "Notepad" -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne [IntPtr]::Zero } | Select-Object -First 1 } $npHwnd = if ($notepad) { $notepad.MainWindowHandle } else { [IntPtr]::Zero } if ($npHwnd -ne [IntPtr]::Zero) { $helper::CenterAndResize($npHwnd, 1040, 680, -50) $helper::SetForegroundWindow($npHwnd) | Out-Null Start-Sleep -Milliseconds 500 } # 3. Start Explorer. Write-Host "3. Starting Explorer..." $explorerHwndsBefore = @(Get-Process explorer -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne [IntPtr]::Zero } | ForEach-Object { $_.MainWindowHandle }) Start-Process "explorer.exe" -ArgumentList "C:\Windows" Start-Sleep -Seconds 2 $explorerProc = Get-Process explorer -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne [IntPtr]::Zero -and $explorerHwndsBefore -notcontains $_.MainWindowHandle } | Select-Object -First 1 if (-not $explorerProc) { $explorerProc = Get-Process explorer -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -match "Windows" -and $_.MainWindowHandle -ne [IntPtr]::Zero } | Select-Object -First 1 } $exHwnd = if ($explorerProc) { $explorerProc.MainWindowHandle } else { [IntPtr]::Zero } if ($exHwnd -ne [IntPtr]::Zero) { $helper::CenterAndResize($exHwnd, 700, 400) $helper::SetForegroundWindow($exHwnd) | Out-Null Start-Sleep -Milliseconds 500 } # 4. Switch focus between windows to generate log entries. Write-Host "4. Switching focus..." if ($npHwnd -ne [IntPtr]::Zero) { $helper::SetForegroundWindow($npHwnd) | Out-Null; Start-Sleep -Milliseconds 800 } if ($exHwnd -ne [IntPtr]::Zero) { $helper::SetForegroundWindow($exHwnd) | Out-Null; Start-Sleep -Milliseconds 800 } if ($npHwnd -ne [IntPtr]::Zero) { $helper::SetForegroundWindow($npHwnd) | Out-Null; Start-Sleep -Milliseconds 800 } if ($exHwnd -ne [IntPtr]::Zero) { $helper::SetForegroundWindow($exHwnd) | Out-Null; Start-Sleep -Milliseconds 800 } if ($npHwnd -ne [IntPtr]::Zero) { $helper::SetForegroundWindow($npHwnd) | Out-Null; Start-Sleep -Milliseconds 800 } # 5. Move Notepad behind FocusLogger as white background, then capture. Write-Host "5. Taking screenshot..." if ($npHwnd -ne [IntPtr]::Zero) { $helper::CenterAndResize($npHwnd, 1040, 680, -50) $helper::SetForegroundWindow($npHwnd) | Out-Null Start-Sleep -Milliseconds 300 } $helper::SetForegroundWindow($flHwnd) | Out-Null Start-Sleep -Seconds 1 $helper::CaptureWindow($flHwnd, $screenshotPath) Write-Host " Screenshot saved to: $screenshotPath" } finally { Write-Host "6. Cleaning up..." if ($notepad -and -not $notepad.HasExited) { $notepad.Kill(); $notepad.WaitForExit(3000) | Out-Null } if ($focusLogger -and -not $focusLogger.HasExited) { $focusLogger.Kill(); $focusLogger.WaitForExit(3000) | Out-Null } if ($explorerProc -and -not $explorerProc.HasExited) { $explorerProc.CloseMainWindow() | Out-Null } Start-Sleep -Milliseconds 500 Remove-Item $tempFile -ErrorAction SilentlyContinue Write-Host "Done." } ================================================ FILE: Documents/Take_Screenshot.ps1.cs ================================================ using System; using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices; using System.Windows.Forms; public class TakeScreenshot { [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, uint nFlags); [DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; } public static RECT GetExtendedFrameBounds(IntPtr hwnd) { RECT rect; DwmGetWindowAttribute(hwnd, 9, out rect, Marshal.SizeOf(typeof(RECT))); return rect; } public static void CenterAndResize(IntPtr hwnd, int w, int h, int yOffset = 0) { var screen = Screen.PrimaryScreen.WorkingArea; int x = (screen.Width - w) / 2 + screen.Left; int y = (screen.Height - h) / 2 + screen.Top + yOffset; SetWindowPos(hwnd, IntPtr.Zero, x, y, w, h, 0x0040); } /// /// Captures a window using CopyFromScreen with DWM extended frame bounds /// for accurate visible area (excludes invisible shadow border). /// public static void CaptureWindow(IntPtr hwnd, string filePath) { var rect = GetExtendedFrameBounds(hwnd); int w = rect.Right - rect.Left; int h = rect.Bottom - rect.Top; using (var bitmap = new Bitmap(w, h, PixelFormat.Format32bppArgb)) { using (var graphics = Graphics.FromImage(bitmap)) { graphics.CopyFromScreen(rect.Left, rect.Top, 0, 0, new Size(w, h)); } bitmap.Save(filePath, ImageFormat.Png); } } } ================================================ FILE: FocusLogger/App.xaml ================================================  ================================================ FILE: FocusLogger/App.xaml.cs ================================================ using System; using System.Windows; namespace JocysCom.FocusLogger { public partial class App : Application { public App() { SetDPIAware(); } internal class NativeMethods { [System.Runtime.InteropServices.DllImport("user32.dll")] internal static extern bool SetProcessDPIAware(); } public static void SetDPIAware() { // DPI aware property must be set before application window is created. if (Environment.OSVersion.Version.Major >= 6) NativeMethods.SetProcessDPIAware(); } } } ================================================ FILE: FocusLogger/AssemblyInfo.cs ================================================ using System.Windows; [assembly: ThemeInfo( ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located //(used if a resource is not found in the page, // or application resource dictionaries) ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located //(used if a resource is not found in the page, // app, or any theme specific resource dictionaries) )] ================================================ FILE: FocusLogger/Common/DataItem.cs ================================================ using JocysCom.ClassLibrary.Configuration; using System; using System.ComponentModel; using System.Runtime.CompilerServices; namespace JocysCom.FocusLogger { public class DataItem : SettingsItem { public DateTime Date { get => _Date; set => SetProperty(ref _Date, value); } DateTime _Date; public int ProcessId { get => _ProcessId; set => SetProperty(ref _ProcessId, value); } int _ProcessId; public string ProcessName { get => _ProcessName; set => SetProperty(ref _ProcessName, value); } string _ProcessName; public string ProcessPath { get => _ProcessPath; set => SetProperty(ref _ProcessPath, value); } string _ProcessPath; public string WindowTitle { get => _WindowTitle; set => SetProperty(ref _WindowTitle, value); } string _WindowTitle; public string WindowClassName { get => _WindowClassName; set => SetProperty(ref _WindowClassName, value); } string _WindowClassName; public bool HasMouse { get => _HasMouse; set => SetProperty(ref _HasMouse, value); } bool _HasMouse; public bool HasKeyboard { get => _HasKeyboard; set => SetProperty(ref _HasKeyboard, value); } bool _HasKeyboard; public bool HasCaret { get => _HasCaret; set => SetProperty(ref _HasCaret, value); } bool _HasCaret; public bool IsActive { get => _IsActive; set => SetProperty(ref _IsActive, value); } bool _IsActive; public bool NonPath { get => _IsError; set => SetProperty(ref _IsError, value); } bool _IsError; public bool IsSame(DataItem item) { return item.ProcessId == ProcessId && item.HasMouse == HasMouse && item.HasKeyboard == HasKeyboard && item.HasCaret == HasCaret && item.IsActive == IsActive; } public System.Windows.MessageBoxImage StatusCode { get => _StatusCode; set => SetProperty(ref _StatusCode, value); } System.Windows.MessageBoxImage _StatusCode; } } ================================================ FILE: FocusLogger/Common/DataItemType.cs ================================================ namespace JocysCom.FocusLogger { public enum DataItemType { None = 0, } } ================================================ FILE: FocusLogger/Common/NativeMethods.cs ================================================ using System; using System.Runtime.InteropServices; using System.Text; namespace JocysCom.FocusLogger { internal class NativeMethods { // https://docs.microsoft.com/en-gb/windows/win32/api/winuser/ /// /// Get handle to the window with the keyboard focus. /// /// /// The return value is the handle to the window with the keyboard focus. /// If the calling thread's message queue does not have an associated window with the keyboard focus, the return value is NULL. /// [DllImport("user32.dll", CharSet = CharSet.Auto, ExactSpelling = true)] internal static extern IntPtr GetFocus(); /// /// Window which is active. /// /// /// The return value is the handle to the active window attached to the calling thread's message queue. /// Otherwise, the return value is NULL. /// [DllImport("user32.dll", CharSet = CharSet.Auto, ExactSpelling = true)] internal static extern IntPtr GetActiveWindow(); /// /// Get handle to the child window at the top of the Z order. /// [DllImport("user32.dll", CharSet = CharSet.Auto, ExactSpelling = true)] internal static extern IntPtr GetTopWindow(IntPtr hWnd); [DllImport("user32.dll", CharSet = CharSet.Auto, ExactSpelling = true)] internal static extern bool IsWindowVisible(IntPtr hWnd); [DllImport("user32.dll", CharSet = CharSet.Auto, ExactSpelling = true)] internal static extern int GetWindowTextLengthW(IntPtr hWnd); /// /// Copies the text of the specified window's title bar (if it has one) into a buffer. /// /// A handle to the window or control containing the text. /// The buffer that will receive the text. /// The maximum number of characters to copy to the buffer, including the null character. /// If the function succeeds, the return value is the length, in characters, of the copied string, not including the terminating null character. [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); /// /// A handle to the window that will receive the keyboard input. /// /// /// The return value is a handle to the foreground window. /// The foreground window can be NULL in certain circumstances, such as when a window is losing activation. /// [DllImport("user32.dll", CharSet = CharSet.Auto, ExactSpelling = true)] internal static extern IntPtr GetForegroundWindow(); /// /// Retrieves the identifier of the thread that created the specified window and, /// optionally, the identifier of the process that created the window. /// /// A handle to the window. /// A pointer to a variable that receives the process identifier /// Identifier of the thread that created the window. [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] internal static extern int GetWindowThreadProcessId(IntPtr hWnd, out int lpdwProcessId); /// /// Retrieves information about the active window or a specified GUI thread. /// /// /// The identifier for the thread for which information is to be retrieved. /// To retrieve this value, use the GetWindowThreadProcessId function. /// If this parameter is NULL, the function returns information for the foreground thread. /// /// /// A pointer to a GUITHREADINFO structure that receives information describing the thread. /// /// If the function succeeds, the return value is nonzero. [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] internal static extern bool GetGUIThreadInfo(int idThread, ref GUITHREADINFO pgui); [StructLayout(LayoutKind.Sequential)] internal struct RECT { public int iLeft; public int iTop; public int iRight; public int iBottom; } [Flags] internal enum GUI { /// The caret's blink state. This bit is set if the caret is visible. GUI_CARETBLINKING = 0x00000001, /// The thread's menu state. This bit is set if the thread is in menu mode. GUI_INMENUMODE = 0x00000004, /// The thread's move state. This bit is set if the thread is in a move or size loop. GUI_INMOVESIZE = 0x00000002, /// The thread's pop-up menu state. This bit is set if the thread has an active pop-up menu. GUI_POPUPMENUMODE = 0x00000010, /// The thread's system menu state. This bit is set if the thread is in a system menu mode. GUI_SYSTEMMENUMODE = 0x00000008, } [StructLayout(LayoutKind.Sequential)] internal struct GUITHREADINFO { /// The size of this structure, in bytes. public int cbSize; /// The thread state. public GUI flags; /// A handle to the active window within the thread. public IntPtr hwndActive; /// A handle to the window that has the keyboard focus. public IntPtr hwndFocus; /// A handle to the window that has captured the mouse. public IntPtr hwndCapture; /// A handle to the window that owns any active menus. public IntPtr hwndMenuOwner; /// A handle to the window in a move or size loop. public IntPtr hwndMoveSize; /// A handle to the window that is displaying the caret. public IntPtr hwndCaret; /// The caret's bounding rectangle, in client coordinates, relative to the window specified by the hwndCaret member. public RECT rectCaret; } internal static GUITHREADINFO? GetInfo(IntPtr hWnd) { int lpdwProcessId; int threadId = GetWindowThreadProcessId(hWnd, out lpdwProcessId); var pgui = new GUITHREADINFO(); pgui.cbSize = Marshal.SizeOf(pgui); if (GetGUIThreadInfo(threadId, ref pgui)) return pgui; return null; } [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount); internal static string GetWindowClassName(IntPtr hWnd) { var sb = new StringBuilder(256); var length = GetClassName(hWnd, sb, sb.Capacity); return length > 0 ? sb.ToString() : ""; } internal static string GetWindowText(IntPtr hWnd) { int textLength = GetWindowTextLengthW(hWnd); var lpString = new StringBuilder(textLength + 1); var length = GetWindowText(hWnd, lpString, lpString.Capacity); return lpString.ToString(); } } } ================================================ FILE: FocusLogger/Controls/DataListControl.xaml ================================================ ================================================ FILE: FocusLogger/Controls/DataListControl.xaml.cs ================================================ using JocysCom.ClassLibrary.ComponentModel; using JocysCom.ClassLibrary.Controls; using JocysCom.FocusLogger.Resources.Icons; using Microsoft.Win32; using System; using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Windows; using System.Windows.Controls; namespace JocysCom.FocusLogger.Controls { /// /// Interaction logic for DataListControl.xaml /// public partial class DataListControl : UserControl { public DataListControl() { InitializeComponent(); if (ControlsHelper.IsDesignMode(this)) return; // Configure converter. MainDataGrid.ItemsSource = DataItems; var gridFormattingConverter = MainDataGrid.Resources.Values.OfType().First(); gridFormattingConverter.ConvertFunction = _MainDataGridFormattingConverter_Convert; } object _MainDataGridFormattingConverter_Convert(object[] values, Type targetType, object parameter, CultureInfo culture) { var sender = (FrameworkElement)values[0]; var template = (FrameworkElement)values[1]; var value = values[2]; var cell = (DataGridCell)(template ?? sender).Parent; var item = (DataItem)cell.DataContext; if (cell.Column == IsActiveImageColumn) { return item.IsActive ? Icons_Default.Current[Icons_Default.Icon_window] : null; } if (cell.Column == IsActiveColumn) { cell.Opacity = 0.5; return item.IsActive ? "Active" : ""; } // Mouse. if (cell.Column == HasMouseColumn) return item.HasMouse ? "Mouse" : ""; if (cell.Column == HasMouseImageColumn) return item.HasMouse ? Icons_Default.Current[Icons_Default.Icon_mouse2] : null; // Keyboard. if (cell.Column == HasKeyboardImageColumn) return item.HasKeyboard ? Icons_Default.Current[Icons_Default.Icon_keyboard] : null; if (cell.Column == HasKeyboardColumn) return item.HasKeyboard ? "Keyboard" : ""; // Caret. if (cell.Column == HasCaretImageColumn) return item.HasCaret ? Icons_Default.Current[Icons_Default.Icon_text_field] : null; if (cell.Column == HasCaretColumn) return item.HasCaret ? "Caret" : ""; if (cell.Column == DateColumn) { value = string.Format("{0:HH:mm:ss:fff}", item.Date); cell.Opacity = 0.5; } if (cell.Column == ProcessPathColumn) { if (item.NonPath) cell.Opacity = 0.3; } // Other. return value; } public SortableBindingList DataItems { get; set; } = new SortableBindingList(); private void UserControl_Loaded(object sender, RoutedEventArgs e) { if (ControlsHelper.IsDesignMode(this)) return; InitTimer(); } private string _lastSavedCsvPath; private void SaveCsvButton_Click(object sender, RoutedEventArgs e) { if (DataItems.Count == 0) { MessageBox.Show("No log entries to save.", "Save CSV", MessageBoxButton.OK, MessageBoxImage.Information); return; } var dialog = new SaveFileDialog { Filter = "CSV files (*.csv)|*.csv", DefaultExt = ".csv", FileName = $"FocusLog_{DateTime.Now:yyyy-MM-dd_HHmmss}.csv", }; if (dialog.ShowDialog() != true) return; var csv = BuildCsvContent(DataItems); File.WriteAllText(dialog.FileName, csv, Encoding.UTF8); _lastSavedCsvPath = dialog.FileName; } private void ExploreCsvButton_Click(object sender, RoutedEventArgs e) { if (string.IsNullOrEmpty(_lastSavedCsvPath) || !File.Exists(_lastSavedCsvPath)) { MessageBox.Show("No CSV file saved yet. Use 'Save CSV' first.", "Explore", MessageBoxButton.OK, MessageBoxImage.Information); return; } Process.Start("explorer.exe", $"/select,\"{_lastSavedCsvPath}\""); } public static string BuildCsvContent(System.Collections.Generic.IEnumerable items) { var sb = new StringBuilder(); sb.AppendLine("Date,PID,Process Name,Active,Mouse,Keyboard,Caret,Window Title,Window Class,Path"); foreach (var item in items) { sb.AppendLine(string.Join(",", CsvEscape(item.Date.ToString("yyyy-MM-dd HH:mm:ss.fff")), item.ProcessId, CsvEscape(item.ProcessName), item.IsActive, item.HasMouse, item.HasKeyboard, item.HasCaret, CsvEscape(item.WindowTitle), CsvEscape(item.WindowClassName), CsvEscape(item.ProcessPath) )); } return sb.ToString(); } public static string CsvEscape(string value) { if (string.IsNullOrEmpty(value)) return ""; if (value.Contains(",") || value.Contains("\"") || value.Contains("\n")) return "\"" + value.Replace("\"", "\"\"") + "\""; return value; } private void CopyAiPromptButton_Click(object sender, RoutedEventArgs e) { var prompt = ClassLibrary.Helper.FindResource("Resources/AiAnalysisPrompt.md").TrimEnd(); var box = new MessageBoxWindow(); box.SetSize(720, 400); box.ShowPrompt(prompt, "AI Analysis Prompt - Copy and paste into your AI assistant", MessageBoxButton.OK, MessageBoxImage.Information, MessageBoxResult.OK); } private void ClearButton_Click(object sender, RoutedEventArgs e) { DataItems.Clear(); } private void UserControl_Unloaded(object sender, RoutedEventArgs e) { if (ControlsHelper.IsDesignMode(this)) return; } object AddLock = new object(); public bool IsElevated { get { var id = System.Security.Principal.WindowsIdentity.GetCurrent(); return id.Owner != id.User; } } public void UpdateFromProcess(DataItem item) { using (var process = Process.GetProcessById(item.ProcessId)) { item.ProcessName = process.ProcessName; if (item.ProcessId == 0) { item.ProcessPath = "System Idle Process"; item.NonPath = true; } if (item.ProcessId > 0) { try { item.ProcessPath = process.MainModule?.FileName; } catch (Exception ex) { const int E_FAIL = unchecked((int)0x80004005); // -2147467259 item.ProcessPath = $"Error: {ex.Message}"; item.NonPath = true; // If Win32 Acccess is denied exception, then... if (ex is Win32Exception && ex.HResult == E_FAIL) item.ProcessPath += " Run as Administrator"; } } } } System.Timers.Timer _Timer; void InitTimer() { _Timer = new System.Timers.Timer(); _Timer.Elapsed += _Timer_Elapsed; _Timer.AutoReset = false; _Timer.Interval = 1; _Timer.Start(); } private void _Timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) { if (MainWindow.IsClosing) return; UpdateInfo(); _Timer.Start(); } DataItem oldActiveItem = new DataItem(); DataItem oldForegroundItem = new DataItem(); public void UpdateInfo() { // Active window - Window that appears in the foreground with a highlighted title bar. // Foreground window - Window with which the user is currently working. // The system assigns a slightly higher priority to the thread used to create the foreground window. // Focus window - Window that is currently receiving keyboard input. // The focus window is always the active window, a descendent of the active window, or NULL. // Top-Level window - A window that has no parent window. // lock (AddLock) { // Get window which or child window of which receives keyboard input. var activeHandle = NativeMethods.GetActiveWindow(); var activeItem = GetItemFromHandle(activeHandle, true); // If active window changed then... if (!activeItem.IsSame(oldActiveItem)) { oldActiveItem = activeItem; UpdateFromProcess(activeItem); ControlsHelper.BeginInvoke(() => DataItems.Insert(0, activeItem)); } // Get foreground window. var foregroundHandle = NativeMethods.GetForegroundWindow(); var foregroundItem = GetItemFromHandle(foregroundHandle); // If foreground window changed then... if (!foregroundItem.IsSame(oldForegroundItem)) { oldForegroundItem = foregroundItem; UpdateFromProcess(foregroundItem); ControlsHelper.BeginInvoke(() => DataItems.Insert(0, foregroundItem)); } } } DataItem GetItemFromHandle(IntPtr hWnd, bool isActive = false) { var item = new DataItem(); item.Date = DateTime.Now; item.IsActive = isActive; var info = NativeMethods.GetInfo(hWnd); if (info.HasValue) { item.HasMouse = info.Value.hwndCapture != IntPtr.Zero; item.HasKeyboard = info.Value.hwndFocus != IntPtr.Zero; item.HasCaret = info.Value.hwndCaret != IntPtr.Zero; } int processId; if (isActive) { hWnd = NativeMethods.GetTopWindow(hWnd); } item.WindowTitle = NativeMethods.GetWindowText(hWnd); item.WindowClassName = NativeMethods.GetWindowClassName(hWnd); NativeMethods.GetWindowThreadProcessId(hWnd, out processId); item.ProcessId = processId; return item; } } } ================================================ FILE: FocusLogger/JocysCom/Collections/CollectionsHelper.cs ================================================ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; namespace JocysCom.ClassLibrary.Collections { public static partial class CollectionsHelper { /// /// Synchronizes target list to match source list: /// removes items not in source, inserts missing items, /// and reorders existing elements to mirror source order. /// /// Type of elements in the lists. /// The source list whose items and order to mirror; must not be null. /// The target list to update; must support Insert and RemoveAt; must not be null. /// Optional equality comparer; defaults to . /// /// Uses a for fast source lookups; overall time complexity is O(n^2) due to list insert/remove operations. /// Try to use quick sort algorithm by using uniqueSortName/index. /// When target is , uses to avoid triggering remove and insert events. /// public static void Synchronize(IList source, IList target, IEqualityComparer comparer = null) { comparer = comparer ?? EqualityComparer.Default; // Create a dictionary for fast lookup in source list var sourceSet = new Dictionary(); for (int i = 0; i < source.Count; i++) sourceSet[source[i]] = i; // Iterate over the target, remove items not in source for (int i = target.Count - 1; i >= 0; i--) if (!sourceSet.Keys.Contains(target[i], comparer)) target.RemoveAt(i); // Iterate over source for (int si = 0; si < source.Count; si++) { // If item is not present in target, insert it. // Note: Only check items that have not been synchornized in the target. if (!target.Skip(si).Contains(source[si], comparer)) { target.Insert(si, source[si]); continue; } // If item is present in target but not at the right position, move it. int ti = -1; // Note: Only check items that have not been synchornized in the target. for (int i = si; i < target.Count; i++) { if (comparer.Equals(target[i], source[si])) { ti = i; break; } } if (ti != si) { var oc = target as ObservableCollection; // If observable collection then if (oc != null) { // Move item without triggering remove and insert events. oc.Move(si, ti); } else { // Removes and inserts item and // can disrupt data binding in WPF controls. var item = target[ti]; target.RemoveAt(ti); target.Insert(si, item); } } } // Remove items at the end of target that exceed source's length while (target.Count > source.Count) target.RemoveAt(target.Count - 1); } } } ================================================ FILE: FocusLogger/JocysCom/Common/Helper.cs ================================================ using System; using System.Collections.Concurrent; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Windows.Threading; namespace JocysCom.ClassLibrary { public partial class Helper : IDisposable { #region Control Resources /// /// Write application header title to CLI interface. /// public static void WriteAppHeader() { var assembly = Assembly.GetExecutingAssembly(); WriteAppHeader(assembly); } public static void WriteAppHeader(Assembly assembly) { // Write title. // Microsoft (R) SQL Server Database Publishing Wizard 1.1.1.0 // Copyright (C) Microsoft Corporation. All rights reserved. var a = new Configuration.AssemblyInfo(assembly); Console.WriteLine(a.Title + " " + a.Version.ToString()); Console.WriteLine(a.Copyright); Console.WriteLine(a.Description); } /// /// Write help text from help.txt file. /// public static void WriteAppHelp() { Console.Write(GetResource("Documents/Help.txt")); } /// /// Get resource from class *.resx file by full name. /// public static T FindResource(string name, object o) { if (o is null) throw new ArgumentNullException(nameof(o)); var resources = new System.ComponentModel.ComponentResourceManager(o.GetType()); return (T)(resources.GetObject(name)); } /// /// Find resource in all loaded assemblies if not specified by full or partial (EndsWith) name. /// Look inside "Build Action: Embedded Resource". /// public static T FindResource(string name, params Assembly[] assemblies) { var name1 = name.Replace("/", ".").Replace(@"\", "."); var name2 = name1.Replace(' ', '_'); if (assemblies.Length == 0) assemblies = GetAssemblies(); foreach (var assembly in assemblies) { var resourceNames = assembly.GetManifestResourceNames(); foreach (var resourceName in resourceNames) { if (!resourceName.EndsWith(name1) && !resourceName.EndsWith(name2)) continue; var stream = assembly.GetManifestResourceStream(resourceName); return ConvertResource(stream); } } return default(T); } /// /// Project Build Action: "Resource". /// public static string[] GetResourceKeys(Assembly assembly) { string resName = assembly.GetName().Name + ".g.resources"; using (var stream = assembly.GetManifestResourceStream(resName)) using (var reader = new System.Resources.ResourceReader(stream)) return reader.Cast().Select(x => (string)x.Key).ToArray(); } /// /// Project Build Action: "Resource". /// public static Stream GetResourceValue(string name, Assembly assembly) { string resName = assembly.GetName().Name + ".g.resources"; using (var stream = assembly.GetManifestResourceStream(resName)) using (var reader = new System.Resources.ResourceReader(stream)) { var value = reader.Cast() .Where(x => (string)x.Key == name) .Select(x => x.Value).FirstOrDefault(); return (Stream)value; //var path = string.Format("{0};component/{1}", assembly.GetName().Name, name); //var s = System.Windows.Application.GetResourceStream(new Uri(path, UriKind.Relative)); //return s.Stream; } } /// /// Get embedded resource from type (*.resx file). /// public static T GetResource(string name) { var resources = new System.ComponentModel.ComponentResourceManager(typeof(T)); return (T)resources.GetObject(name); } /// /// Get embedded resource by its full name. /// public static T GetResource(string name, params Assembly[] assemblies) { var name1 = name.Replace("/", ".").Replace(@"\", "."); var name2 = name1.Replace(' ', '_'); if (assemblies.Length == 0) assemblies = GetAssemblies(); foreach (var assembly in assemblies) { var resourceNames = assembly.GetManifestResourceNames(); foreach (var resourceName in resourceNames) { if (resourceName != name1 && resourceName != name2) continue; var stream = assembly.GetManifestResourceStream(resourceName); return ConvertResource(stream); } } throw new Exception("Resource not found"); } /// Converts a resource Stream to the specified type T: returns Stream, string (BOM-aware), System.Drawing.Image (.NET Framework), or byte[]. static T ConvertResource(Stream stream) { if (typeof(T) == typeof(Stream)) return (T)(object)stream; var results = default(T); if (typeof(T) == typeof(string)) { // File must contain Byte Order Mark (BOM) header in order for bytes correctly encoded to string. // If header is missing then get resource as byte[] type and encode manually. var streamReader = new StreamReader(stream, true); return (T)(object)streamReader.ReadToEnd(); } #if NETFRAMEWORK // .NET Framework else if (typeof(T) == typeof(System.Drawing.Image) || typeof(T) == typeof(System.Drawing.Bitmap)) { return (T)(object)System.Drawing.Image.FromStream(stream); } #endif else { var bytes = new byte[stream.Length]; #if NET7_0_OR_GREATER stream.ReadExactly(bytes, 0, (int)stream.Length); #else stream.Read(bytes, 0, (int)stream.Length); #endif results = (T)(object)bytes; } return results; } /// Retrieves all loaded assemblies, prioritizing the executing, calling, and entry assemblies for resource lookup. static Assembly[] GetAssemblies() { var assemblies = AppDomain.CurrentDomain.GetAssemblies().ToList(); var orderDesc = new Assembly[] { Assembly.GetExecutingAssembly(), Assembly.GetCallingAssembly(), Assembly.GetEntryAssembly(), }; // Move assemblies to top. foreach (var item in orderDesc) { if (assemblies.Contains(item)) { assemblies.Remove(item); assemblies.Insert(0, item); } } return assemblies.ToArray(); } #endregion /* LongDelay example with CancellationToken: // Create a token that auto-cancels after 10 seconds. var source = new CancellationTokenSource(10000); // Delay for 20 seconds. try { LongDelay(20000, source.Token).Wait(); } catch (TaskCanceledException) { } // Cancel silently. catch (Exception) { throw; } */ /// Allow to delay Task for 292,471,209 years. /// Usage makes sense if the process won't be recycled before the delay expires. public static async Task LongDelay( TimeSpan delay, CancellationToken cancellationToken = default(CancellationToken) ) => await LongDelay((long)delay.TotalMilliseconds, cancellationToken).ConfigureAwait(false); /// Allow to delay Task for 292,471,209 years. /// Usage makes sense if the process won't be recycled before the delay expires. public static async Task LongDelay( long millisecondsDelay, CancellationToken cancellationToken = default(CancellationToken) ) { // Use 'do' to run Task.Delay at least once to reproduce the same behavior. do { var delay = (int)Math.Min(int.MaxValue, millisecondsDelay); await Task.Delay(delay, cancellationToken).ConfigureAwait(false); millisecondsDelay -= delay; } while (millisecondsDelay > 0); } #region Debounce Execution /// /// Contains the CancellationTokenSource for each delegate to manage debouncing. /// static ConcurrentDictionary DebounceActions = new ConcurrentDictionary(); /// /// Holds debouncing information for a specific delegate. /// class DebounceData { public int Counter = 0; public object LockObject = new object(); } [Obsolete("Use `async Task Debounce(Action action, int? delay = null, params object[] args)` instead.")] public static async Task Delay(Action action, int? delay = null, params object[] args) => await _Debounce(action, delay, args); [Obsolete("Use `async Task Debounce(Func action, int? delay = null, params object[] args)` instead.")] public static async Task Delay(Func action, int? delay = null, params object[] args) => await _Debounce(action, delay, args); /// /// Executes an action after a delay, canceling any previous pending executions of the same action. /// This method ensures that the action is invoked only after the specified delay has elapsed since the last invocation request. /// /// The action to debounce. /// The delay in milliseconds to wait before invoking the action. Defaults to 500 milliseconds if not specified. /// A Task representing the asynchronous debounced operation. public static async Task Debounce(Action action, int? delay = null) => await _Debounce(action, delay); /// /// Executes an action after a delay, canceling any previous pending executions of the same action. /// This method ensures that the action is invoked only after the specified delay has elapsed since the last invocation request. /// /// The action to debounce. /// The delay in milliseconds to wait before invoking the action. Defaults to 500 milliseconds if not specified. /// A Task representing the asynchronous debounced operation. public static async Task Debounce(Action action, T arg, int? delay = null) => await _Debounce(action, delay, new object[] { arg }); /// /// Executes an asynchronous function after a delay, canceling any previous pending executions of the same function. /// This method ensures that the action is invoked only after the specified delay has elapsed since the last invocation request. /// /// The asynchronous function to debounce. /// The delay in milliseconds to wait before invoking the function. Defaults to 500 milliseconds if not specified. /// A Task representing the asynchronous debounced operation. public static async Task Debounce(Func action, int? delay = null) => await _Debounce(action, delay); /// /// Debounces the specified action, ensuring it's only invoked after a specified delay since the last call. /// Subsequent calls within the delay period reset the timer. /// This method automatically detects WPF UI thread requirements and marshals execution appropriately. /// /// The delegate to debounce. /// The delay in milliseconds before the delegate is invoked. Defaults to 500 milliseconds if not specified. /// Optional arguments to pass to the delegate when invoked. /// A Task representing the asynchronous debounced operation. public static async Task _Debounce(Delegate action, int? delay = null, params object[] args) { if (action == null) return; int delayValue = delay ?? 500; var debounceData = DebounceActions.GetOrAdd(action, new DebounceData()); int currentCount; lock (debounceData.LockObject) { debounceData.Counter++; currentCount = debounceData.Counter; } await Task.Delay(delayValue); bool shouldExecute = false; lock (debounceData.LockObject) { // This is the latest scheduled call; mark for execution shouldExecute = currentCount == debounceData.Counter; } if (shouldExecute) { // Smart UI thread marshaling - detect and marshal to UI thread if needed await ExecuteWithUIThreadMarshaling(action, args); } } /// /// Executes the given delegate with automatic UI thread marshaling if WPF is available and needed. /// private static async Task ExecuteWithUIThreadMarshaling(Delegate action, params object[] args) { // Check if WPF is available and we need UI thread marshaling var dispatcher = GetWpfDispatcher(); if (dispatcher != null && !dispatcher.CheckAccess()) { // We're not on the UI thread, marshal the call await dispatcher.BeginInvoke(new Action(() => { try { action.DynamicInvoke(args); } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Error in debounced UI action: {ex}"); } })); return; } // Execute normally (either no WPF, already on UI thread, or non-WPF environment) action.DynamicInvoke(args); } /// /// Attempts to get the WPF Dispatcher for the current application. /// Returns null if WPF is not available or no dispatcher is found. /// private static Dispatcher GetWpfDispatcher() { try { // Try to get dispatcher from current thread first var currentDispatcher = Dispatcher.FromThread(Thread.CurrentThread); if (currentDispatcher != null) return currentDispatcher; // Try to get dispatcher from application var app = System.Windows.Application.Current; if (app != null) return app.Dispatcher; // Try to get any available dispatcher return Dispatcher.CurrentDispatcher; } catch { // WPF might not be available or initialized return null; } } #endregion #if NETFRAMEWORK // .NET Framework #region Disk Activity // Sometimes it is good to pause if there is too much disk activity. // By letting windows/SQL to commit all changes to disk we can improve speed. private PerformanceCounter _diskReadCounter = new PerformanceCounter(); private PerformanceCounter _diskWriteCounter = new PerformanceCounter(); /// Reads the specified PerformanceCounter (category, counter, instance) and returns its next value. private static double GetCounterValue(PerformanceCounter pc, string categoryName, string counterName, string instanceName) { pc.CategoryName = categoryName; pc.CounterName = counterName; pc.InstanceName = instanceName; return pc.NextValue(); } /// Specifies disk I/O metric types: ReadAndWrite, Read-only, or Write-only operations. public enum DiskData { ReadAndWrite, Read, Write }; /// Gets disk I/O bytes per second using the specified DiskData metric via PhysicalDisk _Total counters. public double GetDiskData(DiskData dd) { return dd == DiskData.Read ? GetCounterValue(_diskReadCounter, "PhysicalDisk", "Disk Read Bytes/sec", "_Total") : dd == DiskData.Write ? GetCounterValue(_diskWriteCounter, "PhysicalDisk", "Disk Write Bytes/sec", "_Total") : dd == DiskData.ReadAndWrite ? GetCounterValue(_diskReadCounter, "PhysicalDisk", "Disk Read Bytes/sec", "_Total") + GetCounterValue(_diskWriteCounter, "PhysicalDisk", "Disk Write Bytes/sec", "_Total") : 0; } #endregion #endif #region Comparisons private static Regex _GuidRegex; /// Regex matching GUID strings in various formats: 32 digits, hyphenated, with braces or parentheses, or hex-coded lists. public static Regex GuidRegex { get { if (_GuidRegex is null) { _GuidRegex = new Regex( "^[A-Fa-f0-9]{32}$|" + "^({|\\()?[A-Fa-f0-9]{8}-([A-Fa-f0-9]{4}-){3}[A-Fa-f0-9]{12}(}|\\))?$|" + "^({)?[0xA-Fa-f0-9]{3,10}(, {0,1}[0xA-Fa-f0-9]{3,6}){2}, {0,1}({)([0xA-Fa-f0-9]{3,4}, {0,1}){7}[0xA-Fa-f0-9]{3,4}(}})$"); } return _GuidRegex; } } /// Determines whether the specified string is a valid GUID format; returns false if null or empty. public static bool IsGuid(string s) { return string.IsNullOrEmpty(s) ? false : GuidRegex.IsMatch(s); } /// /// Returns true if two ranges overlap. /// public static bool IsOverlap( T? min1, T? max1, T? min2, T? max2 = default, bool inclusive = false ) where T : struct, IComparable { // Check arguments. if (min1 != null && max1 != null && min1.Value.CompareTo(max1.Value) > 0) throw new ArgumentException($"{nameof(min1)} can not be after {nameof(max1)}."); if (min2 != null && max2 != null && min2.Value.CompareTo(max2.Value) > 0) throw new ArgumentException($"{nameof(min2)} can not be after {nameof(max2)}."); // The first range begins before the second ends AND // The second range begins before the first ends. // -----|...|--------- // ---------|...|----- // Null is treated as a full range. return (min1 is null || max2 is null || min1.Value.CompareTo(max2.Value) <= (inclusive ? 0 : -1)) && (min2 is null || max1 is null || min2.Value.CompareTo(max1.Value) <= (inclusive ? 0 : -1)); } #endregion #region Run functions synchronously. /// /// Runs the specified asynchronous function synchronously. /// /// The asynchronous function to run. /// /// This method avoids deadlocks by temporarily removing the current SynchronizationContext, /// allowing the asynchronous function to execute without waiting for the context to be available. /// The main disadvantage when compared to the Task.RunSynchronously() method is that /// it bypasses the Task scheduler, which could lead to potential performance issues. /// public static void RunSynchronously(Func asyncFunc) { // Save the current synchronization context var context = SynchronizationContext.Current; // Temporarily remove the current synchronization context SynchronizationContext.SetSynchronizationContext(null); try { // Execute the asynchronous function and wait for it to complete asyncFunc().GetAwaiter().GetResult(); } finally { // Restore the original synchronization context SynchronizationContext.SetSynchronizationContext(context); } } /// /// Runs the specified asynchronous function synchronously and returns the result. /// /// The type of the result. /// The asynchronous function to run. /// The result of the asynchronous function. /// /// This method avoids deadlocks by temporarily removing the current SynchronizationContext, /// allowing the asynchronous function to execute without waiting for the context to be available. /// The main disadvantage when compared to the Task.RunSynchronously() method is that /// it bypasses the Task scheduler, which could lead to potential performance issues. /// public static TResult RunSynchronously(Func> asyncFunc) { // Save the current synchronization context var context = SynchronizationContext.Current; // Temporarily remove the current synchronization context SynchronizationContext.SetSynchronizationContext(null); try { // Execute the asynchronous function and wait for it to complete, then return the result return asyncFunc().GetAwaiter().GetResult(); } finally { // Restore the original synchronization context SynchronizationContext.SetSynchronizationContext(context); } } #endregion #region IDisposable // Dispose() calls Dispose(true) public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } // The bulk of the clean-up code is implemented in Dispose(bool) protected virtual void Dispose(bool disposing) { if (disposing) { #if NETFRAMEWORK // .NET Framework // Free managed resources. if (_diskReadCounter != null) { _diskReadCounter.Dispose(); _diskReadCounter = null; } if (_diskWriteCounter != null) { _diskWriteCounter.Dispose(); _diskWriteCounter = null; } #endif } } #endregion } } ================================================ FILE: FocusLogger/JocysCom/ComponentModel/BindingListInvoked.cs ================================================ using System; using System.Collections.Generic; using System.ComponentModel; using System.Reflection; using System.Threading; using System.Threading.Tasks; namespace JocysCom.ClassLibrary.ComponentModel { /// Marshals list modifications and notifications to a TaskScheduler (e.g., UI thread) to prevent cross-thread errors. /// /// Provides AddRange for bulk addition and overrides to dispatch operations via SynchronizingObject, with optional async invocation. /// Commented-out FixLeak method offers a WPF-specific memory leak workaround. /// public class BindingListInvoked : BindingList { public BindingListInvoked() : base() { } public BindingListInvoked(IList list) : base(list) { } public BindingListInvoked(IEnumerable enumeration) : base(new List(enumeration)) { } public void AddRange(IEnumerable list) { foreach (T item in list) { Add(item); } } #region ISynchronizeInvoker /// TaskScheduler used to marshal list operations; null disables synchronization, executing operations on the calling thread. public TaskScheduler SynchronizingObject { get; set; } delegate void ItemDelegate(int index, T item); /// When true, invocation uses Task.Factory.StartNew to queue asynchronously; when false, runs synchronously on the TaskScheduler. public bool AsynchronousInvoke { get; set; } // Dispatches the delegate to SynchronizingObject's TaskScheduler when required; respects AsynchronousInvoke for async vs sync execution. void Invoke(Delegate method, params object[] args) { var so = SynchronizingObject; if (so is null || !JocysCom.ClassLibrary.Controls.ControlsHelper.InvokeRequired) { DynamicInvoke(method, args); } else { // Note that Control.Invoke(...) is a synchronous action on the main GUI thread, // and will wait for EnableBackControl() to return. // so.Invoke(...) line could freeze if main GUI thread is busy and can't give // attention to any .Invoke requests from background threads. // // Main GUI thread could be blocked because: // a) Modal dialog is up (which means that it's not listening to new requests). // b) It is checking something in a tight continuous loop. // c) Main thread crashed because of exception. // // Try inserting a Application.DoEvents() in the loop, which will pause // execution and force the main thread to process messages and any outstanding .Invoke requests. if (AsynchronousInvoke) Task.Factory.StartNew(() => { DynamicInvoke(method, args); }, CancellationToken.None, TaskCreationOptions.None, so); else { var task = new Task(() => { DynamicInvoke(method, args); }); task.RunSynchronously(so); } } } // Lock to serialize concurrent list modifications. object OneChangeAtTheTime = new object(); // Executes the delegate under a lock and enriches exceptions with type and SynchronizingObject context data. void DynamicInvoke(Delegate method, params object[] args) { try { lock (OneChangeAtTheTime) { method.DynamicInvoke(args); } } catch (Exception ex) { // Add data to help with debugging. var prefix = string.Format("{0}", nameof(BindingListInvoked)) + "."; ex.Data.Add(prefix + "T", typeof(T).FullName); ex.Data.Add(prefix + "SynchronizingObject", SynchronizingObject?.GetType().FullName); ex.Data.Add(prefix + "AsynchronousInvoke", AsynchronousInvoke); throw; } } protected override void RemoveItem(int index) { Invoke((Action)base.RemoveItem, index); } protected override void InsertItem(int index, T item) { Invoke((ItemDelegate)base.InsertItem, index, item); } protected override void SetItem(int index, T item) { Invoke((ItemDelegate)base.SetItem, index, item); } protected override void OnListChanged(ListChangedEventArgs e) { Invoke((Action)base.OnListChanged, e); } protected override void OnAddingNew(AddingNewEventArgs e) { Invoke((Action)base.OnAddingNew, e); } //public void FixLeak() //{ // var flags = BindingFlags.Instance | BindingFlags.NonPublic; // var fi = GetType().BaseType.BaseType.GetField("onListChanged", flags); // var d = (Delegate)fi.GetValue(this); // if (d != null) // { // if (d.Target is System.Windows.Data.BindingListCollectionView view) // { // view.DetachFromSourceCollection(); // var vfi = view.GetType().BaseType.GetField("_currentItem", flags); // vfi.SetValue(view, null); // fi.SetValue(this, null); // } // } //} #endregion } } ================================================ FILE: FocusLogger/JocysCom/ComponentModel/NotifyPropertyChanged.cs ================================================ using System; using System.ComponentModel; using System.Runtime.CompilerServices; using System.Threading; namespace JocysCom.ClassLibrary.ComponentModel { /// /// INotifyPropertyChanged base with optional WPF Dispatcher marshalling of property-change notifications to the UI thread. /// /// /// Supports MVVM data binding scenarios, with optional UI-thread invocation of property change events. /// public class NotifyPropertyChanged : INotifyPropertyChanged { #region ■ INotifyPropertyChanged private readonly SynchronizationContext _ctx = SynchronizationContext.Current ?? new SynchronizationContext(); /// /// Notifies clients that a property value has changed. /// // SUPPRESS: CWE-502: Deserialization of Untrusted Data // Fix: Apply [field: NonSerialized] attribute to an event inside class with [Serialized] attribute. [field: NonSerialized] public event PropertyChangedEventHandler PropertyChanged; /// /// Raises the PropertyChanged event. When UseApplicationDispatcher is true, the invocation is marshaled to the WPF UI thread via Application.Current.Dispatcher. /// /// Name of the property. protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) { if (UseApplicationDispatcher) { // If already on different thread then... if (SynchronizationContext.Current != _ctx) { void RaiseCore() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); _ctx.Post(_ => RaiseCore(), null); return; } } PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } /// /// When true, marshals property-change notifications to the WPF UI thread via Application.Current.Dispatcher. Defaults to false. /// [field: NonSerialized, DefaultValue(false)] public bool UseApplicationDispatcher = false; /// /// Sets the backing field if the new value differs (per Equals), then invokes OnPropertyChanged. Skips notifications when value is unchanged. /// /// Type of the backing field. /// Reference to the backing field. /// New value to assign. /// Name of the property; supplied by CallerMemberName automatically. protected void SetProperty(ref T property, T value, [CallerMemberName] string propertyName = null) { if (Equals(property, value)) return; property = value; // Invoke overridden OnPropertyChanged method in the most derived class of the object. OnPropertyChanged(propertyName); } #endregion } } ================================================ FILE: FocusLogger/JocysCom/ComponentModel/PropertyComparer.cs ================================================ using System; using System.Collections.Generic; using System.ComponentModel; namespace JocysCom.ClassLibrary.ComponentModel { /// Implements IComparer<T> to sort items by a single PropertyDescriptor or a ListSortDescriptionCollection. /// /// Part of Be.Timvw.Framework.ComponentModel (http://betimvwframework.codeplex.com/). /// Used by SortableBindingList<T> to apply simple and advanced sorting in data-binding contexts. /// A parallel SortComparer<T> in the Collections namespace provides an alternative implementation. /// public class PropertyComparer : IComparer { private readonly ListSortDescriptionCollection _SortCollection = null; private readonly PropertyDescriptor _PropDesc = null; private readonly ListSortDirection _Direction = ListSortDirection.Ascending; public PropertyComparer(PropertyDescriptor propDesc, ListSortDirection direction) { _PropDesc = propDesc; _Direction = direction; } public PropertyComparer(ListSortDescriptionCollection sortCollection) { _SortCollection = sortCollection; } int IComparer.Compare(T x, T y) { return Compare(x, y); } /// /// Compares x and y using the configured sort criteria. /// Uses the single PropertyDescriptor and direction if provided; otherwise applies the ListSortDescriptionCollection recursively. /// Returns 0 when no sort criteria are specified. /// protected int Compare(T x, T y) { if (_PropDesc != null) { var xValue = _PropDesc.GetValue(x); var yValue = _PropDesc.GetValue(y); return CompareValues(xValue, yValue, _Direction); } else if (_SortCollection != null && _SortCollection.Count > 0) return RecursiveCompareInternal(x, y, 0); else return 0; } /// /// Compares two property values, using IComparable when available or falling back to string comparison, and applies sort direction. /// private int CompareValues(object xValue, object yValue, ListSortDirection direction) { int retValue; if (xValue is null && yValue is null) retValue = 0; else if (xValue is IComparable) retValue = ((IComparable)xValue).CompareTo(yValue); else if (yValue is IComparable) retValue = ((IComparable)yValue).CompareTo(xValue); else if (!xValue.Equals(yValue)) retValue = xValue.ToString().CompareTo(yValue.ToString()); else retValue = 0; return (direction == ListSortDirection.Ascending ? 1 : -1) * retValue; } /// /// Recursively compares items by each sort description in the collection until a non-zero result is found or the end is reached. /// private int RecursiveCompareInternal(T x, T y, int index) { if (index >= _SortCollection.Count) return 0; var listSortDesc = _SortCollection[index]; var xValue = listSortDesc.PropertyDescriptor.GetValue(x); var yValue = listSortDesc.PropertyDescriptor.GetValue(y); var retValue = CompareValues(xValue, yValue, listSortDesc.SortDirection); return (retValue == 0) ? RecursiveCompareInternal(x, y, ++index) : retValue; } } } ================================================ FILE: FocusLogger/JocysCom/ComponentModel/SortableBindingList.cs ================================================ using System; using System.Collections.Generic; using System.ComponentModel; namespace JocysCom.ClassLibrary.ComponentModel { /// /// Be.Timvw.Framework.ComponentModel /// http://betimvwframework.codeplex.com/ /// /// [Serializable] public class SortableBindingList : BindingListInvoked, IBindingListView, IRaiseItemChangedEvents { public SortableBindingList() : base() { } public SortableBindingList(IList list) : base(list) { } public SortableBindingList(IEnumerable enumeration) : base(new List(enumeration)) { } public static SortableBindingList From(IEnumerable list) { return new SortableBindingList(list); } protected override bool SupportsSearchingCore => true; protected override bool SupportsSortingCore => true; protected override bool IsSortedCore => _Sorted; protected override ListSortDirection SortDirectionCore => _SortDirection; protected override PropertyDescriptor SortPropertyCore => _SortProperty; ListSortDescriptionCollection IBindingListView.SortDescriptions => SortDescriptions; protected ListSortDescriptionCollection SortDescriptions => _SortDescriptions; bool IBindingListView.SupportsAdvancedSorting => SupportsAdvancedSorting; protected bool SupportsAdvancedSorting => true; bool IBindingListView.SupportsFiltering => SupportsFiltering; protected bool SupportsFiltering => true; bool IRaiseItemChangedEvents.RaisesItemChangedEvents => RaisesItemChangedEvents; protected bool RaisesItemChangedEvents => true; private bool _Sorted = false; private bool _Filtered = false; private string _FilterString = null; private ListSortDirection _SortDirection = ListSortDirection.Ascending; [NonSerialized] private PropertyDescriptor _SortProperty = null; [NonSerialized] private ListSortDescriptionCollection _SortDescriptions = new ListSortDescriptionCollection(); private readonly List _OriginalCollection = new List(); bool IBindingList.AllowNew => CheckReadOnly(); bool IBindingList.AllowRemove => CheckReadOnly(); private bool CheckReadOnly() { return !_Sorted && !_Filtered; } protected override int FindCore(PropertyDescriptor property, object key) { // Simple iteration: for (var i = 0; i < Count; i++) { var item = this[i]; if (property.GetValue(item).Equals(key)) return i; } return -1; // Not found } protected override void ApplySortCore(PropertyDescriptor property, ListSortDirection direction) { _SortDirection = direction; _SortProperty = property; var comparer = new PropertyComparer(property, direction); ApplySortInternal(comparer); } void IBindingListView.ApplySort(ListSortDescriptionCollection sorts) { ApplySort(sorts); } protected void ApplySort(ListSortDescriptionCollection sorts) { _SortProperty = null; _SortDescriptions = sorts; var comparer = new PropertyComparer(sorts); ApplySortInternal(comparer); } private void ApplySortInternal(PropertyComparer comparer) { if (_OriginalCollection.Count == 0) _OriginalCollection.AddRange(this); var listRef = Items as List; if (listRef is null) return; listRef.Sort(comparer); _Sorted = true; OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); } protected override void RemoveSortCore() { if (!_Sorted) return; Clear(); foreach (var item in _OriginalCollection) Add(item); _OriginalCollection.Clear(); _SortProperty = null; _SortDescriptions = null; _Sorted = false; } string IBindingListView.Filter { get { return Filter; } set { Filter = value; } } protected string Filter { get { return _FilterString; } set { _FilterString = value; _Filtered = true; UpdateFilter(); } } void IBindingListView.RemoveFilter() { RemoveFilter(); } protected void RemoveFilter() { if (!_Filtered) return; _FilterString = null; _Filtered = false; _Sorted = false; _SortDescriptions = null; _SortProperty = null; Clear(); foreach (var item in _OriginalCollection) Add(item); _OriginalCollection.Clear(); } protected virtual void UpdateFilter() { var equalsPos = _FilterString.IndexOf('='); // Get property name var propName = _FilterString.Substring(0, equalsPos).Trim(); // Get filter criteria var criteria = _FilterString.Substring(equalsPos + 1, _FilterString.Length - equalsPos - 1).Trim(); // Strip leading and trailing quotes criteria = criteria.Trim('\'', '"'); // Get a property descriptor for the filter property var propDesc = TypeDescriptor.GetProperties(typeof(T))[propName]; if (_OriginalCollection.Count == 0) _OriginalCollection.AddRange(this); var currentCollection = new List(this); Clear(); foreach (var item in currentCollection) { var value = propDesc.GetValue(item); if (string.Format("{0}", value) == criteria) Add(item); } } protected override void InsertItem(int index, T item) { foreach (PropertyDescriptor propDesc in TypeDescriptor.GetProperties(item)) { if (propDesc.SupportsChangeEvents) propDesc.AddValueChanged(item, OnItemChanged); } base.InsertItem(index, item); } protected override void RemoveItem(int index) { var item = Items[index]; var propDescs = TypeDescriptor.GetProperties(item); foreach (PropertyDescriptor propDesc in propDescs) { if (propDesc.SupportsChangeEvents) propDesc.RemoveValueChanged(item, OnItemChanged); } base.RemoveItem(index); } private void OnItemChanged(object sender, EventArgs args) { var index = Items.IndexOf((T)sender); OnListChanged(new ListChangedEventArgs(ListChangedType.ItemChanged, index)); } public void RemoveAll(Func value) { throw new NotImplementedException(); } } } ================================================ FILE: FocusLogger/JocysCom/Configuration/Arguments.cs ================================================ using JocysCom.ClassLibrary.Runtime; using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; namespace JocysCom.ClassLibrary.Configuration { /// /// Represents a case-insensitive command-line arguments parser built on Dictionary<string,string>. /// Splits parameters by -, --, /, =, or :, and removes enclosing quotes from values. /// /// /// Shares parsing logic with , but outputs to this dictionary and does not include logging or flag helper methods. /// public class Arguments : Dictionary //public class Arguments : StringDictionary // Case insensitive. { /// /// Initializes and parses the specified command-line arguments. /// /// Array of command-line arguments to parse. /// Comparer for key matching; defaults to InvariantCultureIgnoreCase if null. public Arguments(string[] args, StringComparer comparer = null) : base(comparer ?? StringComparer.InvariantCultureIgnoreCase) { Regex spliter = new Regex(@"^-{1,2}|^/|=|:", RegexOptions.IgnoreCase | RegexOptions.Compiled); Regex remover = new Regex(@"^['""]?(.*?)['""]?$", RegexOptions.IgnoreCase | RegexOptions.Compiled); string Parameter = null; string[] Parts; // Valid parameters forms: // (-|--|/)param( |=|:)[("|')]value[("|')] // Examples: // -param1 value1 --param2 /param3:"Test-:-work" // /param4=happy -param5 '--=nice=--' foreach (string Txt in args) { // Look for new parameters (-,/ or --) and a // possible enclosed value (=,:) Parts = spliter.Split(Txt, 3); switch (Parts.Length) { // Found a value (for the last parameter // found using space separator) case 1: if (Parameter != null) { if (!base.ContainsKey(Parameter)) { Parts[0] = remover.Replace(Parts[0], "$1"); base.Add(Parameter, Parts[0]); } Parameter = null; } // else Error: no parameter waiting for a value (skipped) break; // Found just a parameter case 2: // The last parameter is still waiting. // With no value, set it to true. if (Parameter != null) { if (!base.ContainsKey(Parameter)) base.Add(Parameter, null); } Parameter = Parts[1]; break; // Parameter with enclosed value case 3: // The last parameter is still waiting. // With no value, set it to true. if (Parameter != null) { if (!base.ContainsKey(Parameter)) base.Add(Parameter, null); } Parameter = Parts[1]; // Remove possible enclosing characters (",') if (!base.ContainsKey(Parameter)) { Parts[2] = remover.Replace(Parts[2], "$1"); base.Add(Parameter, Parts[2]); } Parameter = null; break; } } // In case a parameter is still waiting if (Parameter != null) { if (!base.ContainsKey(Parameter)) base.Add(Parameter, null); } } /// /// Retrieves the value associated with the specified key, or null if not found. /// /// The key to locate in the arguments. /// True to ignore case during key comparison; otherwise, false. /// The value associated with the key; or null if not present. public string GetValue(string key, bool ignoreCase = false) { var keyValue = Keys.Cast().FirstOrDefault(x => string.Compare(x, key, ignoreCase) == 0); return keyValue is null ? null : this[keyValue]; } /// /// Determines whether an entry with the specified key exists, using optional case-insensitive comparison. /// /// The key to locate in the arguments. /// True to ignore case during key comparison; otherwise, false. /// True if an entry with the specified key exists; otherwise, false. public bool ContainsKey(string key, bool ignoreCase) { return Keys .Cast() .Any(x => string.Compare(x, key, ignoreCase) == 0); } //public T GetValue(string key, bool ignoreCase = false, T defaultValue = default) where T : struct //{ // var valueString = GetValue(key, ignoreCase); // return RuntimeHelper.TryParse(valueString, defaultValue); //} } } ================================================ FILE: FocusLogger/JocysCom/Configuration/AssemblyInfo.cs ================================================ using System; using System.Diagnostics; using System.IO; using System.Reflection; using System.Runtime.InteropServices; namespace JocysCom.ClassLibrary.Configuration { /// /// Encapsulates assembly metadata and utilities, including entry assembly detection, /// build timestamps, version/title formatting, and application data path resolution. /// public partial class AssemblyInfo { /// /// Initializes AssemblyInfo for the entry assembly by using GetEntryAssembly and fallbacks /// (stack scan, AppDomain scan, calling/executing assemblies). /// public AssemblyInfo() { Assembly = Assembly.GetEntryAssembly() ?? FindEntryAssembly1() ?? FindEntryAssembly2() ?? Assembly.GetCallingAssembly() ?? Assembly.GetExecutingAssembly(); } public static object _EntryLock = new object(); /// /// Gets or sets the singleton entry AssemblyInfo instance; thread-safe. /// public static AssemblyInfo Entry { get { lock (_EntryLock) { if (_Entry is null) _Entry = new AssemblyInfo(); return _Entry; } } set { lock (_EntryLock) { _Entry = value; } } } static AssemblyInfo _Entry; public AssemblyInfo(string strValFile) { Assembly = Assembly.LoadFile(strValFile); } public AssemblyInfo(Assembly assembly) { Assembly = assembly; } #region Entry assembly /// /// Assembly.GetEntryAssembly() returns null in web applications. Mark assembly as the entry assembly /// by adding this attribute inside Properties\AssemblyInfo.cs file: /// [assembly: JocysCom.ClassLibrary.Configuration.AssemblyInfo.EntryAssembly] /// [AttributeUsage(AttributeTargets.Assembly)] public sealed class EntryAssemblyAttribute : Attribute { } // Method 1 better works on multiple assemblies marked as entry. /// /// Finds entry assembly by scanning reversed call stack for EntryAssemblyAttribute markers. /// Assembly FindEntryAssembly1() { var frames = new StackTrace().GetFrames(); Array.Reverse(frames); foreach (var frame in frames) { var declaringType = frame.GetMethod().DeclaringType; var assembly = Assembly.GetAssembly(declaringType); var attribute = assembly.GetCustomAttributes(typeof(EntryAssemblyAttribute), false); if (attribute.Length > 0) return assembly; } return null; } // Find on current domain. /// /// Finds entry assembly by scanning loaded AppDomain assemblies for EntryAssemblyAttribute markers. /// Assembly FindEntryAssembly2() { var assemblies = AppDomain.CurrentDomain.GetAssemblies(); foreach (var assembly in assemblies) { var attribute = assembly.GetCustomAttributes(typeof(EntryAssemblyAttribute), false); if (attribute.Length > 0) return assembly; } return null; } #endregion public Assembly Assembly { get; set; } DateTime? _BuildDateTime; object BuildDateTimeLock = new object(); /// /// Gets the assembly's build timestamp from metadata, cached per instance (thread-safe). /// public DateTime BuildDateTime { get { lock (BuildDateTimeLock) { if (!_BuildDateTime.HasValue) _BuildDateTime = GetBuildDateTime(Assembly); return _BuildDateTime.Value; } } } object FullTitleLock = new object(); string _FullTitle; /// /// Gets the full application title including version, build metadata, run mode, architecture, and description (thread-safe). /// public string FullTitle { get { lock (FullTitleLock) { if (string.IsNullOrEmpty(_FullTitle)) _FullTitle = GetTitle(); return _FullTitle; } } } /// /// Gets the configured runtime mode; currently returns empty until configuration provider is standardized. /// public string RunMode { get { if (_RunMode is null) // TODO: Standardize configuration provider XML, JSON, INI, Registry, etc... // https://docs.microsoft.com/en-us/dotnet/core/extensions/configuration-providers //_RunMode = SettingsParser.Current.Parse("RunMode", ""); return ""; return _RunMode; } } public string _RunMode; /// /// Builds a descriptive title string for the assembly, including version, release stage (Alpha/Beta/RC/RTM/GA), /// optional run mode, build date, architecture, description, and user context. /// public string GetTitle(bool showBuild = true, bool showRunMode = true, bool showBuildDate = true, bool showArchitecture = true, bool showDescription = true, int versionNumbers = 3) { var s = string.Format("{0} {1} {2}", Company, Product, Version.ToString(versionNumbers)); if (showBuild) { // Version = major.minor.build.revision switch (Version.Build) { case 0: s += " Alpha"; break; // Alpha Release (AR) case 1: s += " Beta 1"; break; // Master Beta (MB) case 2: s += " Beta 2"; break; // Feature Complete (FC) case 3: s += " Beta 3"; break; // Technical Preview (TP) case 4: s += " RC"; break; // Release Candidate (RC) case 5: s += " RTM"; break; // Release to Manufacturing (RTM) default: break; // General Availability (GA) - Gold } } var haveRunMode = !string.IsNullOrEmpty(RunMode); // If run mode is not specified then assume live. var nonLive = haveRunMode && string.Compare(RunMode, "LIVE", true) != 0; if (showBuildDate || (showRunMode && nonLive)) { s += " ("; if (showRunMode && nonLive) { s += string.Format("{0}", RunMode); if (showBuildDate) s += " "; } if (showBuildDate) { s += string.Format("Build: {0:yyyy-MM-dd}", BuildDateTime); } s += ")"; } if (showArchitecture) { switch (RuntimeInformation.ProcessArchitecture) { case Architecture.X64: case Architecture.Arm64: s += " 64-bit"; break; case Architecture.X86: case Architecture.Arm: s += " 32-bit"; break; default: // Default is MSIL: Any CPU, show nothing/ break; } } if (showDescription && !string.IsNullOrEmpty(Description) && !s.Contains(Description)) { s += " - " + Description; } #if NETFRAMEWORK // .NET Framework // Add elevated tag. var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); var isElevated = identity.Owner != identity.User; // Add running user. string windowsDomain = GetWindowsDomainName(); string windowsUser = GetWindowsUserName(); string processDomain = Environment.UserDomainName; string processUser = Environment.UserName; if (string.Compare(windowsDomain, processDomain, true) != 0 || string.Compare(windowsUser, processUser, true) != 0) s += string.Format(" ({0}\\{1})", processDomain, processUser); else if (isElevated) s += " (Administrator)"; // if (WinAPI.IsVista && WinAPI.IsElevated() && WinAPI.IsInAdministratorRole) Text += " (Administrator)"; #endif return s.Trim(); } #if NETFRAMEWORK // .NET Framework internal partial class NativeMethods { [DllImport("wtsapi32.dll")] internal static extern bool WTSQuerySessionInformationW( IntPtr hServer, int SessionId, int WTSInfoClass, out IntPtr ppBuffer, out IntPtr pBytesReturned ); } /// /// Retrieves the current Windows session domain name via WTSQuerySessionInformationW. /// public string GetWindowsDomainName() { return GetInformation(7); } /// /// Retrieves the current Windows session user name via WTSQuerySessionInformationW. /// public string GetWindowsUserName() { return GetInformation(5); } /// /// Invokes WTSQuerySessionInformation to query session-specific information (e.g., domain or user name). /// private static string GetInformation(int WTSInfoClass) { // Use current context. var WTS_CURRENT_SERVER_HANDLE = IntPtr.Zero; var p = System.Diagnostics.Process.GetCurrentProcess(); IntPtr AnswerBytes; IntPtr AnswerCount; // Get domain name. var success = NativeMethods.WTSQuerySessionInformationW( WTS_CURRENT_SERVER_HANDLE, p.SessionId, WTSInfoClass, out AnswerBytes, out AnswerCount ); return Marshal.PtrToStringUni(AnswerBytes); } #endif /// /// Reads the PE header timestamp from the specified file to determine its build time; not valid for deterministic builds. /// /// /// The C# compiler (Roslyn) supports deterministic builds since Visual Studio 2015. /// This means that compiling assemblies under the same conditions (permalink) /// would produce byte-for-byte equivalent binaries. /// public static DateTime GetBuildDateTime(string filePath) { // Constants related to the Windows PE file format. const int PE_HEADER_OFFSET = 60; // 0x3C const int LINKER_TIMESTAMP_OFFSET = 8; // Read header from file byte[] b = new byte[2048]; Stream s = null; try { s = new FileStream(filePath, FileMode.Open, FileAccess.Read); var bytesToRead = (int)Math.Min(s.Length, b.Length); #if NET7_0_OR_GREATER s.ReadExactly(b, 0, bytesToRead); #else s.Read(b, 0, bytesToRead); #endif } finally { if (s != null) s.Close(); } // Read the linker TimeStamp var offset = BitConverter.ToInt32(b, PE_HEADER_OFFSET); var secondsSince1970 = BitConverter.ToInt32(b, offset + LINKER_TIMESTAMP_OFFSET); var dt = GetDateTime(secondsSince1970); return dt; } /// /// Reads build time from an assembly, using embedded resource fallback; requires workaround for deterministic builds. /// /// /// You have two options: /// /// Option 1: Disable Deterministic build by adding /// /// <Deterministic>False</Deterministic> inside a <PropertyGroup> section of .csproj /// /// Option 2: /// /// Create "Resources\BuildDate.txt" and set its "Build Action: Embedded Resource" /// Add to pre-build event to work with latest .NET builds: /// /// PowerShell.exe -Command "New-Item -ItemType Directory -Force -Path \"$(ProjectDir)Resources\" | Out-Null" /// PowerShell.exe -Command "(Get-Date).ToString(\"o\") | Out-File \"$(ProjectDir)Resources\BuildDate.txt\"" /// /// Note: /// The C# compiler (Roslyn) supports deterministic builds since Visual Studio 2015. /// This means that compiling assemblies under the same conditions (permalink) /// would produce byte-for-byte equivalent binaries. /// public static DateTime GetBuildDateTime(Assembly assembly, TimeZoneInfo tzi = null) { if (assembly is null) throw new ArgumentNullException(nameof(assembly)); var names = assembly.GetManifestResourceNames(); var dt = default(DateTime); foreach (var name in names) { if (!name.EndsWith("BuildDate.txt")) continue; var stream = assembly.GetManifestResourceStream(name); using (var reader = new StreamReader(stream)) { var date = reader.ReadToEnd(); dt = DateTime.Parse(date); dt = TimeZoneInfo.ConvertTime(dt, tzi ?? TimeZoneInfo.Local); return dt; } } #if NETFRAMEWORK // .NET Framework // Constants related to the Windows PE file format. const int PE_HEADER_OFFSET = 60; const int LINKER_TIMESTAMP_OFFSET = 8; // Discover the base memory address where our assembly is loaded var entryModule = assembly.ManifestModule; var hMod = Marshal.GetHINSTANCE(entryModule); if (hMod == IntPtr.Zero - 1) throw new Exception("Failed to get HINSTANCE."); // Read the linker TimeStamp var offset = Marshal.ReadInt32(hMod, PE_HEADER_OFFSET); var secondsSince1970 = Marshal.ReadInt32(hMod, offset + LINKER_TIMESTAMP_OFFSET); dt = GetDateTime(secondsSince1970); #endif return dt; } /// /// Converts seconds since Unix epoch to a DateTime in the specified timezone. /// static DateTime GetDateTime(int secondsSince1970, TimeZoneInfo tzi = null) { var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); var linkTimeUtc = epoch.AddSeconds(secondsSince1970); return TimeZoneInfo.ConvertTimeFromUtc(linkTimeUtc, tzi ?? TimeZoneInfo.Local); } /// /// Gets the unescaped local file path of the loaded assembly. /// public string AssemblyPath { get { var codeBase = Assembly.Location; if (string.IsNullOrEmpty(codeBase)) return codeBase; var uri = new UriBuilder(codeBase); var path = Uri.UnescapeDataString(uri.Path); return path; } } public string AssemblyFullName { get { return Assembly.GetName().FullName.ToString(); } } public string AssemblyName { get { return Assembly.GetName().Name.ToString(); } } public string CodeBase { get { return Assembly.Location; } } public string Company { get { return GetAttribute(a => a.Company); } } public string Product { get { return GetAttribute(a => a.Product); } } public string Copyright { get { return GetAttribute(a => a.Copyright); } } public string Trademark { get { return GetAttribute(a => a.Trademark); } } public string Title { get { return GetAttribute(a => a.Title); } } public string Description { get { return GetAttribute(a => a.Description); } } public string Configuration { get { return GetAttribute(a => a.Configuration); } } public string FileVersion { get { return GetAttribute(a => a.Version); } } public string ProductGuid { get { return GetAttribute(a => a.Value); } } public Version Version { get { return Assembly.GetName().Version; } } string GetAttribute(Func value) where T : Attribute { T attribute = (T)Attribute.GetCustomAttribute(Assembly, typeof(T)); return attribute is null ? "" : value.Invoke(attribute); } /// /// Constructs a path under user or common application data folder for this assembly's Company/Product and optional file name. /// public string GetAppDataPath(bool userLevel = false, string format = "", params object[] args) { // Get writable application folder. var specialFolder = userLevel ? Environment.SpecialFolder.ApplicationData : Environment.SpecialFolder.CommonApplicationData; var folder = string.Format("{0}\\{1}\\{2}", Environment.GetFolderPath(specialFolder), Company, Product); // Get file name. var file = string.Format(format, args); var path = Path.Combine(folder, file); return path; } /// /// Creates a FileInfo for a file in the application data directory for this assembly. /// public FileInfo GetAppDataFile(bool userLevel = false, string format = "", params object[] args) { var path = GetAppDataPath(userLevel, format, args); return new FileInfo(path); } } } ================================================ FILE: FocusLogger/JocysCom/Configuration/ISettingsData.cs ================================================ using System; using System.ComponentModel; using System.IO; namespace JocysCom.ClassLibrary.Configuration { /// Contract for managing settings data persisted as XML, including reset, load, save, and individual file operations. public interface ISettingsData { /// Reset settings data to default values. /// True if operation was successful; otherwise, False. bool ResetToDefault(); /// Save current settings data via XML serialization to the default file location. void Save(object[] items = null); /// Save current settings data via XML serialization to the specified file. /// Destination file name or path. void SaveAs(string fileName, object[] items = null); /// Load settings data from the default XML file. void Load(); /// Load settings data from the specified XML file. /// Source file name or path. void LoadFrom(string fileName); /// FileInfo for the XML file that stores settings data. FileInfo XmlFile { get; } /// Collection of settings items for data binding. IBindingList Items { get; } /// Raised when a file associated with the settings data changes. event EventHandler FilesChanged; /// Indicates whether there are pending save operations. bool IsSavePending { get; set; } /// Delete the specified settings item file and update settings data accordingly. /// Settings item file to delete. /// A message indicating the result of the delete operation. string DeleteItem(ISettingsFileItem itemFile); /// Rename the specified settings item file and update settings data and file system accordingly. /// Settings item file to rename. /// New name for the settings item file. /// A message indicating the outcome of the rename operation. string RenameItem(ISettingsFileItem itemFile, string newName); } } ================================================ FILE: FocusLogger/JocysCom/Configuration/ISettingsFileItem.cs ================================================ using System; using System.ComponentModel; using System.Xml.Serialization; namespace JocysCom.ClassLibrary.Configuration { /// File-based ISettingsItem: exposes Path, Name, BaseName, WriteTime, and IsReadOnlyFile. public interface ISettingsFileItem : ISettingsItem { /// Relative directory path of the settings file. [DefaultValue(null)] string Path { get; set; } /// File name of the settings file without extension. [DefaultValue(null)] string Name { get; set; } /// Internal base name (without extension) for identifying the settings file. [DefaultValue(null)] string BaseName { get; set; } /// Timestamp of the last write to the settings file, for change tracking. DateTime WriteTime { get; set; } /// Excludes this item from being saved to a separate file when true. [XmlIgnore, DefaultValue(false)] bool IsReadOnlyFile { get; set; } /* Example: #region ■ ISettingsItemFile [XmlIgnore] string ISettingsItemFile.BaseName { get => Name; set => Name = value; } [XmlIgnore] DateTime ISettingsItemFile.WriteTime { get; set; } #endregion protected void OnPropertyChanged([CallerMemberName] string propertyName = null) { ((ISettingsItemFile)this).WriteTime = DateTime.Now; } */ } } ================================================ FILE: FocusLogger/JocysCom/Configuration/ISettingsItem.cs ================================================ using System.ComponentModel; namespace JocysCom.ClassLibrary.Configuration { /// Defines a settings item that notifies on property changes and indicates whether it is enabled or empty. public interface ISettingsItem : INotifyPropertyChanged { /// Indicates whether the item is enabled. Implementers must raise PropertyChanged when this value changes. bool IsEnabled { get; set; } /// Indicates whether the item is uninitialized or contains no data. bool IsEmpty { get; } } } ================================================ FILE: FocusLogger/JocysCom/Configuration/ISettingsListFileItem.cs ================================================ using System; using System.Windows.Media; namespace JocysCom.ClassLibrary.Configuration { /// Defines a settings file item for list-based user interfaces with selection, status, and icon serialization. /// /// Provides grouping metadata properties for UI grouping support: /// ListGroupTimeSortKey, ListGroupPathSortKey, ListGroupNameSortKey, /// ListGroupTime, ListGroupPath, and ListGroupName. /// public interface ISettingsListFileItem : ISettingsFileItem { bool IsChecked { get; set; } string StatusText { get; set; } System.Windows.MessageBoxImage StatusCode { get; set; } DrawingImage Icon { get; } /// Icon file extension or type (e.g. ".svg"). string IconType { get; set; } /// Base64-encoded icon data. string IconData { get; set; } /// Sets the icon data by encoding the provided contents as base64. /// Raw icon content (e.g. SVG). /// File extension/type (default ".svg"). void SetIcon(string contents, string type = ".svg"); // List Properties. bool IsPinned { get; set; } DateTime? Created { get; set; } DateTime? Modified { get; set; } int ListGroupTimeSortKey { get; } string ListGroupPathSortKey { get; } string ListGroupNameSortKey { get; } string ListGroupTime { get; } string ListGroupPath { get; } string ListGroupName { get; } } } ================================================ FILE: FocusLogger/JocysCom/Configuration/SettingsData.cs ================================================ using JocysCom.ClassLibrary.Collections; using JocysCom.ClassLibrary.ComponentModel; using JocysCom.ClassLibrary.Runtime; using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.Serialization; using System.Text; using System.Text.Json.Serialization; using System.Windows; using System.Xml; using System.Xml.Serialization; namespace JocysCom.ClassLibrary.Configuration { /// /// Represents a container for managing settings data T. /// Enables saving and loading of settings either as a single or multiple XML files. /// /// The type of settings data this container will manage. [Serializable, XmlRoot("Data"), DataContract] public class SettingsData : ISettingsData { /// /// Initializes a new instance of the SettingsData class with default settings. /// public SettingsData() { Initialize(null, false, null, null); } /// /// Initializes a new instance of the SettingsData class with specific settings. /// /// Specifies a custom file name for the settings file. If null, a default name based on the type T is used. /// Determines the storage location of the XML settings file. True to use user-specific storage, False for common storage, Null for executable directory. /// A comment to include within the XML settings file. /// The assembly to use for retrieving default company and product name for folder path generation. /// /// userLevel param defines where to store XML settings file: /// True - Environment.SpecialFolder.ApplicationData /// False - Environment.SpecialFolder.CommonApplicationData /// Null - Use ./{ExecutableBaseName}.xml settings file /// public SettingsData(string overrideFileName = null, bool? userLevel = false, string comment = null, Assembly assembly = null) { Initialize(overrideFileName, userLevel, comment, assembly); } private List _Assemblies; private string _Company; private string _Product; private string GetAppDataPath(bool userLevel = false) { var mainAssembly = _Assemblies.First(); _Company = ((AssemblyCompanyAttribute)Attribute.GetCustomAttribute(mainAssembly, typeof(AssemblyCompanyAttribute))).Company; _Product = ((AssemblyProductAttribute)Attribute.GetCustomAttribute(mainAssembly, typeof(AssemblyProductAttribute))).Product; // Get writable application folder. var specialFolder = userLevel ? Environment.SpecialFolder.ApplicationData : Environment.SpecialFolder.CommonApplicationData; var path = string.Format("{0}\\{1}\\{2}", Environment.GetFolderPath(specialFolder), _Company, _Product); return path; } /// /// Initialize class. /// private void Initialize(string overrideFileName, bool? userLevel, string comment, Assembly assembly) { // Wraps all methods into lock. //var items = System.Collections.ArrayList.Synchronized(Items); Items = new SortableBindingList(); _Comment = comment; // Get assemblies which will be used to select default (fists) and search for resources. _Assemblies = new List{ assembly, Assembly.GetEntryAssembly(), Assembly.GetExecutingAssembly(), }.Where(x => x != null) .Distinct() .ToList(); string folder; string fileBaseName; // Check if there is a folder with the same name as executable. folder = GetLocalSettingsDirectory(); if (userLevel.HasValue) { if (string.IsNullOrEmpty(folder)) folder = GetAppDataPath(userLevel.Value); fileBaseName = typeof(T).Name; } else { var fullName = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName; if (string.IsNullOrEmpty(folder)) folder = System.IO.Path.GetDirectoryName(fullName); fileBaseName = System.IO.Path.GetFileNameWithoutExtension(fullName); } string fileName = fileBaseName + ".xml"; // If override file name is set then override the file name. if (!string.IsNullOrEmpty(overrideFileName)) fileName = overrideFileName; var path = Path.Combine(folder, fileName); _XmlFile = new FileInfo(path); } /// /// Retrieves the directory path used for storing local settings, typically named after the executable. /// /// The directory path or null if the directory does not exist. public string GetLocalSettingsDirectory() { var moduleFileName = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName; var fi = new FileInfo(moduleFileName); var settingsFolderName = System.IO.Path.GetFileNameWithoutExtension(fi.Name); // Parse command line settings. var args = Environment.GetCommandLineArgs(); var ic = new JocysCom.ClassLibrary.Configuration.Arguments(args); // ------------------------------------------------ if (ic.ContainsKey("SettingsPath")) { var settingsPath = ic["SettingsPath"]; if (!string.IsNullOrEmpty(settingsPath)) { var sdi = new DirectoryInfo(settingsPath); if (sdi.Exists) return sdi.FullName; } } // Check if folder with settings exists in the same folder as executable. var path = Path.Combine(fi.Directory.FullName, settingsFolderName); var di = new DirectoryInfo(path); if (di.Exists) return di.FullName; return null; } /// /// Indicates whether saving the settings is pending. This can be used to optimize write operations by delaying them until necessary. /// [XmlIgnore, JsonIgnore] public bool IsSavePending { get; set; } /// /// Indicates whether loading the settings is pending. Useful for deferring the loading operation until it's required. /// [XmlIgnore, JsonIgnore] public bool IsLoadPending { get; set; } /// /// Determines whether settings are stored in separate files. /// [XmlIgnore, JsonIgnore] public bool UseSeparateFiles { get; set; } /// /// Gets or sets the FileInfo object for the XML file that stores the settings data. /// [XmlIgnore, JsonIgnore] public FileInfo XmlFile { get { return _XmlFile; } set { _XmlFile = value; } } [NonSerialized] protected FileInfo _XmlFile; [NonSerialized] protected string _Comment; /// /// A list of settings items managed by this instance. /// [DataMember] public SortableBindingList Items { get; set; } [NonSerialized] private object _SyncRoot; /// /// Synchronization root object for thread-safe operations. /// public virtual object SyncRoot { get { if (_SyncRoot is null) System.Threading.Interlocked.CompareExchange(ref _SyncRoot, new object(), null); return _SyncRoot; } } /// /// Converts the items in the collection to an array and returns it. /// This operation is synchronized to prevent data inconsistency during the conversion. /// /// An array of items. public T[] ItemsToArraySynchronized() { lock (SyncRoot) return Items.ToArray(); } [XmlIgnore, JsonIgnore] IBindingList ISettingsData.Items { get { return Items; } } public delegate void ApplyOrderDelegate(SettingsData source); [XmlIgnore, JsonIgnore, NonSerialized] public ApplyOrderDelegate ApplyOrder; /// /// File Version. /// [XmlAttribute] public int Version { get; set; } [XmlIgnore, JsonIgnore, NonSerialized] object saveReadFileLock = new object(); /// /// Occurs when the settings data is about to be saved to the XML file, allowing for pre-save operations or validation. /// public event EventHandler Saving; /// /// Saves the current settings into an XML file at the specified path. Compresses the file if the file extension is .gz. /// /// The file path where the settings will be saved. /// Specific items to save. If null then save all items. public void SaveAs(string path, object[] items = null) { SetFileMonitoring(false); var ev = Saving; if (ev != null) ev(this, new EventArgs()); var tItems = items?.Cast().ToArray() ?? ItemsToArraySynchronized(); lock (saveReadFileLock) { // Remove unique primary keys. var type = tItems.FirstOrDefault()?.GetType(); if (type != null && type.Name.EndsWith("EntityObject")) { var pi = type.GetProperty("EntityKey"); for (int i = 0; i < tItems.Length; i++) pi.SetValue(tItems[i], null); } var fi = new FileInfo(path); var compress = fi.Name.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); // If each item will be saved to a separate file. if (UseSeparateFiles) { var di = GetRootDirectory(fi); // Create directory because it will be watched for changes. if (!di.Exists) di.Create(); for (int i = 0; i < tItems.Length; i++) { var fileItem = (ISettingsFileItem)tItems[i]; if (fileItem.IsReadOnlyFile) continue; var fileFullName = GetFileItemFullName(path, fileItem); var fiItem = new FileInfo(fileFullName); if (!fiItem.Directory.Exists) fiItem.Directory.Create(); var bytes = Serialize(fileItem); if (compress) bytes = SettingsHelper.Compress(bytes); if (!AllowWriteFile(fiItem)) continue; if (!SettingsHelper.WriteIfDifferent(fileFullName, bytes)) continue; fi.Refresh(); fileItem.WriteTime = new FileInfo(fileFullName).LastWriteTime; // Update last write time. fiItem.Refresh(); SetLastWriteTime(fiItem); } } else { if (!fi.Directory.Exists) fi.Directory.Create(); var bytes = Serialize(this); if (compress) bytes = SettingsHelper.Compress(bytes); if (AllowWriteFile(fi)) { if (SettingsHelper.WriteIfDifferent(fi.FullName, bytes)) { // Update last write time. fi.Refresh(); SetLastWriteTime(fi); } } } } IsSavePending = false; SetFileMonitoring(true); } public static string RemoveInvalidPathChars(string name) { var invalidChars = Path.GetInvalidPathChars(); return new string(name.Where(c => !invalidChars.Contains(c)).ToArray()); } public static string RemoveInvalidFileNameChars(string name) { var invalidChars = Path.GetInvalidFileNameChars(); return new string(name.Where(c => !invalidChars.Contains(c)).ToArray()); } /// /// Save items. /// /// If items is null then save all items. public void Save(object[] items = null) { SaveAs(_XmlFile.FullName, items); } /// /// Settings root directory. /// public DirectoryInfo RootDirectory => UseSeparateFiles ? GetRootDirectory(_XmlFile) : _XmlFile.Directory; /// /// Adds an array of items to the current collection of settings data. /// /// The array of items to add. public void Add(params T[] items) { lock (SyncRoot) foreach (var item in items) Items.Add(item); } /// /// Removes an array of items from the current collection of settings data. /// /// The array of items to remove. public void Remove(params T[] items) { lock (SyncRoot) foreach (var item in items) Items.Remove(item); } public class SettingsDataEventArgs : EventArgs { public SettingsDataEventArgs(IList items) { Items = items; } public IList Items { get; } public bool Handled { get; set; } } public class ItemPropertyChangedEventArgs : PropertyChangedEventArgs { /// The old value of the property. public object OldValue { get; } /// The new value of the property. public object NewValue { get; } public ItemPropertyChangedEventArgs(string propertyName, object oldValue, object newValue) : base(propertyName) { OldValue = oldValue; NewValue = newValue; } } public delegate IList ValidateDataDelegate(IList items); [XmlIgnore, JsonIgnore, NonSerialized] public ValidateDataDelegate ValidateData; /// /// Occurs when data validation is required, providing an opportunity to perform custom validation logic on settings data. /// public event EventHandler OnValidateData; #region Last Write Time [XmlIgnore, JsonIgnore] public bool PreventWriteToNewerFiles { get; set; } = true; [XmlIgnore, JsonIgnore, NonSerialized] private Dictionary LastWriteTimes = new Dictionary(); /// /// Record the LastWriteTime when loading for later comparison when saving. /// private void SetLastWriteTime(FileInfo fi) { // If file was deleted or don't exists. if (!fi.Exists) return; if (LastWriteTimes.ContainsKey(fi.FullName)) LastWriteTimes[fi.FullName] = fi.LastWriteTime; else LastWriteTimes.Add(fi.FullName, fi.LastWriteTime); } private bool IsNewerOnDisk(FileInfo fi) { fi.Refresh(); // If file was deleted or don't exists. if (!fi.Exists) return false; if (!LastWriteTimes.ContainsKey(fi.FullName)) return false; return fi.Exists && fi.LastWriteTime > LastWriteTimes[fi.FullName]; } private bool AllowWriteFile(FileInfo fi) { fi.Refresh(); // If file was deleted or don't exists. if (!fi.Exists) return true; if (!PreventWriteToNewerFiles) return true; return !IsNewerOnDisk(fi); } #endregion public void Load() { LoadFrom(_XmlFile.FullName); } static DirectoryInfo GetRootDirectory(FileInfo fi) { var compress = fi.Name.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); var dirName = Path.GetFileNameWithoutExtension(fi.FullName); if (compress) dirName = Path.GetFileNameWithoutExtension(dirName); var dirPath = Path.Combine(fi.Directory.FullName, dirName); var di = new DirectoryInfo(dirPath); return di; } /// /// Loads settings data from an XML file specified by the fileName. /// /// The file name/path from which to load settings data. public void LoadFrom(string fileName) { var settingsLoaded = false; var fi = new FileInfo(fileName); var di = GetRootDirectory(fi); var compress = fi.Name.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); // If configuration file exists then... if (fi.Exists || di.Exists) { SettingsData data = null; // Try to read file until success. while (true) { // Deserialize and load data. lock (saveReadFileLock) { try { // If each item will be saved to a separate file. if (UseSeparateFiles) { data = new SettingsData(); var files = di.GetFiles("*" + fi.Extension, SearchOption.AllDirectories); var fileItems = new List(); for (int i = 0; i < files.Length; i++) { var file = files[i]; // Record the LastWriteTime for later comparison. SetLastWriteTime(file); var bytes = System.IO.File.ReadAllBytes(file.FullName); try { var item = DeserializeItem(bytes, compress); var fileItem = (ISettingsFileItem)item; fileItem.WriteTime = file.LastWriteTime; // Set Name property value to the same as the file. var name = RemoveInvalidFileNameChars(file.Name); var fileBaseName = Path.GetFileNameWithoutExtension(file.Name); var path = IO.PathHelper.GetRelativePath(di.FullName + "\\", file.Directory.FullName + "\\"); path = path.TrimEnd('.', '\\', '/'); fileItem.Path = string.IsNullOrWhiteSpace(path) ? null : path; if (fileItem.BaseName != fileBaseName) fileItem.BaseName = fileBaseName; fileItems.Add(fileItem); var listItem = fileItem as ISettingsListFileItem; if (listItem != null) { // Created can be later than file creation. if (listItem.Created == DateTime.MinValue || listItem.Created > file.CreationTime) listItem.Created = file.CreationTime; // Modified can be earlier than Created. if (listItem.Modified < listItem.Created) listItem.Modified = listItem.Created; } } catch { } } SortList(fileItems); foreach (var fileItem1 in fileItems) data.Add((T)fileItem1); } else { // Record the LastWriteTime for later comparison. SetLastWriteTime(fi); var bytes = System.IO.File.ReadAllBytes(fi.FullName); data = DeserializeData(bytes, compress); } break; } catch (Exception ex) { var backupFile = fi.FullName + ".bak"; var sb = new StringBuilder(); sb.AppendFormat("{0} file has become corrupted.\r\n\r\n" + "Reason: " + ex.Message + "\r\n\r\n" + "Program must reset {0} file in order to continue.\r\n\r\n" + " Click [Yes] to reset and continue.\r\n" + " Click [No] if you wish to attempt manual repair.\r\n\r\n" + " File: {1}", fi.Name, fi.FullName); sb.AppendLine(); sb.Append('-', 64); sb.AppendLine(); sb.AppendLine(ex.ToString()); var caption = string.Format("Corrupt {0} of {1}", fi.Name, _Product); //var form = new MessageBox(); //form.StartPosition = FormStartPosition.CenterParent; var text = sb.ToString(); bool reset; var result = MessageBox.Show(text, caption, MessageBoxButton.YesNo, MessageBoxImage.Error); reset = result == MessageBoxResult.Yes; if (reset) { if (System.IO.File.Exists(backupFile)) { System.IO.File.Copy(backupFile, fi.FullName, true); fi.Refresh(); } else { System.IO.File.Delete(fi.FullName); break; } } else { // Avoid the inevitable crash by killing application first. Process.GetCurrentProcess().Kill(); return; } } } } // If data read was successful then... if (data != null) { // Reorder data of order method exists. var ao = ApplyOrder; if (ao != null) ao(data); Version = data.Version; LoadAndValidateData(data.Items); settingsLoaded = true; } } // If settings failed to load then... if (!settingsLoaded) { ResetToDefault(); Save(); } } /// Sort the list with the fewest UI changes. public void SortList(IList items) where T1 : ISettingsFileItem { // Move works with special characters to the end. var newItems = items .OrderBy(x => x.Path) .ThenBy(x => x.Name.StartsWith("®")) .ThenBy(x => x.Name) .ToList(); CollectionsHelper.Synchronize(newItems, items); } #region Use Separate Files /// /// Get the full path for a file based on a filename with extension. /// public string GetFileItemFullName(ISettingsFileItem fileItem, string overrideBaseName = null) { var fileFullName = GetFileItemFullName(_XmlFile.FullName, fileItem, overrideBaseName); var fiItem = new FileInfo(fileFullName); if (!fiItem.Directory.Exists) fiItem.Directory.Create(); return fileFullName; } public string GetFileItemFullBaseName(ISettingsFileItem fileItem) { var fi = new FileInfo(_XmlFile.FullName); var di = GetRootDirectory(fi); var fileName = RemoveInvalidFileNameChars(fileItem.BaseName); var itemPath = fileItem.Path; if (!string.IsNullOrEmpty(itemPath)) itemPath = RemoveInvalidPathChars(itemPath); var fileFullName = string.IsNullOrEmpty(itemPath) ? Path.Combine(di.FullName, fileName) : Path.Combine(di.FullName, itemPath, fileName); return fileFullName; } /// /// Get item path when using separte files. /// public static string GetFileItemFullName(string rootPath, ISettingsFileItem fileItem, string overrideBaseName = null) { var fi = new FileInfo(rootPath); var di = GetRootDirectory(fi); var fileName = RemoveInvalidFileNameChars(overrideBaseName ?? fileItem.BaseName) + fi.Extension; var itemPath = fileItem.Path; if (!string.IsNullOrEmpty(itemPath)) itemPath = RemoveInvalidPathChars(itemPath); var fileFullName = string.IsNullOrEmpty(itemPath) ? Path.Combine(di.FullName, fileName) : Path.Combine(di.FullName, itemPath, fileName); return fileFullName; } /// /// Renames the specified folder to a new name, managing potential case-sensitivity issues on certain file systems by temporarily renaming to a GUID-based name. /// /// The current path of the folder to be renamed. /// The new name for the folder. /// A message indicating success, error, or null if the operation is successful. public string RenameFolder(string currentPath, string newFolderName) { try { // If directory don't exists if (!Directory.Exists(currentPath)) return null; var directoryInfo = new DirectoryInfo(currentPath); var parentDirectory = directoryInfo.Parent.FullName; var newPath = Path.Combine(parentDirectory, newFolderName); // check if the new folder name is different from the current one (ignoring the case) if (string.Equals(directoryInfo.Name, newFolderName, StringComparison.OrdinalIgnoreCase)) { // rename to temp folder first if only the casing is changed var tempPath = Path.Combine(parentDirectory, Guid.NewGuid().ToString()); directoryInfo.MoveTo(tempPath); } else if (Directory.Exists(newPath)) { return "Folder with the same name already exists."; } directoryInfo.MoveTo(newPath); } catch (Exception ex) { return "An error occurred: " + ex.Message; } return null; } /// /// Occurs when item was renamed. /// public event EventHandler ItemRenamed; /// /// Renames a settings item file to a new name, ensuring file system consistency and updating internal metadata accordingly. /// If folder with the same name exists. then rename the folder too. /// /// The settings item file object to be renamed. /// The new name for the settings item file. /// A message indicating the outcome of the operation or null if the operation is successful. public string RenameItem(ISettingsFileItem fileItem, string newName) { lock (saveReadFileLock) { var oldName = RemoveInvalidFileNameChars(fileItem.BaseName); // If the names are exactly the same (case sensitive comparison), no renaming is needed. if (string.Equals(oldName, newName, StringComparison.Ordinal)) return null; if (string.IsNullOrEmpty(newName)) return "The file name cannot be empty. Please provide a valid name."; //newName = RemoveInvalidFileNameChars(newName); var invalidChars = newName.Intersect(Path.GetInvalidFileNameChars()); if (invalidChars.Any()) return $"The file name contains invalid character(s): {string.Join("", invalidChars)}"; var oldPath = GetFileItemFullName(fileItem, oldName); var file = new FileInfo(oldPath); var newPath = GetFileItemFullName(fileItem, newName); // Disable monitoring in order not to trigger reloading. SetFileMonitoring(false); try { // Rename folder if folder with the same name exists. var folderPath = Path.Combine(file.Directory.FullName, oldName); var error = RenameFolder(folderPath, newName); if (!string.IsNullOrEmpty(error)) return error; // Rename file. // If only case changed then... if (string.Equals(oldName, newName, StringComparison.OrdinalIgnoreCase)) { // rename to temp file first. var tempFilePath = Path.Combine(Path.GetDirectoryName(oldPath), Guid.NewGuid().ToString() + Path.GetExtension(oldPath)); file.MoveTo(tempFilePath); } if (File.Exists(newPath)) { var fi = new FileInfo(newPath); return $"A file named '{fi.Name}' ({fi.Length} bytes) already exists. Please remove or rename the existing file and try again."; } if (file.Exists) { var oldBaseName = fileItem.BaseName; file.MoveTo(newPath); fileItem.BaseName = newName; fileItem.WriteTime = file.LastWriteTime; var e = new ItemPropertyChangedEventArgs(nameof(fileItem.BaseName), oldBaseName, newName); ItemRenamed?.Invoke(fileItem, e); } } catch (Exception) { throw; } finally { SetFileMonitoring(true); } return null; } } /// /// Deletes the file associated with a settings item and removes the item from the internal collection, ensuring data integrity and freeing up resources. /// /// The settings item file object to be deleted. /// A message indicating the result of the delete operation, or null if successful. public string DeleteItem(ISettingsFileItem fileItem) { lock (saveReadFileLock) { var oldName = RemoveInvalidFileNameChars(fileItem.BaseName); var oldPath = GetFileItemFullName(fileItem, oldName); var fi = new FileInfo(oldPath); // Rename folder if folder with the same name exists. var folderPath = Path.Combine(fi.Directory.FullName, oldName); try { if (Directory.Exists(folderPath)) Directory.Delete(folderPath, true); } catch (Exception ex) { return ex.Message; } try { if (fi.Exists) fi.Delete(); } catch (Exception ex) { return ex.Message; } Items.Remove((T)fileItem); return null; } } #endregion /// /// Indicates whether the items collection should be cleared when loading new data. /// [XmlIgnore, JsonIgnore] public bool ClearWhenLoading = false; void LoadAndValidateData(IList data) { if (data is null) data = new SortableBindingList(); // Filter data if filter method exists. var fl = ValidateData; var items = (fl is null) ? data : fl(data); // Filter data if filter method exists. var e = new SettingsDataEventArgs(items); OnValidateData?.Invoke(this, e); if (ClearWhenLoading) { // Clear original data. Items.Clear(); for (int i = 0; i < items.Count; i++) Items.Add(items[i]); } else if (!(e?.Handled == true)) { var oldList = GetHashValues(Items); var newList = GetHashValues(data).ToArray(); var newData = new List(); // Step 1: Update new list with the old items if they are exactly the same. for (int i = 0; i < newList.Length; i++) { var newItem = newList[i]; // Find same item from the old list. var oldItem = oldList.FirstOrDefault(x => x.Value.SequenceEqual(newItem.Value)); // If same item found then use it... if (oldItem.Key != null) { newData.Add(oldItem.Key); oldList.Remove(oldItem.Key); } else { newData.Add(newItem.Key); } } CollectionsHelper.Synchronize(newData, Items); } } #region Synchronize /// /// Synchronizes the content of the source collection with the target collection. /// /// The source collection to sync from. /// The target collection to sync to. /// /// Same Code: /// JocysCom\Controls\SearchHelper.cs /// public static void Synchronize(IList source, IList target) { // Convert to array to avoid modification of collection during processing. var sList = source.ToArray(); var t = 0; for (var s = 0; s < sList.Length; s++) { var item = sList[s]; // If item exists in destination and is in the correct position then continue if (t < target.Count && target[t].Equals(item)) { t++; continue; } // If item is in destination but not at the correct position, remove it. var indexInDestination = target.IndexOf(item); if (indexInDestination != -1) target.RemoveAt(indexInDestination); // Insert item at the correct position. target.Insert(s, item); t = s + 1; } // Remove extra items. while (target.Count > sList.Length) target.RemoveAt(target.Count - 1); } /// /// Return list of items their SHA256 hash. /// Dictionary GetHashValues(IList items) { var list = new Dictionary(); var algorithm = System.Security.Cryptography.SHA256.Create(); foreach (var item in items) { var bytes = Serialize(item); var byteHash = algorithm.ComputeHash(bytes); list.Add(item, byteHash); } return list; } #endregion /// /// Resets settings to default values defined within the resource files of the specified assemblies. /// /// True if default settings were successfully loaded; otherwise, False. public bool ResetToDefault() { // Clear original data. Items.Clear(); SettingsData data = null; var success = false; for (int a = 0; a < _Assemblies.Count; a++) { var assembly = _Assemblies[a]; var names = assembly.GetManifestResourceNames(); // Get compressed resource name. var name = names.FirstOrDefault(x => x.EndsWith(_XmlFile.Name + ".gz", StringComparison.OrdinalIgnoreCase)); if (string.IsNullOrEmpty(name)) { // Get uncompressed resource name. name = names.FirstOrDefault(x => x.EndsWith(_XmlFile.Name, StringComparison.OrdinalIgnoreCase)); } // If internal preset was found. if (!string.IsNullOrEmpty(name)) { var resource = assembly.GetManifestResourceStream(name); var sr = new StreamReader(resource); byte[] bytes; using (var memstream = new MemoryStream()) { sr.BaseStream.CopyTo(memstream); bytes = memstream.ToArray(); } sr.Dispose(); data = DeserializeData(bytes, name.EndsWith(".gz", StringComparison.OrdinalIgnoreCase)); success = true; break; } } LoadAndValidateData(data is null ? null : data.Items); return success; } #region Serialization byte[] Serialize(object fileItem) { return Serializer.SerializeToXmlBytes(fileItem, Encoding.UTF8, true, _Comment); } /// /// Deserializes settings data from a byte array, potentially decompressing it first if indicated. /// /// The byte array containing serialized settings data. /// Indicates whether the byte array is compressed and requires decompression. /// A instance deserialized from the byte array. public SettingsData DeserializeData(byte[] bytes, bool compressed) { if (compressed) bytes = SettingsHelper.Decompress(bytes); var data = Serializer.DeserializeFromXmlBytes>(bytes); return data; } /// /// Deserializes a single settings item from a byte array, optionally decompressing it. This method facilitates the reconstruction of individual settings from file storage. /// /// The byte array containing the serialized settings item. /// Indicates whether the byte array is compressed. /// The deserialized settings item. public T DeserializeItem(byte[] bytes, bool compressed) { if (compressed) bytes = SettingsHelper.Decompress(bytes); var item = Serializer.DeserializeFromXmlBytes(bytes); return item; } #endregion #region Folder Monitoring /// /// Enables or disables monitoring on the settings file directory. When enabled, changes to the files are detected, allowing for the application to respond accordingly. /// /// true to enable monitoring; false to disable it. public void SetFileMonitoring(bool enabled) { // Allow to monitor if items are in separate files. if (!UseSeparateFiles) return; var fi = new FileInfo(XmlFile.FullName); var di = GetRootDirectory(fi); SetFileMonitoring(enabled, di.FullName, "*.xml"); } private FileSystemWatcher _folderWatcher; /// /// Raises an event when files in the monitored settings directory change, ensuring settings are reloaded or updated accordingly. /// public event EventHandler FilesChanged; [DefaultValue(false)] public bool IsFolderMonitored { get; set; } /// /// Enables or disables file monitoring for changes to settings files. When enabled, changes to the settings file on disk will trigger a reload of settings to reflect the new state. /// /// Indicates whether file monitoring should be enabled (true) or disabled (false). /// The path to the folder to monitor. /// The pattern of the file names to monitor within the folder. /// /// Monitoring settings files is crucial in scenarios where settings might be changed externally or by different instances, ensuring the application operates with the most up-to-date configuration. /// public void SetFileMonitoring(bool enabled, string folderPath, string filePattern) { IsFolderMonitored = enabled; if (enabled) { if (_folderWatcher != null) { _folderWatcher.EnableRaisingEvents = false; _folderWatcher.Dispose(); } _folderWatcher = new FileSystemWatcher(folderPath, filePattern) { NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName, IncludeSubdirectories = true, }; _folderWatcher.Changed += OnChanged; _folderWatcher.Created += OnCreated; _folderWatcher.Deleted += OnDeleted; _folderWatcher.Renamed += OnRenamed; _folderWatcher.EnableRaisingEvents = true; } else { if (_folderWatcher != null) { _folderWatcher.EnableRaisingEvents = false; _folderWatcher.Dispose(); _folderWatcher = null; } } } private void OnChanged(object sender, FileSystemEventArgs e) => _ = Helper.Debounce(FilesChangedDebounced); private void OnCreated(object sender, FileSystemEventArgs e) => _ = Helper.Debounce(FilesChangedDebounced); private void OnDeleted(object sender, FileSystemEventArgs e) => _ = Helper.Debounce(FilesChangedDebounced); private void OnRenamed(object sender, RenamedEventArgs e) => _ = Helper.Debounce(FilesChangedDebounced); private void FilesChangedDebounced() => FilesChanged?.Invoke(this, EventArgs.Empty); #endregion } } ================================================ FILE: FocusLogger/JocysCom/Configuration/SettingsHelper.cs ================================================ using System; using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; using System.Text; namespace JocysCom.ClassLibrary.Configuration { /// /// Provides utilities for managing application settings, including file comparisons, /// data compression/decompression, and content transformation. /// public static partial class SettingsHelper { #region Compression /// /// Compresses the given byte array using GZip compression. /// /// The byte array to compress. /// The compressed byte array. public static byte[] Compress(byte[] bytes) { int numRead; var srcStream = new MemoryStream(bytes); var dstStream = new MemoryStream(); srcStream.Position = 0; var stream = new GZipStream(dstStream, CompressionMode.Compress); byte[] buffer = new byte[0x1000]; while (true) { numRead = srcStream.Read(buffer, 0, buffer.Length); if (numRead == 0) break; stream.Write(buffer, 0, numRead); } stream.Close(); srcStream.Close(); return dstStream.ToArray(); } /// /// Decompresses a previously compressed byte array using GZip. /// /// The compressed byte array to decompress. /// The original byte array. public static byte[] Decompress(byte[] bytes) { int numRead; var srcStream = new MemoryStream(bytes); var dstStream = new MemoryStream(); srcStream.Position = 0; var stream = new GZipStream(srcStream, CompressionMode.Decompress); var buffer = new byte[0x1000]; while (true) { numRead = stream.Read(buffer, 0, buffer.Length); if (numRead == 0) break; dstStream.Write(buffer, 0, numRead); } dstStream.Close(); stream.Close(); return dstStream.ToArray(); } #endregion #region Writing /// /// Returns true if the file at the specified path differs from the provided byte array (by existence, size, or SHA-256 checksum); otherwise false. /// /// The path to the file to compare. /// The byte array to compare against the file's contents. /// True if the file is considered different; otherwise, false. public static bool IsDifferent(string path, byte[] bytes) { if (bytes is null) throw new ArgumentNullException(nameof(bytes)); var fileInfo = new FileInfo(path); // If the file does not exist or the size is different, then it is considered different. if (!fileInfo.Exists || fileInfo.Length != bytes.Length) return true; // Compare checksums. using (var algorithm = System.Security.Cryptography.SHA256.Create()) { var byteHash = algorithm.ComputeHash(bytes); var fileBytes = File.ReadAllBytes(fileInfo.FullName); var fileHash = algorithm.ComputeHash(fileBytes); var isDifferent = !byteHash.SequenceEqual(fileHash); return isDifferent; } } /// /// Writes the byte array to the specified path only if its content differs (comparing size and SHA-256 checksum). /// To avoid truncating the file on disk-full or other IO errors, writes to a temporary file first and then renames it. /// /// The path where the file will be written. /// The byte array to write. /// True if the file was written; false if the contents were the same and no write occurred. public static bool WriteIfDifferent(string path, byte[] bytes) { if (!IsDifferent(path, bytes)) return false; if (IsEnoughSpaceAvailable(path, bytes.Length)) { File.WriteAllBytes(path, bytes); return true; } // Generate a temporary filename in the same directory for an atomic-like replacement. // Writing to the same directory helps avoid cross-volume moves. var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Directory not found."); var tempFileName = Path.Combine(directory, Path.GetRandomFileName()); try { // Write all bytes to the temporary file first. File.WriteAllBytes(tempFileName, bytes); // If we have .NET 6 or later, we could do: File.Move(tempFileName, path, overwrite: true); // Otherwise, we can delete the existing file (if any) and then rename the temp file. if (File.Exists(path)) File.Delete(path); // Rename the temp file to the final path (nearly atomic on Windows). File.Move(tempFileName, path); return true; } catch { // Clean up the temp file if something goes wrong. if (File.Exists(tempFileName)) File.Delete(tempFileName); // Rethrow the exception or handle it as needed. throw; } } /// /// Reads the content of a file into a string using the detected encoding of the file. /// /// The path to the file to read. /// The encoding used to read the file. Detected automatically if left null. /// The content of the file as a string. public static string ReadFileContent(string name, out Encoding encoding) { using (var reader = new System.IO.StreamReader(name, true)) { encoding = reader.CurrentEncoding; return reader.ReadToEnd(); } } /// /// Converts a string content into a byte array with an optional encoding header. /// /// The string content to convert. /// The encoding to use for the byte array. If null, the default encoding is used. /// The byte array representation of the content, including the encoding header if specified. public static byte[] GetFileContentBytes(string content, Encoding encoding = null) { var ms = new MemoryStream(); // Encoding header will be added to content. var sw = new StreamWriter(ms, encoding); sw.Write(content); sw.Flush(); var bytes = ms.ToArray(); sw.Dispose(); return bytes; } /// /// Determines if the drive containing the specified path has enough free space to accommodate required bytes plus a buffer (10 MB or 5% of required size). /// /// The file path whose drive to check. /// The number of bytes intended to be written. /// True if available free space exceeds requiredBytes plus buffer; otherwise, false. public static bool IsEnoughSpaceAvailable(string path, long requiredBytes) { // Convert a relative path to an absolute path var fullPath = Path.GetFullPath(path); // Extract the drive from the full path var driveRoot = Path.GetPathRoot(fullPath); if (string.IsNullOrEmpty(driveRoot)) return false; var drive = new DriveInfo(driveRoot); // A rule of thumb buffer: 10 MB or 5% of file size, whichever is greater. long buffer = Math.Max(10 * 1024 * 1024, (long)(requiredBytes * 0.05)); long totalNeeded = requiredBytes + buffer; return drive.AvailableFreeSpace > totalNeeded; } #endregion #region Saving /// /// Saves the byte array to a file and appends a CRC32 (Cyclic Redundancy Check) checksum to the filename for integrity verification. /// Ensures file contents are not tampered with and remain consistent across operations. /// /// The name of the file to save, without the checksum. /// The byte array containing the data to be saved to the file. /// FileInfo object representing the saved file, including its checksum in the filename. public static FileInfo SaveFileWithChecksum(string name, byte[] bytes) { var assembly = Assembly.GetEntryAssembly(); var company = ((AssemblyCompanyAttribute)Attribute.GetCustomAttribute(assembly, typeof(AssemblyCompanyAttribute))).Company; var product = ((AssemblyProductAttribute)Attribute.GetCustomAttribute(assembly, typeof(AssemblyProductAttribute))).Product; // Get writable application folder. var specialFolder = Environment.SpecialFolder.CommonApplicationData; var folder = string.Format("{0}\\{1}\\{2}", Environment.GetFolderPath(specialFolder), company, product); var hash = ComputeCRC32Checksum(bytes); // Put file into sub folder because file name must match with LoadLibrary() argument. var chName = string.Format("{0}.{1:X8}\\{0}", name, hash); var fileName = System.IO.Path.Combine(folder, "Temp", chName); var fi = new FileInfo(fileName); if (fi.Exists) return fi; if (!fi.Directory.Exists) fi.Directory.Create(); File.WriteAllBytes(fileName, bytes); fi.Refresh(); return fi; } /// /// Calculates the 32-bit Cyclic Redundancy Check (CRC32) checksum for the given byte array. /// /// The byte array to calculate the checksum for. /// The calculated CRC32 checksum as an unsigned 32-bit integer. public static uint ComputeCRC32Checksum(byte[] bytes) { uint poly = 0xedb88320; uint[] table = new uint[256]; uint temp; for (uint i = 0; i < table.Length; ++i) { temp = i; for (int j = 8; j > 0; --j) temp = (temp & 1) == 1 ? (temp >> 1) ^ poly : temp >> 1; table[i] = temp; } uint crc = 0xffffffff; for (int i = 0; i < bytes.Length; ++i) crc = (crc >> 8) ^ table[(byte)(((crc) & 0xff) ^ bytes[i])]; return ~crc; } #endregion } } ================================================ FILE: FocusLogger/JocysCom/Configuration/SettingsItem.cs ================================================ using JocysCom.ClassLibrary.ComponentModel; using System.ComponentModel; namespace JocysCom.ClassLibrary.Configuration { /// Base configuration entity implementing ISettingsItem with change notification support. /// /// Exposes enabled state and emptiness checks. /// Extended by SettingsFileItem for file-based entries. /// Edited via a WinForms SettingsItemForm. /// public class SettingsItem : NotifyPropertyChanged, ISettingsItem { /// [DefaultValue(true)] public bool IsEnabled { get => _IsEnabled; set => SetProperty(ref _IsEnabled, value); } bool _IsEnabled = true; /// public virtual bool IsEmpty => true; } } ================================================ FILE: FocusLogger/JocysCom/Configuration/SettingsParser.cs ================================================ using System; using System.Linq; #if NETFRAMEWORK // .NET Framework... using System.Configuration; #endif #if __MOBILE__ using Xamarin.Forms; #endif namespace JocysCom.ClassLibrary.Configuration { /// /// Parse application setting values. /// Parses app settings across .NET Framework, .NET Standard, .NET Core, and Xamarin; converts raw strings to target types. /// public partial class SettingsParser { public SettingsParser(string configPrefix = "") { ConfigPrefix = configPrefix; } public string ConfigPrefix { get; set; } public static SettingsParser Current { get; } = new SettingsParser(); /// /// Parse all IConvertible types, like value types, with one function. /// Retrieves the setting identified by (prefixed with ) /// and converts it to . Falls back to if missing or conversion fails. /// /// Target type for conversion. /// Setting key without prefix. /// Value returned if missing or conversion fails. /// Converted value or . public T Parse(string name, T defaultValue = default(T)) { if (_GetValue is null) return defaultValue; var v = _GetValue(ConfigPrefix + name); return ParseValue(v, defaultValue); } /// Converts a string to the specified type. /// /// Supports System.Drawing.Color (FromName), static Parse(string), enums (case-insensitive), and IConvertible via Convert.ChangeType. /// Returns if is null or no converter is found. /// /// Target type; must not be null. /// Input string value. /// Fallback value. /// Converted object or . /// Thrown when is null. public static object ParseValue(Type t, string v, object defaultValue = null) { if (t is null) throw new ArgumentNullException(nameof(t)); if (v is null) return defaultValue; if (typeof(System.Drawing.Color).IsAssignableFrom(t)) return System.Drawing.Color.FromName(v); // Get Parse method with string parameter. var m = t.GetMethod("Parse", new[] { typeof(string) }); if (m != null) return m.Invoke(null, new[] { v }); //if (typeof(IPAddress).IsAssignableFrom(t)) // return IPAddress.Parse(v); //if (typeof(TimeSpan).IsAssignableFrom(t)) // return TimeSpan.Parse(v, CultureInfo.InvariantCulture); if (t.IsEnum) return Enum.Parse(t, v, true); // If type can be converted then convert. if (typeof(IConvertible).IsAssignableFrom(t)) return System.Convert.ChangeType(v, t); return defaultValue; } /// Attempts to convert the string to , returning on error. /// Target type to parse. /// Input string. /// Fallback value. /// Parsed value or . public static T TryParseValue(string v, T defaultValue = default(T)) { try { return (T)ParseValue(typeof(T), v, defaultValue); } catch (Exception) { return defaultValue; } } public static T ParseValue(string v, T defaultValue = default(T)) { return (T)ParseValue(typeof(T), v, defaultValue); } #if NETFRAMEWORK // .NET Framework... /// Delegate to fetch configuration values by key; defaults to ConfigurationManager.AppSettings in .NET Framework. public static Func _GetValue = (name) => ConfigurationManager.AppSettings[name]; #else // .NET (Core/5+) /// Delegate to fetch configuration values by key; initialize via InitializeParser in .NET Core. public static Func _GetValue; #endif } } ================================================ FILE: FocusLogger/JocysCom/Controls/ControlsHelper.WPF.UseWindowsForms.cs ================================================ using System.Windows; using System.Linq; using System; using System.Windows.Media; using System.IO; using System.Data; namespace JocysCom.ClassLibrary.Controls { public partial class ControlsHelper { /// /// Set form TopMost if one of the application forms is top most. /// /// public static void CheckTopMost(Window win) { // If this form is not set as TopMost but one of the application forms is on TopMost then... // Make this dialog form TopMost too or user won't be able to access it. if (!win.Topmost && System.Windows.Forms.Application.OpenForms.Cast().Any(x => x.TopMost)) win.Topmost = true; } public static void AutoSizeByOpenForms(Window win, int addSize = -64) { var form = System.Windows.Forms.Application.OpenForms.Cast().FirstOrDefault(); if (form == null) return; win.Width = form.Width + addSize; win.Height = form.Height + addSize; win.Top = form.Top - addSize / 2; win.Left = form.Left - addSize / 2; } /// /// Convert Bitmap to image source. /// /// /// Requires NuGet Package on .NET Core: System.Drawing.Common or... /// set property true inside the project. /// public static ImageSource GetImageSource(System.Drawing.Bitmap bitmap) { var bi = new System.Windows.Media.Imaging.BitmapImage(); var ms = new MemoryStream(); bitmap.Save(ms, System.Drawing.Imaging.ImageFormat.Png); bi.BeginInit(); bi.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad; bi.StreamSource = ms; bi.EndInit(); ms.Dispose(); return bi; } #region Center Window /// /// Center window on Owner window. /// /// public static void CenterWindowOnApplication(Window window) { // Get WFF window first. var win = window.Owner; System.Drawing.Rectangle? r = null; var isNormal = false; if (win != null) { r = new System.Drawing.Rectangle((int)win.Left, (int)win.Top, (int)win.Width, (int)win.Height); isNormal = win.WindowState == WindowState.Normal; } else { // Try to get top windows form. var form = System.Windows.Forms.Application.OpenForms.Cast().FirstOrDefault(); if (form != null) { double l; double t; double w; double h; TransformToUnits(form.Left, form.Top, out l, out t); TransformToUnits(form.Width, form.Height, out w, out h); r = new System.Drawing.Rectangle((int)l, (int)t, (int)w, (int)h); isNormal = form.WindowState == System.Windows.Forms.FormWindowState.Normal; } } if (r.HasValue) { if (isNormal) { window.Left = r.Value.X + ((r.Value.Width - window.ActualWidth) / 2); window.Top = r.Value.Y + ((r.Value.Height - window.ActualHeight) / 2); } else { // Get the form screen. var screen = System.Windows.Forms.Screen.FromRectangle(r.Value); double screenWidth = screen.WorkingArea.Width; double screenHeight = screen.WorkingArea.Height; window.Left = (screenWidth / 2) - (window.Width / 2); window.Top = (screenHeight / 2) - (window.Height / 2); } } } /// /// Transforms device independent units (1/96 of an inch) to pixels. /// private static void TransformToPixels(double unitX, double unitY, out int pixelX, out int pixelY) { using (var g = System.Drawing.Graphics.FromHwnd(IntPtr.Zero)) { pixelX = (int)((g.DpiX / 96) * unitX); pixelY = (int)((g.DpiY / 96) * unitY); } } /// /// Transforms device pixels to independent units (1/96 of an inch). /// private static void TransformToUnits(int pixelX, int pixelY, out double unitX, out double unitY) { using (var g = System.Drawing.Graphics.FromHwnd(IntPtr.Zero)) { unitX = (double)pixelX / (g.DpiX / 96); unitY = (double)pixelY / (g.DpiX / 96); } } public static bool GetMainFormTopMost() { var win = System.Windows.Application.Current?.MainWindow; if (win != null) return win.Topmost; var form = System.Windows.Forms.Application.OpenForms.Cast().FirstOrDefault(); if (form != null) return form.TopMost; return false; } #endregion } } ================================================ FILE: FocusLogger/JocysCom/Controls/ControlsHelper.WPF.cs ================================================ using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Linq; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Documents; using System.Windows.Input; using System.Windows.Media; using System.Xml; namespace JocysCom.ClassLibrary.Controls { public partial class ControlsHelper { public static void EnableWithDelay(UIElement control) { Task.Run(async delegate { // Logical delay without blocking the current hardware thread. await Task.Delay(500).ConfigureAwait(true); control.Dispatcher.Invoke(() => control.IsEnabled = true); }); } private static bool? _IsDesignModeWPF; public static bool IsDesignMode(UIElement component) { if (!_IsDesignModeWPF.HasValue) _IsDesignModeWPF = IsDesignMode1(component); return _IsDesignModeWPF.Value; } private static bool IsDesignMode1(UIElement component) { // Check 1. if (DesignerProperties.GetIsInDesignMode(component)) return true; //If WPF hosted in WinForms. var ea = System.Reflection.Assembly.GetEntryAssembly(); if (ea != null && ea.Location.Contains("VisualStudio")) return true; //If WPF hosted in WinForms. ea = System.Reflection.Assembly.GetExecutingAssembly(); if (ea != null && ea.Location.Contains("VisualStudio")) return true; return false; } public static T Clone(T o) { var sb = new System.Text.StringBuilder(); var writer = XmlWriter.Create(sb, new XmlWriterSettings { Indent = true, ConformanceLevel = ConformanceLevel.Fragment, OmitXmlDeclaration = true, NamespaceHandling = NamespaceHandling.OmitDuplicates, // XmlReader normalizes all newlines and converts '\r\n' to '\n'. // This requires to save NewLines with option which // "Entitize" option replace '\r' with ' ' in text node values. NewLineHandling = NewLineHandling.Entitize, }); var manager = new System.Windows.Markup.XamlDesignerSerializationManager(writer); manager.XamlWriterMode = System.Windows.Markup.XamlWriterMode.Expression; System.Windows.Markup.XamlWriter.Save(o, manager); var stringReader = new StringReader(sb.ToString()); var xmlReader = XmlReader.Create(stringReader); var item = System.Windows.Markup.XamlReader.Load(xmlReader); if (item is null) throw new ArgumentNullException("Could not be cloned."); return (T)item; } /// /// Change value if it is different only. /// This helps not to trigger control events when doing frequent events. /// public static void SetText(Label control, string format, params object[] args) { if (control is null) throw new ArgumentNullException(nameof(control)); var text = args?.Count() > 0 ? string.Format(format ?? "", args) : format; if (control.Content as string != text) control.Content = text; } public static void SetText(PasswordBox control, string format, params object[] args) { if (control is null) throw new ArgumentNullException(nameof(control)); var text = args?.Count() > 0 ? string.Format(format ?? "", args) : format; if (control.Password != text) control.Password = text; } /// /// Change value if it is different only. /// This helps not to trigger control events when doing frequent events. /// public static void SetText(GroupBox control, string format, params object[] args) { if (control is null) throw new ArgumentNullException(nameof(control)); var text = args?.Count() > 0 ? string.Format(format ?? "", args) : format; if (control.Header as string != text) control.Header = text; } /// /// Change value if it is different only. /// This helps not to trigger control events when doing frequent events. /// public static void SetText(TextBox control, string format, params object[] args) { if (control is null) throw new ArgumentNullException(nameof(control)); var text = args?.Count() > 0 ? string.Format(format ?? "", args) : format; if (control.Text != text) control.Text = text; } /// /// Change value if it is different only. /// This helps not to trigger control events when doing frequent events. /// public static void SetText(TextBlock control, string format, params object[] args) { if (control is null) throw new ArgumentNullException(nameof(control)); var text = args?.Count() > 0 ? string.Format(format ?? "", args) : format; if (control.Text != text) control.Text = text; } public static void SetTextFromResource(RichTextBox box, byte[] rtf) { var ms = new MemoryStream(rtf); var textRange = new TextRange(box.Document.ContentStart, box.Document.ContentEnd); textRange.Load(ms, DataFormats.Rtf); ms.Dispose(); box.Document.PagePadding = new Thickness(8); box.IsDocumentEnabled = true; HookHyperlinks(box, null); } /// /// Change value if it is different only. /// This helps not to trigger control events when doing frequent events. /// public static void SetChecked(System.Windows.Controls.Primitives.ToggleButton control, bool check) { if (control is null) throw new ArgumentNullException(nameof(control)); if (control.IsChecked != check) control.IsChecked = check; } /// /// Change value if it is different only. /// This helps not to trigger control events when doing frequent events. /// public static void SetEnabled(UIElement control, bool enabled) { if (control is null) throw new ArgumentNullException(nameof(control)); if (control.IsEnabled != enabled) control.IsEnabled = enabled; } /// /// Change value if it is different only. /// This helps not to trigger control events when doing frequent events. /// public static void SetVisible(UIElement control, bool enabled) { if (control is null) throw new ArgumentNullException(nameof(control)); var visibility = enabled ? Visibility.Visible : Visibility.Collapsed; if (control.Visibility != visibility) control.Visibility = visibility; } public static void SetItemsSource(DataGridComboBoxColumn grid, IBindingList list) { if (list is null) { if (grid.ItemsSource is System.Windows.Data.BindingListCollectionView view) { grid.ItemsSource = null; view.DetachFromSourceCollection(); } return; } var currentView = (System.Windows.Data.BindingListCollectionView)grid.ItemsSource; // If same list then... if (currentView?.SourceCollection == list) return; var newView = new System.Windows.Data.BindingListCollectionView(list); grid.ItemsSource = newView; } public static void SetItemsSource(ItemsControl grid, IBindingList list) { if (list is null) { if (grid.ItemsSource is System.Windows.Data.BindingListCollectionView view) { grid.ItemsSource = null; view.DetachFromSourceCollection(); } return; } var currentView = (System.Windows.Data.BindingListCollectionView)grid.ItemsSource; // If same list then... if (currentView?.SourceCollection == list) return; var newView = new System.Windows.Data.BindingListCollectionView(list); // Clear Items to avoid exception: "Items collection must be empty before using ItemsSource" grid.Items.Clear(); grid.ItemsSource = newView; } private static void HookHyperlinks(object sender, TextChangedEventArgs e) { var doc = (sender as RichTextBox).Document; for (var position = doc.ContentStart; position != null && position.CompareTo(doc.ContentEnd) <= 0; position = position.GetNextContextPosition(LogicalDirection.Forward)) { if (position.GetPointerContext(LogicalDirection.Forward) == TextPointerContext.ElementEnd) { if (position.Parent is Hyperlink link) link.RequestNavigate += link_RequestNavigate; else if (position.Parent is Span span) { var range = new TextRange(span.ContentStart, span.ContentEnd); if (Uri.TryCreate(range.Text, UriKind.Absolute, out var uriResult)) { if (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps) { var h = new Hyperlink(range.Start, range.End); h.RequestNavigate += link_RequestNavigate; h.NavigateUri = new Uri(range.Text); h.Cursor = System.Windows.Input.Cursors.Hand; } } } } } } private static void link_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e) { var link = (Hyperlink)sender; OpenUrl(link.NavigateUri.AbsoluteUri); e.Handled = true; } #region IsVisibleToUser public static Point[] GetPoints(Control control, bool relative = false) { if (control is null) throw new ArgumentNullException(nameof(control)); var pos = relative ? new Point(0, 0) // Get control position on the screen : control.PointToScreen(new Point(0, 0)); var pointsToCheck = new Point[] { // Top-Left. pos, // Top-Right. new Point(pos.X + control.ActualWidth - 1, pos.Y), // Bottom-Left. new Point(pos.X, pos.Y + control.ActualHeight - 1), // Bottom-Right. new Point(pos.X + control.ActualWidth - 1, pos.Y + control.ActualHeight - 1), // Middle-Centre. new Point(pos.X + control.ActualWidth/2, pos.Y + control.ActualHeight/2) }; return pointsToCheck; } /* public static bool IsControlVisibleToUser(Control control) { if (control is null) throw new ArgumentNullException(nameof(control)); var handle = (PresentationSource.FromVisual(control) as System.Windows.Interop.HwndSource)?.Handle; if (!handle.HasValue) return false; var children = GetAll(control, true); // Return true if any of the controls is visible. var pointsToCheck = GetPoints(control, true); foreach (var p in pointsToCheck) { //var hwnd = NativeMethods.WindowFromPoint(p); //if (hwnd == IntPtr.Zero) // continue; var result = VisualTreeHelper.HitTest(control, p); if (result is null) continue; if (children.Contains(result.VisualHit)) return true; //var other = Control.FromChildHandle(hwnd); //if (other is null) // continue; //if (GetAll(control, null, true).Contains(other)) } return false; } */ /// /// Get parent control of specific type. /// public static T GetParent(DependencyObject control, bool includeTop = false) where T : class { if (control is null) throw new ArgumentNullException(nameof(control)); var parent = control; while (parent != null) { if (parent is T && (includeTop || parent != control)) return (T)(object)parent; var p = VisualTreeHelper.GetParent(parent); if (p is null) p = LogicalTreeHelper.GetParent(parent); parent = p; } return null; } public static void AddWeakHandlerOnWindowClosing(DependencyObject control, EventHandler handler) { var w = GetParent(control); if (w is null) return; WeakEventManager.AddHandler(w, nameof(Window.Closing), handler); } public static void RemoveFromParent(FrameworkElement element) { if (element == null) return; var lParent = LogicalTreeHelper.GetParent(element); var vParent = VisualTreeHelper.GetParent(element); if (vParent is ItemsControl items) items.Items.Remove(element); if (vParent is StackPanel panel) panel.Children.Remove(element); if (vParent is Panel grid) grid.Children.Remove(element); if (vParent is ContentPresenter window) window.Content = null; if (vParent is Decorator border) border.Child = null; // Remove visual and logical children. var flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic; if (vParent is FrameworkElement) { var methodInfo = vParent.GetType().GetMethod("RemoveVisualChild", flags); methodInfo.Invoke(vParent, new object[] { element }); } if (lParent is FrameworkElement) { var methodInfo = lParent.GetType().GetMethod("RemoveLogicalChild", flags); methodInfo.Invoke(lParent, new object[] { element }); } } /// /// Get all child controls with path. /// Use regex to make shorter tabbed path: /// var rx = new Regex("[^.]+[.]+"); /// var tabbedPath = rx.Replace(item.Path, "\t"); /// public static Dictionary GetAll(string path, DependencyObject control, Type type = null, bool includeTop = false) { var controls = _GetAll(path, control, includeTop); // If type is set then... if (type is null) return controls; var filtered = type.IsInterface ? controls.Where(x => x.Value.GetType().GetInterfaces().Contains(type)) : controls.Where(x => type.IsAssignableFrom(x.Value.GetType())); var results = filtered.ToDictionary(x => x.Key, y => y.Value); return results; } private static Dictionary _GetAll(string path, DependencyObject control, bool includeTop = false) { if (control is null) throw new ArgumentNullException(nameof(control)); // Create new list. var controls = new Dictionary(); if (string.IsNullOrEmpty(path)) path = $"{control.GetType().Name} {(control as FrameworkElement)?.Name}".TrimEnd(); // Add top control if required. if (includeTop && !controls.Values.Contains(control)) { controls.Add(path, control); } // If control is Visual then then... if (control is Visual || control is System.Windows.Media.Media3D.Visual3D) { var childrenCount = VisualTreeHelper.GetChildrenCount(control); for (int i = 0; i < childrenCount; i++) { var child = VisualTreeHelper.GetChild(control, i); var childKey = $"{path}[{i}].{child.GetType().Name} {(child as FrameworkElement)?.Name}".TrimEnd(); //controls.Add(childKey, child); // Get children of children. var pairs = _GetAll(childKey, child, true); foreach (var pair in pairs) { if (!controls.ContainsValue(pair.Value)) controls.Add(pair.Key, pair.Value); } } } // If contorl is FrameworkElement then... if (control is FrameworkElement || control is FrameworkContentElement) { var logicalChildren = LogicalTreeHelper.GetChildren(control).OfType().ToList(); for (int i = 0; i < logicalChildren.Count; i++) { var child = logicalChildren[i]; var childKey = $"{path}[{i}].{child.GetType().Name} {(child as FrameworkElement)?.Name}".TrimEnd(); //controls.Add(childKey, child); // Get children of children. var pairs = _GetAll(childKey, child, true); foreach (var pair in pairs) { if (!controls.ContainsValue(pair.Value)) controls.Add(pair.Key, pair.Value); } } } return controls; } /// /// Get all child controls. /// public static IEnumerable GetAll(DependencyObject control, Type type = null, bool includeTop = false) { return GetAll(null, control, type, includeTop).Values.ToList(); } /// /// Get all child controls. /// public static T[] GetAll(FrameworkElement control, bool includeTop = false) { if (control is null) return new T[0]; return GetAll(control, typeof(T), includeTop).Cast().ToArray(); } public static void GetActiveControl(FrameworkElement control, out FrameworkElement activeControl, out string activePath) { string _activePath = null; Invoke(() => { _activePath = string.Format("/{0}", control?.Name); }); activePath = _activePath; // Return current control by default. activeControl = control; // If control can contains active controls. var container = control as DependencyObject; while (container != null) { Invoke(() => { control = System.Windows.Input.FocusManager.GetFocusedElement(control) as FrameworkElement; }); if (control is null) break; Invoke(() => { _activePath = string.Format("/{0}", control?.Name); }); activePath += _activePath; activeControl = control; container = control; } } #endregion #region Apply Grid Border Style public static void ApplyBorderStyle(DataGrid grid) { if (grid is null) throw new ArgumentNullException(nameof(grid)); grid.Background = new SolidColorBrush(Color.FromRgb(255, 255, 255)); //grid.BorderThickness = BorderStyle.None; //grid.EnableHeadersVisualStyles = false; //grid.ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single; //grid.ColumnHeadersDefaultCellStyle.BackColor = SystemColors.Control; //grid.ColumnHeadersDefaultCellStyle.WrapMode = DataGridViewTriState.False; //grid.RowHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single; //grid.RowHeadersDefaultCellStyle.BackColor = SystemColors.Control; //grid.BackColor = SystemColors.Window; //grid.DefaultCellStyle.BackColor = SystemColors.Window; //grid.CellPainting += Grid_CellPainting; //grid.SelectionChanged += Grid_SelectionChanged; //grid.CellFormatting += Grid_CellFormatting; //if (updateEnabledProperty) // grid.CellClick += Grid_CellClick; } /* private static void Grid_CellClick(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex < 0) return; var grid = (DataGridView)sender; // If add new record row. if (grid.AllowUserToAddRows && e.RowIndex + 1 == grid.Rows.Count) return; var column = grid.Columns[e.ColumnIndex]; var item = grid.Rows[e.RowIndex].DataBoundItem; if (column.DataPropertyName == "Enabled" || column.DataPropertyName == "IsEnabled") { SetEnabled(item, !GetEnabled(item)); grid.Invalidate(); } } private static void Grid_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex < 0) return; var grid = (DataGridView)sender; // If add new record row. if (grid.AllowUserToAddRows && e.RowIndex + 1 == grid.Rows.Count) return; var row = grid.Rows[e.RowIndex]; if (e.RowIndex > -1 && e.ColumnIndex > -1) { var item = row.DataBoundItem; // If grid is virtual then... if (item is null) { var list = grid.DataSource as IBindingList; if (list != null) item = list[e.RowIndex]; } var enabled = true; if (item != null) enabled = GetEnabled(item); var fore = enabled ? grid.DefaultCellStyle.ForeColor : SystemColors.ControlDark; var selectedBack = enabled ? grid.DefaultCellStyle.SelectionBackColor : SystemColors.ControlDark; // Apply style to row header. if (row.HeaderCell.Style.ForeColor != fore) row.HeaderCell.Style.ForeColor = fore; if (row.HeaderCell.Style.SelectionBackColor != selectedBack) row.HeaderCell.Style.SelectionBackColor = selectedBack; // Apply style to cell var cell = grid.Rows[e.RowIndex].Cells[e.ColumnIndex]; if (cell.Style.ForeColor != fore) cell.Style.ForeColor = fore; if (cell.Style.SelectionBackColor != selectedBack) cell.Style.SelectionBackColor = selectedBack; } } private static void Grid_SelectionChanged(object sender, EventArgs e) { // Sort issue with paint artifcats. var grid = (DataGridView)sender; grid.Invalidate(); } private static void SetEnabled(object item, bool enabled) { var enabledProperty = item.GetType().GetProperties().FirstOrDefault(x => x.Name == "Enabled" || x.Name == "IsEnabled"); if (enabledProperty != null) { enabledProperty.SetValue(item, enabled, null); } } private static bool GetEnabled(object item) { var enabledProperty = item.GetType().GetProperties().FirstOrDefault(x => x.Name == "Enabled" || x.Name == "IsEnabled"); var enabled = enabledProperty is null ? true : (bool)enabledProperty.GetValue(item, null); return enabled; } private static void Grid_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { // Header and cell borders must be set to "Single" style. var grid = (DataGridView)sender; var firstVisibleColumn = grid.Columns.Cast().Where(x => x.Displayed).Min(x => x.Index); var lastVisibleColumn = grid.Columns.Cast().Where(x => x.Displayed).Max(x => x.Index); var selected = e.RowIndex > -1 ? grid.Rows[e.RowIndex].Selected : false; e.Paint(e.CellBounds, DataGridViewPaintParts.All & ~DataGridViewPaintParts.Border); var bounds = e.CellBounds; var tl = new Point(bounds.X, bounds.Y); var tr = new Point(bounds.X + bounds.Width - 1, bounds.Y); var bl = new Point(bounds.X, bounds.Y + bounds.Height - 1); var br = new Point(bounds.X + bounds.Width - 1, bounds.Y + bounds.Height - 1); Color backColor; // If top left corner and column header then... if (e.RowIndex == -1) { backColor = selected ? grid.ColumnHeadersDefaultCellStyle.SelectionBackColor : grid.ColumnHeadersDefaultCellStyle.BackColor; } // If row header then... else if (e.ColumnIndex == -1 && e.RowIndex > -1) { var row = grid.Rows[e.RowIndex]; backColor = selected ? row.HeaderCell.Style.SelectionBackColor : grid.RowHeadersDefaultCellStyle.BackColor; } // If normal cell then... else { var row = grid.Rows[e.RowIndex]; var cell = row.Cells[e.ColumnIndex]; backColor = selected ? cell.InheritedStyle.SelectionBackColor : cell.InheritedStyle.BackColor; } // Cell background colour. var back = new Pen(backColor, 1); // Border colour. var border = new Pen(SystemColors.Control, 1); // Do not draw borders for selected device. Pen c; // Top e.Graphics.DrawLine(back, tl, tr); // Left (only if not first) c = !selected && e.ColumnIndex > firstVisibleColumn ? border : back; e.Graphics.DrawLine(c, bl, tl); // Right (always) c = back; e.Graphics.DrawLine(c, tr, br); // Bottom (always) c = border; e.Graphics.DrawLine(c, bl, br); back.Dispose(); border.Dispose(); e.Handled = true; } */ #endregion #region Data Grid Functions /// /// Get list of primary keys of items selected in the grid. /// /// Type of Primary key. /// Grid for getting selection /// Primary key name. public static List GetSelection(DataGrid grid, string keyPropertyName = null) { if (grid is null) throw new ArgumentNullException(nameof(grid)); var list = new List(); var items = grid.SelectedItems.Cast().ToArray(); // If nothing selected then try to get rows from cells. if (items.Length == 0) items = grid.SelectedCells.Cast().Select(x => x.Item).Distinct().ToArray(); // If nothing selected then return. if (items.Length == 0) return list; var pi = GetPropertyInfo(keyPropertyName, items[0]); for (var i = 0; i < items.Length; i++) { var value = GetValue(items[i], keyPropertyName, pi); list.Add(value); } return list; } [Obsolete("Use `bool SetSelection(DataGrid grid, string keyPropertyName, List list, int selectIndex = 0) instead.`")] public static void RestoreSelection(DataGrid grid, string keyPropertyName, List list, bool selectFirst = true) => SetSelection(grid, keyPropertyName, list, selectFirst ? 0 : -1); [Obsolete("Use `bool SetSelection(DataGrid grid, string keyPropertyName, List list, int selectIndex = 0)` instead.")] public static void RestoreSelection(DataGrid grid, string keyPropertyName, List list, int selectIndex = 0) => SetSelection(grid, keyPropertyName, list, selectIndex); public static bool SetSelection(DataGrid grid, string keyPropertyName, List list, int selectIndex = 0) { if (grid is null) throw new ArgumentNullException(nameof(grid)); var items = grid.Items.Cast().ToArray(); // Return if grid is empty. if (items.Length == 0) return false; // If something to restore then... if (list?.Count > 0) { var selectedItems = new List(); var pi = GetPropertyInfo(keyPropertyName, items[0]); for (var i = 0; i < items.Length; i++) { var item = items[i]; var val = GetValue(item, keyPropertyName, pi); if (list.Contains(val)) selectedItems.Add(item); } if (grid.SelectionMode == DataGridSelectionMode.Single) { grid.SelectedItem = selectedItems.FirstOrDefault(); } else { // Remove items which should not be selected. var itemsToUnselect = grid.SelectedItems.Cast().Except(selectedItems).ToArray(); foreach (var item in itemsToUnselect) grid.SelectedItems.Remove(item); var itemsToSelect = selectedItems.Except(grid.SelectedItems.Cast()); foreach (var item in itemsToSelect) grid.SelectedItems.Add(item); } } // If nothing was selected and must select index then... if (grid.SelectedItems.Count == 0 && selectIndex >= 0 && selectIndex < grid.Items.Count) grid.SelectedItem = items[selectIndex]; return grid.SelectedItems.Count > 0; } #endregion #region TextBoxBase - EnableAutoScroll public static VerticalAlignment GetScrollVerticalAlignment(ScrollViewer control) { // Vertical scroll position. var offset = control.VerticalOffset; // Vertical size of the scrollable content area. var height = control.ExtentHeight; // Vertical size of the visible content area. var visibleView = control.ViewportHeight; // Allow flexibility of 2 pixels. var flex = 2; if (height - offset - visibleView < flex) return VerticalAlignment.Bottom; if (offset < flex) return VerticalAlignment.Top; return VerticalAlignment.Center; } public static void AutoScroll(Control control) { ScrollViewer sv = null; if (!(control is ScrollViewer)) { var all = GetAll(control); // Try to get one with visible vertical bar first otherwise get default. sv = all .Where(x => x.ComputedVerticalScrollBarVisibility == Visibility.Visible) .FirstOrDefault() ?? all.FirstOrDefault(); } if (sv != null) { var scrollPosition = GetScrollVerticalAlignment(sv); if (scrollPosition == VerticalAlignment.Bottom && control.IsVisible) sv.ScrollToEnd(); } } public static void EnableAutoScroll(TextBoxBase control, bool enable = true) { control.TextChanged -= TextBoxBase_TextChanged; control.IsVisibleChanged -= TextBoxBase_IsVisibleChanged; control.Unloaded -= TextBoxBase_Unloaded; if (enable) { control.TextChanged += TextBoxBase_TextChanged; control.IsVisibleChanged += TextBoxBase_IsVisibleChanged; control.Unloaded += TextBoxBase_Unloaded; } } private static void TextBoxBase_Unloaded(object sender, RoutedEventArgs e) => EnableAutoScroll((TextBox)sender, false); private static void TextBoxBase_TextChanged(object sender, TextChangedEventArgs e) => AutoScroll((TextBox)sender); private static void TextBoxBase_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) => AutoScroll((TextBox)sender); #endregion #region TextBoxBase - AppendText - Logging public static void AppendText(TextBox control, string text, int maxSize = 65535) { // Check for a null control if (control == null) throw new ArgumentNullException(nameof(control)); // Invoke UI thread if necessary, to perform UI updates AppInvoke(() => { // Calculate new text size var newTextSize = System.Text.Encoding.UTF8.GetByteCount(control.Text + text); // Ensure the final text size does not exceed maxSize if (newTextSize > maxSize) { var lines = control.Text.Split(new[] { Environment.NewLine }, StringSplitOptions.None); int linesToRemove = 0; int sizeRemoved = 0; // Determine how many lines to remove from the start to stay within maxSize while (sizeRemoved < newTextSize - maxSize && linesToRemove < lines.Length) { sizeRemoved += System.Text.Encoding.UTF8.GetByteCount(lines[linesToRemove] + Environment.NewLine); linesToRemove++; } // Rebuild the remaining text after removing oldest lines var remainingText = string.Join(Environment.NewLine, lines, linesToRemove, lines.Length - linesToRemove); control.Text = remainingText; } // Append the new text if (control.Text.Length > 0) control.AppendText(Environment.NewLine + text); else control.AppendText(text); }); } #endregion public static void AppInvoke(Action action) { // Check if we are on the UI thread if (Application.Current.Dispatcher.CheckAccess()) { // If on UI thread, update the UI elements directly action.Invoke(); } else { // If not on UI thread, invoke on the UI thread Application.Current.Dispatcher.Invoke(action); } } public static void AppBeginInvoke(Action action) { // Check if we are on the UI thread if (Application.Current.Dispatcher.CheckAccess()) { // If on UI thread, update the UI elements directly //_ = action.BeginInvoke(action.EndInvoke, null); Application.Current.Dispatcher.BeginInvoke(action); } else { // If not on UI thread, invoke on the UI thread Application.Current.Dispatcher.BeginInvoke(action); } } // Contains unique list of control IDs for the applicaiton. private static SortedSet LoadedControls = new SortedSet(); /// /// Returnd false if displayed in desing mode (IDE). /// Return true if control is not in the list of loaded controls. /// Add control to the list of loaded controls. /// IMPORTANT! Must be used in pair with AllowUnload. /// public static bool AllowLoad(FrameworkElement control) { if (IsDesignMode(control)) return false; var code = control.GetHashCode(); return LoadedControls.Add(code); } /// /// Returnd false if displayed in desing mode (IDE). /// Return true if control is in the list of loaded controls. /// Remove control from the list of loaded controls. /// IMPORTANT! Must be used in pair with AllowLoad. /// public static bool AllowUnload(FrameworkElement control) { if (IsDesignMode(control)) return false; var code = control.GetHashCode(); return LoadedControls.Remove(code); } /// /// Returnd false if displayed in desing mode (IDE). /// Return true if control is in the list of loaded controls. /// Remove control from the list of loaded controls. /// public static bool IsLoaded(FrameworkElement control) { if (IsDesignMode(control)) return false; var code = control.GetHashCode(); return LoadedControls.Contains(code); } #region File Explorer Behaviour /* Behavior: 1. Selecting a row by clicking on the row (excluding the checkbox) should select the row and check its box. 2. Deselecting a row by clicking on the selected row (excluding the checkbox) should deselect the row and uncheck its box. 3. Checking the box should only affect its associated row, selecting it. All other rows and checkboxes should remain unaffected. 4. Unchecking the box should only deselect its associated row. All other rows and checkboxes should remain unaffected. In summary, both selection and multi-selection operate normally and mirrored on checkboxes. Checking or unchecking a box affects the selection of its associated row only. */ static T GetParent(DependencyObject source) where T : class { while (source != null && !(source is T)) source = VisualTreeHelper.GetParent(source); return source as T; } /// /// Workaround: Without this event, "mouse down" will select the checkbox, but "mouse up" will deselect it immediately. /// public static void FileExplorer_DataGrid_CheckBox_PreviewMouseDown(object sender, MouseButtonEventArgs e) { var checkBox = (CheckBox)sender; var dataGridRow = GetParent((DependencyObject)e.OriginalSource); if (dataGridRow != null) { dataGridRow.IsSelected = !(checkBox.IsChecked == true); e.Handled = true; } } #endregion /// /// Checks if the specified control within its parent TabControls is selected. /// /// The control to check for selection. /// True if the TabItem is selected, otherwise false. public static bool IsTabItemSelected(FrameworkElement control) { var parent = control.Parent as FrameworkElement; while (parent != null) { if (parent is TabItem tabItem) { if (!tabItem.IsSelected) return false; } else if (parent is TabControl tabControl) { foreach (TabItem item in tabControl.Items) { if (item.Content == control) { if (!item.IsSelected) return false; break; } } } parent = parent.Parent as FrameworkElement; } return true; } /// /// Ensures that the specified control is selected within its parent TabControls. /// /// The control to be selected. public static void EnsureTabItemSelected(FrameworkElement control) { if (IsTabItemSelected(control)) return; var parent = control.Parent as FrameworkElement; while (parent != null) { if (parent is TabItem tabItem) { if (!tabItem.IsSelected) tabItem.IsSelected = true; } else if (parent is TabControl tabControl) { foreach (TabItem item in tabControl.Items) { if (item.Content == control) { if (!item.IsSelected) item.IsSelected = true; break; } } } parent = parent.Parent as FrameworkElement; } } } } ================================================ FILE: FocusLogger/JocysCom/Controls/ControlsHelper.cs ================================================ using System; using System.Collections.Concurrent; using System.Data; using System.Diagnostics; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; namespace JocysCom.ClassLibrary.Controls { public static partial class ControlsHelper { #region Invoke and BeginInvoke /// /// Call this method from main form constructor for BeginInvoke to work. /// public static void InitInvokeContext() { if (MainTaskScheduler != null) return; _MainThreadId = Thread.CurrentThread.ManagedThreadId; // Create a TaskScheduler that wraps the SynchronizationContext returned from // System.Threading.SynchronizationContext.Current MainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); } /// /// Object that handles the low-level work of queuing tasks onto main User Interface (GUI) thread. /// public static TaskScheduler MainTaskScheduler { get; private set; } public static int MainThreadId => _MainThreadId; private static int _MainThreadId; public static bool InvokeRequired => _MainThreadId != Thread.CurrentThread.ManagedThreadId; /* public static void TestTasks(TaskCreationOptions childOptions) { var i = 5000000; Console.WriteLine("//"); Console.WriteLine("TestTasks(TaskCreationOptions.{0});", childOptions); Console.WriteLine("// Parent starting"); var parent = Task.Factory.StartNew(() => { Console.WriteLine("// Parent started"); Console.WriteLine("// Child starting"); var child = Task.Factory.StartNew(() => { Console.WriteLine("// Child started"); Thread.SpinWait(i); Console.WriteLine("// Child completing"); }, childOptions); //child.Wait(); //Console.WriteLine("// Child completed"); Console.WriteLine("// Parent completing"); }); parent.Wait(); Console.WriteLine("// Parent completed"); Thread.SpinWait(i * 4); } // Attached and Detached Child Tasks. // // TaskCreationOptions.AttachedToParent: // // - Parent task waits for child tasks to complete. // - Parent task propagates exceptions thrown by child tasks. // - Status of parent task depends on status of child task. // TestTasks(TaskCreationOptions.AttachedToParent); // // Parent starting // Parent started // Child starting // Parent completing // Child started // Child completing // Parent completed // TestTasks(TaskCreationOptions.None); // // Parent starting // Parent started // Child starting // Parent completing // Parent completed // Child started // Child completing */ /// Executes the specified action delegate asynchronously on main Graphical User Interface (GUI) Thread. /// The action delegate to execute asynchronously. /// The started System.Threading.Tasks.Task. public static Task BeginInvoke(Action action, int? millisecondsDelay = null) { if (millisecondsDelay.HasValue) { return Task.Run(async () => { // Wait 1 second, which will allow to release the button. // Logical delay without blocking the current hardware thread. await Task.Delay(millisecondsDelay.Value).ConfigureAwait(true); await BeginInvoke(action); }); } InitInvokeContext(); return Task.Factory.StartNew(action, CancellationToken.None, TaskCreationOptions.DenyChildAttach, MainTaskScheduler); } /// Executes the specified action delegate asynchronously on main User Interface (UI) Thread. /// The delegate to execute asynchronously. /// The arguments to pass to the delegate. /// The started System.Threading.Tasks.Task. public static Task BeginInvoke(Delegate method, params object[] args) { InitInvokeContext(); return Task.Factory.StartNew(() => { method.DynamicInvoke(args); }, CancellationToken.None, TaskCreationOptions.DenyChildAttach, MainTaskScheduler); } /// Executes the specified action delegate synchronously on main Graphical User Interface (GUI) Thread. /// The action delegate to execute synchronously. public static void Invoke(Action action) { if (action is null) throw new ArgumentNullException(nameof(action)); InitInvokeContext(); if (InvokeRequired) { var t = new Task(action); t.RunSynchronously(MainTaskScheduler); } else { action.DynamicInvoke(); } } /// Executes the specified action delegate synchronously on main Graphical User Interface (GUI) Thread. /// The delegate to execute synchronously. /// The arguments to pass to the delegate. public static object Invoke(Delegate method, params object[] args) { if (method is null) throw new ArgumentNullException(nameof(method)); // Run method on main Graphical User Interface thread. if (InvokeRequired) { var t = new Task(() => method.DynamicInvoke(args)); t.RunSynchronously(MainTaskScheduler); return t.Result; } else { return method.DynamicInvoke(args); } } #endregion #region Open Path or URL public static void OpenUrl(string url) { try { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) Process.Start("xdg-open", url); else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) Process.Start("open", url); } catch (System.ComponentModel.Win32Exception winEx) { if (winEx.ErrorCode == -2147467259) MessageBoxShow(winEx.Message); } catch (System.Exception ex) { MessageBoxShow(ex.Message); } } private static void MessageBoxShow(string message) { #if NETFRAMEWORK // .NET Framework // Requires: PresentationFramework.dll System.Windows.MessageBox.Show(message); #else System.Windows.MessageBox.Show(message); #endif } /// /// Open file with associated program. /// /// file to open. public static void OpenPath(string path, string arguments = null) { try { var psi = new System.Diagnostics.ProcessStartInfo(); if (Uri.TryCreate(path, UriKind.Absolute, out Uri uri) && uri.Scheme != Uri.UriSchemeFile) { // Open URL psi.UseShellExecute = true; psi.FileName = uri.AbsoluteUri; if (arguments != null) psi.Arguments = arguments; psi.WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); } else { // Open file/directory psi.FileName = path; if (arguments != null) psi.Arguments = arguments; var fi = new System.IO.FileInfo(path); psi.UseShellExecute = true; psi.ErrorDialog = true; psi.WorkingDirectory = fi.Directory?.FullName ?? Environment.CurrentDirectory; } System.Diagnostics.Process.Start(psi); } catch { } } #endregion public static PropertyInfo GetPrimaryKeyPropertyInfo(object item) { if (item is null) return null; var t = item.GetType(); PropertyInfo pi = null; #if NETFRAMEWORK // Try to find property by EntityFramework EdmScalarPropertyAttribute (System.Data.Entity.dll). pi = t.GetProperties() .Where(x => x.GetCustomAttributes(typeof(System.Data.Objects.DataClasses.EdmScalarPropertyAttribute), true) .Cast() .Any(a => a.EntityKeyProperty)) .FirstOrDefault(); if (pi != null) return pi; #else // Try to find property by KeyAttribute. pi = t.GetProperties() .Where(x => Attribute.IsDefined(x, typeof(System.ComponentModel.DataAnnotations.KeyAttribute), true)) .FirstOrDefault(); if (pi != null) return pi; #endif return null; } /// /// Get DataViewRow, DataRow or item property value. /// /// Return value type. /// DataViewRow, DataRow or another type. /// Data property or column name. /// Optional property info cache. /// private static T GetValue(object item, string keyPropertyName, PropertyInfo pi = null) { // Return object value if property info supplied. if (pi != null) return (T)pi.GetValue(item, null); // Get DataRow. var row = item is System.Data.DataRowView rowView ? rowView.Row : (System.Data.DataRow)item; // Return DataRow value. return row.IsNull(keyPropertyName) ? default : (T)row[keyPropertyName]; } /// /// Get Property info /// /// /// private static PropertyInfo GetPropertyInfo(string keyPropertyName, object item) { // Get property info if not DataRowView or DataRow. PropertyInfo pi = null; if (!(item is DataRowView) && !(item is DataRow)) pi = string.IsNullOrEmpty(keyPropertyName) ? GetPrimaryKeyPropertyInfo(item) : item.GetType().GetProperty(keyPropertyName); return pi; } #region Add cool downs to controls. // Default cool-down 1 second. public static TimeSpan ControlCooldown = new TimeSpan(0, 0, 1); public static ConcurrentDictionary ControlCooldowns { get; } = new ConcurrentDictionary(); /// /// Returns true if control is on cool-down. /// /// Control to check. public static bool IsOnCooldown(object control, int? milliseconds = null) { lock (ControlCooldowns) { var now = DateTime.UtcNow; int hashCode = control.GetHashCode(); // Cleanup expired cooldowns. var expiredKeys = ControlCooldowns.Where(kv => now > kv.Value).Select(kv => kv.Key).ToList(); foreach (var key in expiredKeys) ControlCooldowns.TryRemove(key, out _); // If on cool-down then... if (ControlCooldowns.ContainsKey(hashCode)) return true; var newTime = milliseconds.HasValue ? now.AddMilliseconds(milliseconds.Value) : now.Add(ControlCooldown); ControlCooldowns.TryAdd(hashCode, newTime); return false; } } #endregion } } ================================================ FILE: FocusLogger/JocysCom/Controls/InfoControl.xaml ================================================  ================================================ FILE: FocusLogger/JocysCom/Controls/InfoControl.xaml.cs ================================================ using JocysCom.ClassLibrary.Controls.Themes; using System; using System.ComponentModel; using System.Reflection; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Media.Animation; namespace JocysCom.ClassLibrary.Controls { /// /// Interaction logic for InfoControl.xaml /// public partial class InfoControl : UserControl { public InfoControl() { InitHelper.InitTimer(this, InitializeComponent); if (ControlsHelper.IsDesignMode(this)) return; // Get assemblies which will be used to select default (fists) and search for resources. var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly(); //var company = ((AssemblyCompanyAttribute)Attribute.GetCustomAttribute(assembly, typeof(AssemblyCompanyAttribute)))?.Company; var product = ((AssemblyProductAttribute)Attribute.GetCustomAttribute(assembly, typeof(AssemblyProductAttribute)))?.Product; var description = ((AssemblyDescriptionAttribute)Attribute.GetCustomAttribute(assembly, typeof(AssemblyDescriptionAttribute)))?.Description; DefaultHead = product; DefaultBody = description; Reset(); CreateBusyIconAnimation(); // InitRotation(); HelpProvider.OnMouseEnter += HelpProvider_OnMouseEnter; HelpProvider.OnMouseLeave += HelpProvider_OnMouseLeave; } public DoubleAnimationUsingKeyFrames animationBusyIcon = new DoubleAnimationUsingKeyFrames(); public Storyboard storyboardBusyIcon = new Storyboard(); private void CreateBusyIconAnimation() { // Animation properties. Storyboard.SetTarget(animationBusyIcon, BusyIcon); Storyboard.SetTargetProperty(animationBusyIcon, new PropertyPath("(UIElement.RenderTransform).(RotateTransform.Angle)")); animationBusyIcon.KeyFrames.Add(new DiscreteDoubleKeyFrame(0, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(0)))); animationBusyIcon.KeyFrames.Add(new LinearDoubleKeyFrame(360, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(6)))); // Storyboard properties. storyboardBusyIcon.RepeatBehavior = RepeatBehavior.Forever; storyboardBusyIcon.Children.Add(animationBusyIcon); storyboardBusyIcon.Begin(); } private void HelpProvider_OnMouseEnter(object sender, EventArgs e) { var control = (Control)sender; var head = HelpProvider.GetHelpHead(control); var body = HelpProvider.GetHelpBody(control, BodyMaxLength, true); var image = HelpProvider.GetHelpImage(control); SetHead(head); SetBody(image, body); } public int BodyMaxLength { get; set; } = int.MaxValue; private void HelpProvider_OnMouseLeave(object sender, EventArgs e) { Reset(); } public InfoHelpProvider HelpProvider { get; set; } = new InfoHelpProvider(); #region ■ Properties public string DefaultHead { get; set; } public string DefaultBody { get; set; } public object RightIconContent { get => RightIcon.GetValue(ContentProperty); set => RightIcon.SetValue(ContentProperty, value); } object _Image; public void SetImage(object resource) { _Image = resource; RightIcon.Content = _Image; } #endregion #region Set Text public void Reset() { SetHead(DefaultHead); SetBodyInfo(DefaultBody); } public void SetTitle(string format, params object[] args) { var win = System.Windows.Window.GetWindow(this); if (win is null) return; win.Title = (args.Length == 0) ? format : string.Format(format, args); } public void SetHead(string format, params object[] args) { // Apply format. if (format is null) format = DefaultHead; else if (args.Length > 0) format = string.Format(format, args); if (HeadLabel.Content as string != format) HeadLabel.Content = format; } public void SetBodyError(string content, params object[] args) { // Apply format. if (content is null) content = DefaultBody; else if (args.Length > 0) content = string.Format(content, args); // Set info with time. SetBody(MessageBoxImage.Error, "{0: yyyy-MM-dd HH:mm:ss}: {1}", DateTime.Now, content); } public void SetBodyInfo(string content, params object[] args) { // Apply format. if (content is null) content = DefaultBody; else if (args.Length > 0) content = string.Format(content, args); // Set info with time. SetBody(MessageBoxImage.Information, content); } public async void SetWithTimeout(MessageBoxImage image, string content = null, params object[] args) { SetBody(image, content, args); var bodyText = BodyLabel.Text; // The average minimal reading speed for adults is 16 characters per second. // Use reading speed for adults as 14 characters per second. // Add 4 extra seconds for realization and focus. var waitSeconds = 4 + bodyText.Length / 14.0; // Task code which waits for waitSeconds and executes code below. await Task.Delay(TimeSpan.FromSeconds(waitSeconds)); if (bodyText == BodyLabel.Text) Reset(); } public void SetBody(MessageBoxImage image, string content = null, params object[] args) { if (content is null) content = DefaultBody; else if (args.Length > 0) content = string.Format(content, args); BodyLabel.Text = content; // Set body color and icon. switch (image) { case MessageBoxImage.Error: BodyLabel.Foreground = new SolidColorBrush(Colors.DarkRed); LeftIcon.Content = Icons.Current[Icons.Icon_Error]; break; case MessageBoxImage.Question: BodyLabel.Foreground = new SolidColorBrush(Colors.DarkBlue); LeftIcon.Content = Icons.Current[Icons.Icon_Question]; break; case MessageBoxImage.Warning: BodyLabel.Foreground = new SolidColorBrush(Colors.DarkOrange); LeftIcon.Content = Icons.Current[Icons.Icon_Warning]; break; default: BodyLabel.Foreground = SystemColors.ControlTextBrush; LeftIcon.Content = Icons.Current[Icons.Icon_Information]; break; } } #endregion #region Task and Rotating Icon private readonly object TasksLock = new object(); public readonly BindingList Tasks = new BindingList(); /// Activate busy spinner. public void AddTask(object name) { lock (TasksLock) { if (!Tasks.Contains(name)) Tasks.Add(name); UpdateIcon(); } } /// Deactivate busy spinner if all tasks are gone. public void RemoveTask(object name) { lock (TasksLock) { if (Tasks.Contains(name)) Tasks.Remove(name); UpdateIcon(); } } public void UpdateIcon() { Dispatcher.Invoke(() => { BusyCount.Content = Tasks.Count > 1 ? $"{Tasks.Count}" : ""; if (Tasks.Count > 0) { BusyIcon.Visibility = Visibility.Visible; RightIcon.Visibility = Visibility.Hidden; storyboardBusyIcon.Begin(); } else { storyboardBusyIcon.Pause(); BusyIcon.Visibility = Visibility.Hidden; RightIcon.Visibility = Visibility.Visible; } }); } #endregion private void UserControl_Loaded(object sender, RoutedEventArgs e) { if (!ControlsHelper.AllowLoad(this)) return; } private void UserControl_Unloaded(object sender, RoutedEventArgs e) { if (!ControlsHelper.AllowUnload(this)) return; _Image = null; } } } ================================================ FILE: FocusLogger/JocysCom/Controls/InfoHelpProvider.cs ================================================ using System; using System.Collections.Generic; using System.Text.RegularExpressions; using System.Windows; namespace JocysCom.ClassLibrary.Controls { public class InfoHelpProvider { /// /// Dictionary to hold controls and their corresponding help texts. /// public Dictionary Controls { get; set; } = new Dictionary(); /// /// Event to trigger when mouse enters any control from `Controls`. /// public event EventHandler OnMouseEnter; /// /// Event to trigger when mouse leaves all controls from `Control`. /// public event EventHandler OnMouseLeave; public string GetHelpHead(UIElement control) { if (!Controls.ContainsKey(control)) return ""; return (string)Controls[control][0]; } public MessageBoxImage GetHelpImage(UIElement control) { if (!Controls.ContainsKey(control)) return MessageBoxImage.Information; return (MessageBoxImage)Controls[control][2]; } public string GetHelpBody(UIElement control, int? maxLength = null, bool removeMultispace = false) { if (!Controls.ContainsKey(control)) return ""; var body = (string)Controls[control][1]; if (removeMultispace) body = RemoveMultispace(body); if (maxLength != null) body = CropText(body, maxLength); return body; } // Method to add control and its help text to HelpControls and attach MouseEnter and MouseLeave events public void Add(UIElement control, string helpHead, string helpBody = "", MessageBoxImage image = MessageBoxImage.Information) { if (Controls.ContainsKey(control)) Controls.Remove(control); Controls.Add(control, new object[] { helpHead, helpBody, image }); control.MouseEnter += (s, e) => OnMouseEnter?.Invoke(s, e); control.MouseLeave += (s, e) => { bool isMouseOutsideAll = true; foreach (var item in Controls.Keys) { if (item.IsMouseOver) { isMouseOutsideAll = false; break; } } if (isMouseOutsideAll) OnMouseLeave?.Invoke(s, e); }; } // Method to remove control from HelpControls and detach MouseEnter and MouseLeave events public void Remove(UIElement control) { if (!Controls.ContainsKey(control)) return; control.MouseEnter -= (s, e) => OnMouseEnter?.Invoke(s, e); control.MouseLeave -= (s, e) => { bool isMouseOutsideAll = true; foreach (var item in Controls.Keys) { if (item.IsMouseOver) { isMouseOutsideAll = false; break; } } if (isMouseOutsideAll) OnMouseLeave?.Invoke(s, e); }; Controls.Remove(control); } public const int CropTextDefauldMaxLength = 128; /// /// if maxLength == -1, return string.Empty /// if maxLength == 0 return s /// /// /// /// public static string CropText(object so, int? maxLength = 0) { var s = string.Format("{0}", so); if (!maxLength.HasValue) maxLength = CropTextDefauldMaxLength; if (string.IsNullOrEmpty(s) || maxLength == -1) return ""; if (maxLength == 0) return s; if (maxLength == 0) maxLength = CropTextDefauldMaxLength; if (s.Length > maxLength) { s = s.Substring(0, maxLength.Value - 3); // Find last separator and crop there... var ls = s.LastIndexOf(' '); if (ls > 0) s = s.Substring(0, ls); s += "..."; } return s; } public static readonly Regex RxMultiSpace = new Regex("[ \u00A0\r\n\t]+", RegexOptions.Multiline); public static string RemoveMultispace(string s) { if (string.IsNullOrEmpty(s)) return s; // Replace multiple spaces. s = RxMultiSpace.Replace(s, " "); return s; } } } ================================================ FILE: FocusLogger/JocysCom/Controls/InitHelper.cs ================================================ using System; using System.Collections.Generic; using System.Diagnostics; using System.Windows; using System.Linq; namespace JocysCom.ClassLibrary.Controls { /// /// Helps to output initialize stats when form initialize. /// public class InitHelper { public InitHelper() { _Timer = new System.Timers.Timer(); _Timer.AutoReset = false; _Timer.Interval = 2000; _Timer.Elapsed += _Timer_Elapsed; } internal FrameworkElement Control; internal DateTime StartDate; internal DateTime EndDate; internal int _PropertyChangedCount; System.Timers.Timer _Timer; public void WriteLine(string prefix) { var s = string.Format("-4-> {0,4}. {1} - {2}: {3} changes", _InitEndCount, prefix, Control.GetType(), _PropertyChangedCount); if (EndDate.Ticks > 0) s += string.Format(", {0:# ##0} ms", EndDate.Subtract(StartDate).TotalMilliseconds); Debug.WriteLine(s); } #region ■ Static internal static int _InitEndCount; private static object TimersLock = new object(); private static List Timers = new List(); public static List LoadedControlNames = new List(); public static bool IsDebug { get { #if DEBUG return true; #else return false; #endif } } /// /// In release mode invoke only, in debug mode use times and monitor object. /// public static void InitTimer(FrameworkElement control, Action InitializeComponent) { if (!IsDebug) { InitializeComponent.Invoke(); return; } var ih = new InitHelper(); ih.Control = control; ih.StartDate = DateTime.Now; ih.WriteLine("INIT START"); ih.EndDate = DateTime.Now; InitializeComponent.Invoke(); ih.EndDate = DateTime.Now; ih.WriteLine("INIT CON "); lock (TimersLock) Timers.Add(ih); ih.Control.Loaded += Control_Loaded; ih.Control.Unloaded += Control_Unloaded; ih.Control.IsVisibleChanged += Control_IsVisibleChanged; //ih.Control.PropertyChanged += Control_PropertyChanged; ih._Timer.Start(); } private static void Control_Loaded(object sender, RoutedEventArgs e) { var control = (FrameworkElement)sender; var name = $"{control.GetType()} {control.Name} {control.GetHashCode()}"; if (LoadedControlNames.Contains(name)) Console.WriteLine($"WARN: Control is loaded already: {name}"); LoadedControlNames.Add(name); } private static void Control_Unloaded(object sender, RoutedEventArgs e) { var control = (FrameworkElement)sender; var name = $"{control.GetType()} {control.Name} {control.GetHashCode()}"; LoadedControlNames.Remove(name); Debug.WriteLine($"-5-> LoadedControlNames.Count = {LoadedControlNames.Count()} // Unloaded: {name}"); // Remove events. control.Loaded -= Control_Loaded; control.Unloaded -= Control_Unloaded; control.IsVisibleChanged -= Control_IsVisibleChanged; //control.PropertyChanged -= Control_PropertyChanged; } private static void Control_IsVisibleChanged(object sender, System.Windows.DependencyPropertyChangedEventArgs e) { RestartTimer(sender); } /// /// Xamarin. /// private static void Control_PropertyChanged(object sender, EventArgs e) { RestartTimer(sender); } private static void RestartTimer(object sender) { InitHelper ih = null; lock (TimersLock) ih = Timers.FirstOrDefault(x => Equals(x.Control, sender)); if (ih is null) return; ih._PropertyChangedCount++; ih.EndDate = DateTime.Now; ih._Timer.Stop(); ih._Timer.Start(); } /// /// This function will trigger once, after 2000ms when control stops visible changing. /// private static void _Timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) { InitHelper ih = null; // Find InitHelper by timer. lock (TimersLock) { ih = Timers.FirstOrDefault(x => Equals(x._Timer, sender)); if (ih is null) return; Timers.Remove(ih); _InitEndCount++; ih.WriteLine("INIT END"); // Disconnect all links. ih.Control.IsVisibleChanged -= Control_IsVisibleChanged; //ih.Control.PropertyChanged -= Control_PropertyChanged; ih.Control = null; ih._Timer.Dispose(); } } #endregion } } ================================================ FILE: FocusLogger/JocysCom/Controls/ItemFormattingConverter.cs ================================================ using System; using System.Globalization; using System.Windows.Data; namespace JocysCom.ClassLibrary.Controls { public class ItemFormattingConverter : IMultiValueConverter { public Func ConvertFunction; public Func ConvertBackFunction; public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) => ConvertFunction?.Invoke(values, targetType, parameter, culture); public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) => ConvertBackFunction?.Invoke(value, targetTypes, parameter, culture); } } ================================================ FILE: FocusLogger/JocysCom/Controls/MessageBoxWindow.xaml ================================================  ================================================ FILE: FocusLogger/JocysCom/Controls/MessageBoxWindow.xaml.cs ================================================ using System; using System.Globalization; using System.Windows; using System.Windows.Controls; using System.Windows.Media; namespace JocysCom.ClassLibrary.Controls { /// /// Interaction logic for MessageBoxWindow.xaml /// /// Make sure to set the Owner property to be disposed properly after closing. public partial class MessageBoxWindow : Window { /// /// Make sure to set window Owner to properly dispose after closing. /// public MessageBoxWindow() { InitHelper.InitTimer(this, InitializeComponent); LinkTextBlock.Visibility = Visibility.Collapsed; // Center owner. var owner = Application.Current?.MainWindow; if (owner != null) { Owner = owner; WindowStartupLocation = WindowStartupLocation.CenterOwner; } } /// Displays a message box that has a message, title bar caption, button, and icon; and that accepts a default message box result, complies with the specified options, and returns a result. /// A that specifies the text to display. /// A that specifies the title bar caption to display. /// A value that specifies which button or buttons to display. /// A value that specifies the icon to display. /// A value that specifies the default result of the message box. /// A value object that specifies the options. public static MessageBoxResult Show( string message, string caption = "", MessageBoxButton button = MessageBoxButton.OK, MessageBoxImage icon = MessageBoxImage.None, MessageBoxResult defaultResult = MessageBoxResult.None, MessageBoxOptions options = MessageBoxOptions.None ) { var box = new MessageBoxWindow(); return box.ShowDialog(message, caption, button, icon, defaultResult, options); } /// Displays a message box that has a message, title bar caption, button, and icon; and that accepts a default message box result, complies with the specified options, and returns a result. /// A that specifies the text to display. /// A that specifies the title bar caption to display. /// A value that specifies which button or buttons to display. /// A value that specifies the icon to display. /// A value that specifies the default result of the message box. /// A value object that specifies the options. public MessageBoxResult ShowDialog( string message, string caption = "", MessageBoxButton button = MessageBoxButton.OK, MessageBoxImage icon = MessageBoxImage.None, MessageBoxResult defaultResult = MessageBoxResult.None, MessageBoxOptions options = MessageBoxOptions.None) { MessageTextBlock.Visibility = Visibility.Visible; MessageTextBox.Visibility = Visibility.Collapsed; LinkLabel.Visibility = Visibility.Collapsed; SizeLabel.Visibility = Visibility.Visible; Title = caption; MessageTextBlock.Text = message; _SwitchButton(button, defaultResult); _SwitchIcon(icon); // Get text size. var size = MeasureString(message, MessageTextBlock); if (size.Width > 960) { size = ApplyAspectRatio(size); MessageTextBlock.MaxWidth = Math.Round(size.Width, 0); } if (ControlsHelper.GetMainFormTopMost()) Topmost = true; // Show form. var result = ShowDialog(); return Result; } /// Displays a message box that has a message, title bar caption, button, and icon; and that accepts a default message box result, complies with the specified options, and returns a result. /// A that specifies the text to display. /// A that specifies the title bar caption to display. /// A value that specifies which button or buttons to display. /// A value that specifies the icon to display. /// A value that specifies the default result of the message box. /// A value object that specifies the options. public MessageBoxResult ShowPrompt( string message, string caption = "", MessageBoxButton button = MessageBoxButton.OKCancel, MessageBoxImage icon = MessageBoxImage.Information, MessageBoxResult defaultResult = MessageBoxResult.OK, MessageBoxOptions options = MessageBoxOptions.None ) { MessageTextBlock.Visibility = Visibility.Collapsed; MessageTextBox.Visibility = Visibility.Visible; LinkLabel.Visibility = Visibility.Collapsed; SizeLabel.Visibility = Visibility.Visible; Title = caption; MessageTextBox.Text = message; _SwitchButton(button, defaultResult); _SwitchIcon(icon); // Set size. Loaded -= MessageBoxWindow_Loaded; Loaded += MessageBoxWindow_Loaded; // Update size label. UpdateSizeLabel(); if (ControlsHelper.GetMainFormTopMost()) Topmost = true; // Attach a new Loaded event handler specifically for focusing the message text box Loaded += FocusMessageTextBox; // Show form. var result = ShowDialog(); // Clean up by removing the event handler after the dialog is closed Loaded -= FocusMessageTextBox; return Result; } private void FocusMessageTextBox(object sender, RoutedEventArgs e) { // Set focus to the MessageTextBox control. MessageTextBox.Focus(); // Set the caret position to the end of the text MessageTextBox.CaretIndex = MessageTextBox.Text.Length; } public void SetSize(double width = 0, double height = 0) { if (width > 0 && height > 0) { SizeToContent = SizeToContent.Manual; Width = width; Height = height; } else { SizeToContent = SizeToContent.WidthAndHeight; } } private void MessageBoxWindow_Loaded(object sender, RoutedEventArgs e) { if (SizeToContent == SizeToContent.Manual) { ControlsHelper.CenterWindowOnApplication(this); } else { // Get text size (from 256 to 512). var measureSize = Math.Min(Math.Max(256, MessageTextBlock.Text.Length), 512); var measureMessage = new string('a', measureSize); var size = MeasureString(measureMessage, MessageTextBlock); size = ApplyAspectRatio(size); var boxWidth = Math.Round(size.Width, 0); var boxHeight = Math.Round(size.Height, 0); // Set window size. var winWidthDif = Width - MessageTextBox.ActualWidth; var winHeightDif = Height - MessageTextBox.ActualHeight; SizeToContent = SizeToContent.Manual; Width = boxWidth + winWidthDif; Height = boxHeight + winHeightDif; } } void EnableButtons(MessageBoxResult r1, MessageBoxResult r2 = MessageBoxResult.None, MessageBoxResult r3 = MessageBoxResult.None) { Button1.Tag = r1; Button1Label.Content = r1.ToString(); Button1.Visibility = r1 == MessageBoxResult.None ? Visibility.Collapsed : Visibility.Visible; Button2.Tag = r2; Button2Label.Content = r2.ToString(); Button2.Visibility = r2 == MessageBoxResult.None ? Visibility.Collapsed : Visibility.Visible; Button3.Tag = r3; Button3Label.Content = r3.ToString(); Button3.Visibility = r3 == MessageBoxResult.None ? Visibility.Collapsed : Visibility.Visible; } private MessageBoxResult Result = MessageBoxResult.None; private void _SwitchButton(MessageBoxButton button, MessageBoxResult defaultResult) { switch (button) { case MessageBoxButton.OK: EnableButtons(MessageBoxResult.OK); break; case MessageBoxButton.OKCancel: EnableButtons(MessageBoxResult.OK, MessageBoxResult.None, MessageBoxResult.Cancel); break; case MessageBoxButton.YesNoCancel: EnableButtons(MessageBoxResult.Yes, MessageBoxResult.No, MessageBoxResult.Cancel); break; case MessageBoxButton.YesNo: EnableButtons(MessageBoxResult.Yes, MessageBoxResult.No); break; default: break; } var buttons = new[] { Button1, Button2, Button3 }; foreach (var b in buttons) { if ((MessageBoxResult)b.Tag == MessageBoxResult.Cancel) b.IsCancel = true; if ((MessageBoxResult)b.Tag == defaultResult) b.IsDefault = true; } } private void _SwitchIcon(MessageBoxImage icon) { switch (icon) { case MessageBoxImage.Error: IconContent.Content = Resources["Icon_Error"]; break; case MessageBoxImage.Question: IconContent.Content = Resources["Icon_Question"]; break; case MessageBoxImage.Warning: IconContent.Content = Resources["Icon_Warning"]; break; default: IconContent.Content = Resources["Icon_Information"]; break; } } private void CopyMessage_Click(object sender, RoutedEventArgs e) { var text = MessageTextBox.Visibility == Visibility.Visible ? MessageTextBox.Text : MessageTextBlock.Text; if (!string.IsNullOrEmpty(text)) Clipboard.SetText(text); } private void Button_Click(object sender, RoutedEventArgs e) { Result = (MessageBoxResult)((Button)sender).Tag; DialogResult = true; } public void SetLink(string text = null, Uri uri = null) { LinkTextBlock.Visibility = string.IsNullOrEmpty(text) ? Visibility.Collapsed : Visibility.Visible; MainHyperLink.NavigateUri = uri; LinkLabel.Text = text; } private void MainHyperLink_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e) { OpenUrl(e.Uri.AbsoluteUri); } #region Helper Methods public static void OpenUrl(string url) { try { System.Diagnostics.Process.Start(url); } catch (System.ComponentModel.Win32Exception noBrowser) { if (noBrowser.ErrorCode == -2147467259) MessageBox.Show(noBrowser.Message); } catch (Exception other) { MessageBox.Show(other.Message); } } private static Size ApplyAspectRatio(Size s, double w = 16, double h = 9) { var area = s.Width * s.Height; // w * x * h * x = area; var x = Math.Sqrt(area / w / h); return new Size(w * x, h * x); } private static Size MeasureString(string candidate, TextBlock control) { var formattedText = new FormattedText( candidate, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface(control.FontFamily, control.FontStyle, control.FontWeight, control.FontStretch), control.FontSize, Brushes.Black, new NumberSubstitution(), 1); return new Size(formattedText.Width, formattedText.Height); } #endregion private void MessageTextBox_KeyUp(object sender, System.Windows.Input.KeyEventArgs e) { UpdateSizeLabel(); } void UpdateSizeLabel() { var text = (MessageTextBox.MaxLength - MessageTextBox.Text.Length).ToString(); ControlsHelper.SetText(SizeLabel, text); ControlsHelper.SetVisible(SizeLabel, MessageTextBox.MaxLength > 0); } private void Window_Loaded(object sender, RoutedEventArgs e) { if (!ControlsHelper.AllowLoad(this)) return; // Center message box window in application. if (Owner is null) ControlsHelper.CenterWindowOnApplication(this); } private void Window_Unloaded(object sender, RoutedEventArgs e) { if (!ControlsHelper.AllowUnload(this)) return; } private void Window_Closed(object sender, EventArgs e) { Owner = null; } } } ================================================ FILE: FocusLogger/JocysCom/Controls/TabIndexConverter.cs ================================================ using System; using System.Globalization; using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Data; namespace JocysCom.ClassLibrary.Controls { public class TabIndexConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { var item = value as TabItem; if (item is null) return ""; var container = ItemsControl.ItemsControlFromItemContainer(item).ItemContainerGenerator; var items = container.Items.Cast().Where(x => x.Visibility == Visibility.Visible).ToList(); var count = items.Count(); var index = items.IndexOf(item); var result = ""; if (index == 0) result += "First"; if (item.IsSelected) result += "Selected"; return result; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { return DependencyProperty.UnsetValue; } } } ================================================ FILE: FocusLogger/JocysCom/Controls/Themes/Convert_SVG_to_XAML.ps1 ================================================ <# .SYNOPSIS Convert Folder with SVG image files into XAML Resource file. .NOTES Author: Evaldas Jocys Modified: 2021-11-06 .LINK http://www.jocys.com .REMARKS Requires Installation of InkScape 1.2 from https://inkscape.org/release/ https://inkscape.org/release/inkscape-1.2.2/windows/64-bit/compressed-7z/dl/ How to include icons resource into App.xaml file: How to display image inside the XAML with style: How to set image to Content control from code behind: MyIcon.Content = Icons_Default.Current[Icons_Default.Icon_IconFileName]; #> using namespace System; using namespace System.IO; using namespace System.Linq; using namespace System.Xml.Linq; using namespace System.Text.RegularExpressions; using namespace System.Collections.Generic; [Reflection.Assembly]::LoadWithPartialName("System.Xml.Linq") | Out-Null; Clear-Host; # ---------------------------------------------------------------------------- # Get current command path. [string]$current = $MyInvocation.MyCommand.Path; # Get calling command path. [string]$calling = @(Get-PSCallStack)[1].InvocationInfo.MyCommand.Path; # If executed directly then... if ($calling -ne "") { $current = $calling; } # ---------------------------------------------------------------------------- [FileInfo]$file = New-Object FileInfo($current); # Set public parameters. $global:scriptName = $file.Basename; $global:scriptPath = $file.Directory.FullName; # Change current directory. [Console]::WriteLine("Script Path: {0}", $scriptPath); [Environment]::CurrentDirectory = $scriptPath; Set-Location $scriptPath; # ---------------------------------------------------------------------------- [DirectoryInfo]$root = New-Object DirectoryInfo($scriptPath); # ---------------------------------------------------------------------------- function RemoveAttributes { param([XElement]$Node,[string]$Name); #---------------------------------------------------------- foreach ($attr in $Node.Attributes()) { if ($attr.Name -eq $Name) { $attr.Remove(); } } foreach ($child in $Node.Descendants()) { RemoveAttributes -Node $child -Name $Name; } } # ---------------------------------------------------------------------------- function FindParentFile { [OutputType([FileInfo[]])] param([string]$pattern); #---------------------------------------------------------- [DirectoryInfo]$di = new-Object DirectoryInfo $scriptPath; do { $files = $di.GetFiles($pattern); # Return if project files were found. if ($files.Count -gt 0) { return $files; } # Continue to parent. $di = $di.Parent; } while($null -ne $di); return $null; } # ---------------------------------------------------------------------------- function GetProjectValue { [OutputType([string])] param([string]$path, [string]$name); #---------------------------------------------------------- [string]$content = [File]::ReadAllText($path); [Regex]$rx = New-Object Regex("(?

<$name>)(?[^<]*)(?<\/$name>)"); $match = $rx.Match($content); if ($match.Success -eq $true) { return $match.Groups["v"].Value; } return $null; } # ---------------------------------------------------------------------------- function FindExistingFile { [OutputType([string])] param([string]$path); #---------------------------------------------------------- [FileInfo]$fi = $null; # Paths to look for executable. $ps = @( $path, "${env:ProgramFiles}\$path", "${env:ProgramFiles(x86)}\$path", "D:\Program Files\$path", "D:\Program Files (x86)\$dfe" ); foreach ($p in $ps) { # Fix dot notations. $fullPath = [Path]::GetFullPath($p); #Write-Host "Check... $fullPath"; if ([File]::Exists($fullPath)) { $fi = new-Object FileInfo $fullPath; break; } } return $fi; } # ---------------------------------------------------------------------------- function SHA256CheckSum { param($filePath); #---------------------------------------------------------- $SHA256 = [System.Security.Cryptography.SHA256Managed]::Create(); $fileStream = [System.IO.File]::OpenRead($filePath); $bytes = $SHA256.ComputeHash($fileStream); $hash = ($bytes|ForEach-Object ToString X2) -join ''; $fileStream.Dispose(); $SHA256.Dispose(); return $hash; } # ---------------------------------------------------------------------------- function FindProjectFile { [FileInfo[]]$list = FindParentFile "*.*proj"; if ($list -ne $null -and $list.Count -gt 0){ # Order by date descendign to most recent file. $list = [Enumerable]::OrderByDescending($list, [Func[object,object]]{ param($x) $x.LastWriteTime }); $list = [Enumerable]::ToArray($list); return $list[0]; } return $null; } # ---------------------------------------------------------------------------- # Show menu # ---------------------------------------------------------------------------- function ShowOptionsMenu { param($items); #---------------------------------------------------------- # Get local configurations. $keys = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"; Write-Host "Options:"; Write-Host; for ($i = 0; $i -lt $items.Count; $i++) { $item = $items[$i]; Write-Host " $($keys[$i]) - $($item)"; } Write-Host; $m = Read-Host -Prompt "Type option and press ENTER to continue"; $m = $m.ToUpper(); $keyIndex = $keys.IndexOf($m); # If wrong choice then... if ($keyIndex -eq -1) { return $null; } return $items[$keyIndex]; } # ---------------------------------------------------------------------------- Write-Host; #------------------------------ # Inkscape program location, which will be used for conversion from SVG format to XAML format. #------------------------------ $inkscape = FindExistingFile "Inkscape\bin\inkscape.exe"; if ($null -eq $inkscape) { Write-Host "Inkscape program not found!"; Write-Host "Download from https://inkscape.org/release/"; return; } Write-Host "Inkscape: $($inkscape.FullName)"; #------------------------------ # Get Project file. #------------------------------ [FileInfo]$project = FindProjectFile; if ($null -eq $project) { Write-Host "Project file not found."; return; } Write-Host "Project: $($project.FullName)"; #------------------------------ # Get Default namespace. #------------------------------ # Get from project file. $defaultNamespace = GetProjectValue $project.FullName "RootNamespace"; # If default namespace not found. if ("" -eq "$defaultNamespace") { # Visual studio use Project file name as default assembly and root namespace. $defaultNamespace = $project.BaseName; } # If namespace not found then... if ("" -eq "$defaultNamespace") { Write-Host "Please provide default namespace"; $defaultNamespace = Host-Read; } #Write-Host "Default Namespace: $defaultNamespace"; # Get Relative namespace. $relativeNamespace = $scriptPath.Substring($project.Directory.FullName.Length).Replace("\", "."); #Write-Host "Relative Namespace: $relativeNamespace"; $namespace = $defaultNamespace + $relativeNamespace; Write-Host; Write-Host "Namespace: $namespace"; #------------------------------ # Get Class Name #------------------------------ # Get forders with *.svg files inside. $dirs = $file.Directory.GetDirectories(); $dirNames = new-Object List[string]; foreach ($dir in $dirs) { $filePattern = "*.svg"; # If folder contains images then... $dirFiles = $dir.GetFiles($filePattern); if ($dirFiles.Length -gt 0){ $dirNames.Add($dir.Name); } #Write-Host "Source: $($dir.Name), $filePattern Files: $($dirFiles.Length)"; } [string]$className = $null; if ($dirNames.Count -eq 1){ $className = $dirNames[0]; } elseif ($dirNames.Count -gt 1){ $className = ShowOptionsMenu $dirNames; } if ($null -eq $className){ Write-Host "Folder with images not found!"; return; } $sourceDir = New-Object DirectoryInfo($root.FullName + "\" + $className); Write-Host "Class: $className"; Write-Host; Write-Host "Source: $($sourceDir.Name)\"; Write-Host "Target: $className.xaml + $className.xaml.cs"; Write-Host; pause; #------------------------------ # Generate images. #------------------------------ Write-Host; #Write-Host "Done. Press any key to continue..."; #$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown"); # Get files. $files = $sourceDir.GetFiles("*.svg"); # If no SVG images found then skip. if ($files.Length -eq 0){ continue; } # Create regular expressions for key and names generation. $RxAllExceptNumbersAndLetters = New-Object Regex("[^a-zA-Z0-9]", [RegexOptions]::IgnoreCase); $UsRx = New-Object Regex("_+"); # Crate output file name. $fileName = $RxAllExceptNumbersAndLetters.Replace($sourceDir.Name, "_"); $fileName = $UsRx.Replace($fileName, "_"); $fileName = "$className.xaml"; $fileNameCs = "$className.xaml.cs"; if ($files.Length -eq 1){ Write-Host "Convert $($files.Length) image:"; }else{ Write-Host "Convert $($files.Length) images:"; } # Start .xaml file. $xNs = "http://schemas.microsoft.com/winfx/2006/xaml"; if ([File]::Exists($fileName) -ne $true) { [File]::WriteAllText($fileName, "'); [File]::AppendAllText($fileName,"`r`n`r`n"); } [XDocument]$xaml = [XDocument]::Load($fileName); # Create list from existing nodes. $nodes = $xaml.Root.Nodes(); #$nodes = $xaml.Root.Elements("Viewbox").ToArray(); #$nodes = $xaml.DocumentElement.SelectNodes("/*[local-name() = 'ResourceDictionary']/*[local-name() = 'Viewbox']"); $nodeList = new-Object System.Collections.Generic.Dictionary[string`,object]; [XElement]$node = $null; foreach ($node in $nodes) { $nodeKey = $node.Attribute([XName]::Get("Key", $xNs)).Value; $nodeList.Add($nodeKey, $node); #Write-Host "Key $nodeKey"; } # Cleanup old nodes. $xaml.Root.RemoveNodes(); # Start .xaml.cs file. [File]::WriteAllText($fileNameCs, "using System.Windows;`r`n"); [File]::AppendAllText($fileNameCs, "`r`n"); [File]::AppendAllText($fileNameCs, "namespace $namespace`r`n"); [File]::AppendAllText($fileNameCs, "{`r`n"); [File]::AppendAllText($fileNameCs, "`tpartial class $className : ResourceDictionary`r`n"); [File]::AppendAllText($fileNameCs, "`t{`r`n"); [File]::AppendAllText($fileNameCs, "`t`tpublic $className()`r`n"); [File]::AppendAllText($fileNameCs, "`t`t{`r`n"); [File]::AppendAllText($fileNameCs, "`t`t`tInitializeComponent();`r`n"); [File]::AppendAllText($fileNameCs, "`t`t}`r`n"); [File]::AppendAllText($fileNameCs, "`r`n"); [File]::AppendAllText($fileNameCs, "`t`tpublic static $className Current => _Current = _Current ?? new $className();`r`n"); [File]::AppendAllText($fileNameCs, "`t`tprivate static $className _Current;`r`n"); [File]::AppendAllText($fileNameCs, "`r`n"); Write-Host; # Process files. for ($f = 0; $f -lt $files.Length; $f++) { $file = $files[$f]; $fileHash = SHA256CheckSum -filePath $file.FullName; $fileHashNodeName = "Tag"; $nodeXml = $null; # Create unique key. $key = "Icon_$($file.BaseName)"; # Get existing node or create new. $action = "Insert:"; if ($nodeList.ContainsKey($key)) { $action = "Update:"; [XElement]$oldNode = $nodeList[$key]; # Get hash of current node. $oldHash = $oldNode.Attribute([XName]::Get($fileHashNodeName)).Value; if ($oldHash -eq "SHA256_$fileHash") { $nodeXml = $oldNode; $action = "Keep: "; } } $isNew = ($null -eq $nodeXml); if ($isNew) { $nodeXml = Get-Content "$($file.FullName)" | & $inkscape --pipe --export-type=xaml | Out-String; } # Show file name. Write-Host "`t$action $($sourceDir.Name)\$($file.Name)"; # Remove name attributes. [XDocument]$node = [XDocument]::Parse($nodeXml); # Remove "Name" attributes. RemoveAttributes -Node $node.Root -Name "Name"; RemoveAttributes -Node $node.Root -Name "Key"; RemoveAttributes -Node $node.Root -Name "Shared"; # Add image XML to XAML document. $xaml.Root.Add($node.Root); # Remove old attributes. if ($isNew) { # Get node which was just added. $ln = $xaml.Root.LastNode; # Give node unique name. $ln.SetAttributeValue([XName]::Get("Key", $xNs), $key); # Make sure that image copy is made when it is used. $ln.SetAttributeValue([XName]::Get("Shared", $xNs), "False"); # Set file hash. $ln.SetAttributeValue([XName]::Get($fileHashNodeName), "SHA256_$fileHash"); } # Write unique name to code file. [File]::AppendAllText($fileNameCs, "`t`tpublic const string $key = nameof($key);`r`n"); } # Save XAML file. $xaml.Save($fileName); # End .xaml.cs file. [File]::AppendAllText($fileNameCs, "`r`n"); [File]::AppendAllText($fileNameCs, "`t}`r`n"); [File]::AppendAllText($fileNameCs, "}`r`n"); Write-Host; Write-Host "Done. Press any key to continue..."; $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown"); ================================================ FILE: FocusLogger/JocysCom/Controls/Themes/Default.xaml ================================================  #ff000000 #FFE6E6 #FFF2E6 #FFFFE6 #F2FFE6 #E6FFE6 #E6FFF2 #E6FFFF #E6F2FF #E6E6FF #F2E6FF #FFE6FF #FFE6F2 #D60000 #D66700 #D6D600 #67D600 #00D600 #00D667 #00D6D6 #0067D6 #0000D6 #6700D6 #D600D6 #D60067 -->